From 8af75a9508a4368c0f6392aefce27483f03aff5f Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Wed, 29 Jul 2026 16:33:37 +0200 Subject: [PATCH] security(pki): replace reflective persistence decoding with strict schemas --- .../pki/impl/fs/FilesystemPkiStore.java | 91 +- .../java/zeroecho/pki/impl/fs/FsCodec.java | 1362 +++++++++++------ .../pki/impl/fs/FsSnapshotExporter.java | 2 +- .../zeroecho/pki/impl/fs/package-info.java | 6 + .../fs/FilesystemPkiStoreOwnershipTest.java | 3 +- .../pki/impl/fs/FilesystemPkiStoreTest.java | 69 + .../fs/FilesystemSignWorkflowStoreTest.java | 39 +- .../zeroecho/pki/impl/fs/FsCodecTest.java | 459 ++---- 8 files changed, 1200 insertions(+), 831 deletions(-) diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index 2798bde..530bbec 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -120,9 +120,11 @@ import zeroecho.pki.spi.store.SignWorkflowStore; *

Security Notes

* *

- * This reference implementation stores objects as-is. It does not implement - * encryption at rest. It also must not persist private key material; higher - * layers must respect the SPI security requirements. + * This reference implementation stores domain objects through a closed, + * current-version schema. It does not implement encryption at rest. It also + * must not persist private key material; higher layers must respect the SPI + * security requirements. Earlier pre-release formats are rejected rather than + * migrated. *

* *

@@ -135,9 +137,9 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); - private static final String VERSION_V1 = "v1"; + /* package */ static final String CURRENT_STORE_VERSION = "v2"; private static final int SIGN_RECORD_MAGIC = 0x5A455352; - private static final int SIGN_RECORD_VERSION = 1; + private static final int CURRENT_SIGN_RECORD_VERSION = 2; private static final int SIGN_RECORD_HEADER_BYTES = Integer.BYTES * 2; private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:"; private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64; @@ -248,21 +250,21 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { PkiId caId = record.caId(); Path current = this.paths.caCurrent(caId); - writeWithHistory(this.paths.caHistoryDir(caId), current, FsCodec.encode(record), this.options.caHistoryPolicy(), - "CA", FsUtil.safeId(caId)); + writeWithHistory(this.paths.caHistoryDir(caId), current, FsCodec.encode(FsCodec.CA_RECORD, record), + this.options.caHistoryPolicy(), "CA", FsUtil.safeId(caId)); } @Override public Optional getCa(final PkiId caId) { Objects.requireNonNull(caId, "caId"); Path p = this.paths.caCurrent(caId); - return readOptional(p, CaRecord.class); + return readOptional(p, FsCodec.CA_RECORD); } @Override public List listCas() { Path casRoot = this.paths.root().resolve("cas").resolve("by-id"); - return listCurrentRecords(casRoot, CaRecord.class); + return listCurrentRecords(casRoot, FsCodec.CA_RECORD); } @Override @@ -270,26 +272,27 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { Objects.requireNonNull(credential, "credential"); PkiId id = credential.credentialId(); Path p = this.paths.credentialPath(id); - writeOnce(p, FsCodec.encode(credential), "CREDENTIAL", FsUtil.safeId(id)); + writeOnce(p, FsCodec.encode(FsCodec.CREDENTIAL, credential), "CREDENTIAL", FsUtil.safeId(id)); } @Override public Optional getCredential(final PkiId credentialId) { Objects.requireNonNull(credentialId, "credentialId"); - return readOptional(this.paths.credentialPath(credentialId), Credential.class); + return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL); } @Override public void putRequest(final ParsedCertificationRequest request) { Objects.requireNonNull(request, "request"); PkiId id = request.requestId(); - writeOnce(this.paths.requestPath(id), FsCodec.encode(request), "REQUEST", FsUtil.safeId(id)); + writeOnce(this.paths.requestPath(id), FsCodec.encode(FsCodec.PARSED_REQUEST, request), "REQUEST", + FsUtil.safeId(id)); } @Override public Optional getRequest(final PkiId requestId) { Objects.requireNonNull(requestId, "requestId"); - return readOptional(this.paths.requestPath(requestId), ParsedCertificationRequest.class); + return readOptional(this.paths.requestPath(requestId), FsCodec.PARSED_REQUEST); } @Override @@ -298,33 +301,34 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { PkiId credId = record.credentialId(); Path current = this.paths.revocationCurrent(credId); - writeWithHistory(this.paths.revocationHistoryDir(credId), current, FsCodec.encode(record), + writeWithHistory(this.paths.revocationHistoryDir(credId), current, FsCodec.encode(FsCodec.REVOCATION, record), this.options.revocationHistoryPolicy(), "REVOCATION", FsUtil.safeId(credId)); } @Override public Optional getRevocation(final PkiId credentialId) { Objects.requireNonNull(credentialId, "credentialId"); - return readOptional(this.paths.revocationCurrent(credentialId), RevokedRecord.class); + return readOptional(this.paths.revocationCurrent(credentialId), FsCodec.REVOCATION); } @Override public List listRevocations() { Path root = this.paths.root().resolve("revocations").resolve("by-credential"); - return listCurrentRecords(root, RevokedRecord.class); + return listCurrentRecords(root, FsCodec.REVOCATION); } @Override public void putStatusObject(final StatusObject object) { Objects.requireNonNull(object, "object"); PkiId id = object.statusObjectId(); - writeOnce(this.paths.statusObjectPath(id), FsCodec.encode(object), "STATUS_OBJECT", FsUtil.safeId(id)); + writeOnce(this.paths.statusObjectPath(id), FsCodec.encode(FsCodec.STATUS_OBJECT, object), "STATUS_OBJECT", + FsUtil.safeId(id)); } @Override public Optional getStatusObject(final PkiId statusObjectId) { Objects.requireNonNull(statusObjectId, "statusObjectId"); - return readOptional(this.paths.statusObjectPath(statusObjectId), StatusObject.class); + return readOptional(this.paths.statusObjectPath(statusObjectId), FsCodec.STATUS_OBJECT); } @Override @@ -335,7 +339,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { // This is acceptable for a reference implementation; indexes can be added // later. Path byId = this.paths.root().resolve("status").resolve("by-id"); - List all = listBinaryFiles(byId, StatusObject.class); + List all = listBinaryFiles(byId, FsCodec.STATUS_OBJECT); List out = new ArrayList<>(); for (StatusObject o : all) { if (issuerCaId.equals(o.issuerCaId())) { @@ -349,13 +353,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { public void putPublicationRecord(final PublicationRecord record) { Objects.requireNonNull(record, "record"); PkiId id = record.publicationId(); - writeOnce(this.paths.publicationPath(id), FsCodec.encode(record), "PUBLICATION", FsUtil.safeId(id)); + writeOnce(this.paths.publicationPath(id), FsCodec.encode(FsCodec.PUBLICATION, record), "PUBLICATION", + FsUtil.safeId(id)); } @Override public List listPublicationRecords() { Path byId = this.paths.root().resolve("publications").resolve("by-id"); - return listBinaryFiles(byId, PublicationRecord.class); + return listBinaryFiles(byId, FsCodec.PUBLICATION); } @Override @@ -364,7 +369,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { String profileId = profile.profileId(); Path current = this.paths.profileCurrent(profileId); - writeWithHistory(this.paths.profileHistoryDir(profileId), current, FsCodec.encode(profile), + writeWithHistory(this.paths.profileHistoryDir(profileId), current, + FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile), this.options.profileHistoryPolicy(), "PROFILE", FsUtil.safeSegment(profileId)); } @@ -373,26 +379,27 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { if (profileId == null || profileId.isBlank()) { throw new IllegalArgumentException("profileId must not be null/blank"); } - return readOptional(this.paths.profileCurrent(profileId), CertificateProfile.class); + return readOptional(this.paths.profileCurrent(profileId), FsCodec.CERTIFICATE_PROFILE); } @Override public List listProfiles() { Path root = this.paths.root().resolve("profiles").resolve("by-id"); - return listCurrentRecords(root, CertificateProfile.class); + return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE); } @Override public void putPolicyTrace(final PolicyTrace trace) { Objects.requireNonNull(trace, "trace"); PkiId id = trace.decisionId(); - writeOnce(this.paths.policyTracePath(id), FsCodec.encode(trace), "POLICY_TRACE", FsUtil.safeId(id)); + writeOnce(this.paths.policyTracePath(id), FsCodec.encode(FsCodec.POLICY_TRACE, trace), "POLICY_TRACE", + FsUtil.safeId(id)); } @Override public Optional getPolicyTrace(final PkiId decisionId) { Objects.requireNonNull(decisionId, "decisionId"); - return readOptional(this.paths.policyTracePath(decisionId), PolicyTrace.class); + return readOptional(this.paths.policyTracePath(decisionId), FsCodec.POLICY_TRACE); } // ------------------------------------------------------------------------- @@ -407,14 +414,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { Path current = this.paths.workflowCurrent(opId); // Never log payload; writeWithHistory logs only type + safe identifiers. - writeWithHistory(this.paths.workflowHistoryDir(opId), current, FsCodec.encode(record), + writeWithHistory(this.paths.workflowHistoryDir(opId), current, FsCodec.encode(FsCodec.WORKFLOW_STATE, record), this.options.workflowHistoryPolicy(), "WORKFLOW", FsUtil.safeId(opId)); } @Override public Optional getWorkflowState(final PkiId opId) { Objects.requireNonNull(opId, "opId"); - return readOptional(this.paths.workflowCurrent(opId), WorkflowStateRecord.class); + return readOptional(this.paths.workflowCurrent(opId), FsCodec.WORKFLOW_STATE); } @Override @@ -432,7 +439,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { @Override public List listWorkflowStates() { // List by operation directory (workflows/by-op//current.bin) - return listCurrentRecords(this.paths.workflowRoot(), WorkflowStateRecord.class); + return listCurrentRecords(this.paths.workflowRoot(), FsCodec.WORKFLOW_STATE); } @Override @@ -710,12 +717,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { if (input.getInt() != SIGN_RECORD_MAGIC) { throw new IllegalStateException("Invalid signing record envelope"); } - if (input.getInt() != SIGN_RECORD_VERSION) { + if (input.getInt() != CURRENT_SIGN_RECORD_VERSION) { throw new IllegalStateException("Unsupported signing record version"); } byte[] payload = new byte[input.remaining()]; input.get(payload); - return FsCodec.decode(payload, SignWorkflowStore.Record.class); + return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload); } catch (IOException ex) { throw new IllegalStateException("Failed to read authoritative signing record", ex); } @@ -796,10 +803,10 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private void writeSignRecord(SignWorkflowStore.Record record) { validateSignRecord(record.submissionId(), record); try { - byte[] payload = FsCodec.encode(record); + byte[] payload = FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, record); ByteBuffer envelope = ByteBuffer.allocate(SIGN_RECORD_HEADER_BYTES + payload.length); envelope.putInt(SIGN_RECORD_MAGIC); - envelope.putInt(SIGN_RECORD_VERSION); + envelope.putInt(CURRENT_SIGN_RECORD_VERSION); envelope.put(payload); FsOperations.writeAtomic(paths.signWorkflowPath(record.submissionId()), envelope.array()); } catch (IOException ex) { @@ -970,7 +977,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private static IllegalStateException invalidSignRecord(SignWorkflowStore.Record record, String code) { return new IllegalStateException("Signing record corruption: type=sign-workflow version=" - + SIGN_RECORD_VERSION + " state=" + record.state() + " code=" + code); + + CURRENT_SIGN_RECORD_VERSION + " state=" + record.state() + " code=" + code); } private static void requirePositive(Duration value, String name) { @@ -1145,28 +1152,28 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private void ensureVersionFile() throws IOException { Path vf = this.paths.versionFile(); if (!Files.exists(vf)) { - FsOperations.writeAtomic(vf, VERSION_V1.getBytes()); + FsOperations.writeAtomic(vf, CURRENT_STORE_VERSION.getBytes()); return; } String ver = Files.readString(vf).trim(); - if (!VERSION_V1.equals(ver)) { + if (!CURRENT_STORE_VERSION.equals(ver)) { throw new IllegalStateException("unsupported store version: " + ver); } } - private static Optional readOptional(final Path path, final Class type) { + private static Optional readOptional(final Path path, final FsCodec.Schema schema) { try { if (!Files.exists(path)) { return Optional.empty(); } byte[] data = FsOperations.readAll(path); - return Optional.of(FsCodec.decode(data, type)); + return Optional.of(FsCodec.decode(schema, data)); } catch (IOException e) { throw new IllegalStateException("read failed: " + path, e); } } - private static List listBinaryFiles(final Path byIdDir, final Class type) { + private static List listBinaryFiles(final Path byIdDir, final FsCodec.Schema schema) { if (!Files.isDirectory(byIdDir)) { return List.of(); } @@ -1174,7 +1181,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { return Files.list(byIdDir).filter(Files::isRegularFile) .sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> { try { - return FsCodec.decode(FsOperations.readAll(p), type); + return FsCodec.decode(schema, FsOperations.readAll(p)); } catch (IOException e) { throw new IllegalStateException("read failed: " + p, e); } @@ -1184,7 +1191,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } - private static List listCurrentRecords(final Path byIdDir, final Class type) { + private static List listCurrentRecords(final Path byIdDir, final FsCodec.Schema schema) { if (!Files.isDirectory(byIdDir)) { return List.of(); } @@ -1196,7 +1203,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { for (Path entityDir : entityDirs) { Path current = entityDir.resolve(FsPaths.CURRENT_FILE); if (Files.exists(current)) { - out.add(FsCodec.decode(FsOperations.readAll(current), type)); + out.add(FsCodec.decode(schema, FsOperations.readAll(current))); } } return out; diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java index c7d918b..10d6e02 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -23,555 +23,1021 @@ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ******************************************************************************/ package zeroecho.pki.impl.fs; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.lang.invoke.MethodHandles; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.RecordComponent; import java.time.Duration; import java.time.Instant; +import java.time.DateTimeException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import zeroecho.core.io.Util; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.IssuerRef; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.ca.CaKind; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialStatus; +import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; +import zeroecho.pki.api.orch.WorkflowStateRecord; +import zeroecho.pki.api.policy.PolicyTrace; +import zeroecho.pki.api.policy.PolicyTraceStep; +import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.publication.PublicationRecord; +import zeroecho.pki.api.publication.PublicationStatus; +import zeroecho.pki.api.publication.PublicationTarget; +import zeroecho.pki.api.publication.PublicationTargetType; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.revocation.RevokedRecord; +import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.store.SignWorkflowStore; /** - * Compact binary codec for filesystem persistence. - * - *

Encoding format

- *

- * The codec supports two on-wire formats: - *

- * + * Closed binary codec for the current filesystem persistence schema. * *

- * The compact format eliminates the pathological overhead of writing a full - * Java class name for every scalar value (e.g., {@code boolean}). It also - * substantially reduces class-loading activity during decode. + * Every payload carries a fixed magic value, one current codec version, and one + * of ten explicit top-level type identifiers. Nested values are decoded only + * through the schema selected by trusted store code. Java class names are never + * persisted or resolved, and this codec performs no runtime class loading, + * reflective construction, or factory discovery. *

* - *

Type compatibility

*

- * During decode, the codec enforces that the encoded value type is compatible - * with the expected type. Primitive/wrapper pairs (e.g., - * {@code boolean}/{@link Boolean}) are treated as compatible because values are - * boxed when represented as {@link Object}. + * Pre-release payloads written by earlier class-name-based codecs are rejected. + * No compatibility or migration decoder is provided. Lists and optional values + * carry their exact element schema, and attribute sets are represented + * structurally and decoded to {@link SimpleAttributeSet}. *

*/ -@SuppressWarnings("PMD.CyclomaticComplexity") +@SuppressWarnings("PMD.CouplingBetweenObjects") final class FsCodec { /* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024; + /* package */ static final int CURRENT_CODEC_VERSION = 2; - private static final Map, Class> PRIMITIVE_TO_WRAPPER = Map.of(boolean.class, Boolean.class, byte.class, - Byte.class, short.class, Short.class, int.class, Integer.class, long.class, Long.class, char.class, - Character.class, float.class, Float.class, double.class, Double.class); + private static final int CODEC_MAGIC = 0x5A454346; + private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES; - /** - * Magic byte that marks the compact format. - * - *

- * This value is chosen to be extremely unlikely as a first byte of the legacy - * UTF-8 class-name length prefix. Class names are short; legacy length prefix - * first byte will be < 0x80. - *

- */ - private static final int MAGIC_COMPACT = 0xFF; + private static final int TOP_CA_RECORD = 1; + private static final int TOP_CREDENTIAL = 2; + private static final int TOP_PARSED_REQUEST = 3; + private static final int TOP_REVOCATION = 4; + private static final int TOP_STATUS_OBJECT = 5; + private static final int TOP_PUBLICATION = 6; + private static final int TOP_CERTIFICATE_PROFILE = 7; + private static final int TOP_POLICY_TRACE = 8; + private static final int TOP_WORKFLOW_STATE = 9; + private static final int TOP_SIGN_WORKFLOW_RECORD = 10; - // Compact tags (1 byte). Keep stable. - private static final int TAG_STRING = 1; - private static final int TAG_INT = 2; - private static final int TAG_LONG = 3; - private static final int TAG_BOOL = 4; - private static final int TAG_BYTES = 5; - private static final int TAG_INSTANT = 6; - private static final int TAG_DURATION = 7; - private static final int TAG_ENUM = 8; - private static final int TAG_RECORD = 9; - private static final int TAG_FALLBACK_STRING = 10; - private static final int TAG_LIST = 11; - private static final int TAG_SET = 12; - private static final int TAG_OPTIONAL = 13; + private static final int TYPE_STRING = 1; + private static final int TYPE_BOOLEAN = 2; + private static final int TYPE_LONG = 3; + private static final int TYPE_BYTES = 4; + private static final int TYPE_INSTANT = 5; + private static final int TYPE_DURATION = 6; + private static final int TYPE_LIST = 7; + private static final int TYPE_OPTIONAL = 8; + private static final int TYPE_PKI_ID = 20; + private static final int TYPE_KEY_REF = 21; + private static final int TYPE_SUBJECT_REF = 22; + private static final int TYPE_ISSUER_REF = 23; + private static final int TYPE_FORMAT_ID = 24; + private static final int TYPE_VALIDITY = 25; + private static final int TYPE_ENCODED_OBJECT = 26; + private static final int TYPE_PRINCIPAL = 27; + private static final int TYPE_ATTRIBUTE_ID = 28; + private static final int TYPE_POLICY_TRACE_STEP = 29; + private static final int TYPE_PUBLICATION_TARGET = 30; + private static final int TYPE_ATTRIBUTE_SET = 31; + private static final int TYPE_ATTRIBUTE_VALUE = 33; + private static final int TYPE_CREDENTIAL_RECORD = 34; + private static final int TYPE_ENCODING_ENUM = 50; + private static final int TYPE_CA_KIND_ENUM = 51; + private static final int TYPE_CA_STATE_ENUM = 52; + private static final int TYPE_CREDENTIAL_STATUS_ENUM = 53; + private static final int TYPE_REVOCATION_REASON_ENUM = 54; + private static final int TYPE_STATUS_OBJECT_TYPE_ENUM = 55; + private static final int TYPE_PUBLICATION_TARGET_TYPE_ENUM = 56; + private static final int TYPE_PUBLICATION_STATUS_ENUM = 57; + private static final int TYPE_DURABILITY_POLICY_ENUM = 58; + private static final int TYPE_SIGN_STATE_ENUM = 59; + + private static final int ATTRIBUTE_STRING = 1; + private static final int ATTRIBUTE_BOOLEAN = 2; + private static final int ATTRIBUTE_INTEGER = 3; + private static final int ATTRIBUTE_INSTANT = 4; + private static final int ATTRIBUTE_BYTES = 5; + private static final int MARKER_ABSENT = 0; + private static final int MARKER_PRESENT = 1; + + private static final ValueSchema STRING = valueSchema(TYPE_STRING, Writer::writeString, + Reader::readString); + private static final ValueSchema BOOLEAN = valueSchema(TYPE_BOOLEAN, Writer::writeBoolean, + Reader::readBoolean); + private static final ValueSchema LONG = valueSchema(TYPE_LONG, Writer::writeLong, Reader::readLong); + private static final ValueSchema BYTES = valueSchema(TYPE_BYTES, Writer::writeBytes, Reader::readBytes); + private static final ValueSchema INSTANT = valueSchema(TYPE_INSTANT, Writer::writeInstant, + Reader::readInstant); + private static final ValueSchema DURATION = valueSchema(TYPE_DURATION, Writer::writeDuration, + Reader::readDuration); + + private static final ValueSchema ENCODING = enumSchema(TYPE_ENCODING_ENUM, + value -> switch (value) { + case DER -> 1; + case PEM -> 2; + case BINARY -> 3; + }, + code -> switch (code) { + case 1 -> Encoding.DER; + case 2 -> Encoding.PEM; + case 3 -> Encoding.BINARY; + default -> throw unknownEnum("Encoding", code); + }); + private static final ValueSchema CA_KIND = enumSchema(TYPE_CA_KIND_ENUM, + value -> switch (value) { + case ROOT -> 1; + case INTERMEDIATE -> 2; + }, + code -> switch (code) { + case 1 -> CaKind.ROOT; + case 2 -> CaKind.INTERMEDIATE; + default -> throw unknownEnum("CaKind", code); + }); + private static final ValueSchema CA_STATE = enumSchema(TYPE_CA_STATE_ENUM, + value -> switch (value) { + case ACTIVE -> 1; + case RETIRED -> 2; + case COMPROMISED -> 3; + case DISABLED -> 4; + }, + code -> switch (code) { + case 1 -> CaState.ACTIVE; + case 2 -> CaState.RETIRED; + case 3 -> CaState.COMPROMISED; + case 4 -> CaState.DISABLED; + default -> throw unknownEnum("CaState", code); + }); + private static final ValueSchema CREDENTIAL_STATUS = enumSchema(TYPE_CREDENTIAL_STATUS_ENUM, + value -> switch (value) { + case ISSUED -> 1; + case REVOKED -> 2; + case EXPIRED -> 3; + }, + code -> switch (code) { + case 1 -> CredentialStatus.ISSUED; + case 2 -> CredentialStatus.REVOKED; + case 3 -> CredentialStatus.EXPIRED; + default -> throw unknownEnum("CredentialStatus", code); + }); + private static final ValueSchema REVOCATION_REASON = enumSchema(TYPE_REVOCATION_REASON_ENUM, + value -> switch (value) { + case UNSPECIFIED -> 1; + case KEY_COMPROMISE -> 2; + case CA_COMPROMISE -> 3; + case AFFILIATION_CHANGED -> 4; + case SUPERSEDED -> 5; + case CESSATION_OF_OPERATION -> 6; + case CERTIFICATE_HOLD -> 7; + case REMOVE_FROM_CRL -> 8; + case PRIVILEGE_WITHDRAWN -> 9; + case AA_COMPROMISE -> 10; + }, + code -> switch (code) { + case 1 -> RevocationReason.UNSPECIFIED; + case 2 -> RevocationReason.KEY_COMPROMISE; + case 3 -> RevocationReason.CA_COMPROMISE; + case 4 -> RevocationReason.AFFILIATION_CHANGED; + case 5 -> RevocationReason.SUPERSEDED; + case 6 -> RevocationReason.CESSATION_OF_OPERATION; + case 7 -> RevocationReason.CERTIFICATE_HOLD; + case 8 -> RevocationReason.REMOVE_FROM_CRL; + case 9 -> RevocationReason.PRIVILEGE_WITHDRAWN; + case 10 -> RevocationReason.AA_COMPROMISE; + default -> throw unknownEnum("RevocationReason", code); + }); + private static final ValueSchema STATUS_OBJECT_TYPE = enumSchema(TYPE_STATUS_OBJECT_TYPE_ENUM, + value -> switch (value) { + case CRL -> 1; + case DELTA_CRL -> 2; + case OCSP -> 3; + case REVOCATION_LIST -> 4; + }, + code -> switch (code) { + case 1 -> StatusObjectType.CRL; + case 2 -> StatusObjectType.DELTA_CRL; + case 3 -> StatusObjectType.OCSP; + case 4 -> StatusObjectType.REVOCATION_LIST; + default -> throw unknownEnum("StatusObjectType", code); + }); + private static final ValueSchema PUBLICATION_TARGET_TYPE = enumSchema( + TYPE_PUBLICATION_TARGET_TYPE_ENUM, + value -> switch (value) { + case FILESYSTEM -> 1; + case LDAP -> 2; + case HTTP -> 3; + case OBJECT_STORE -> 4; + case CUSTOM -> 5; + }, + code -> switch (code) { + case 1 -> PublicationTargetType.FILESYSTEM; + case 2 -> PublicationTargetType.LDAP; + case 3 -> PublicationTargetType.HTTP; + case 4 -> PublicationTargetType.OBJECT_STORE; + case 5 -> PublicationTargetType.CUSTOM; + default -> throw unknownEnum("PublicationTargetType", code); + }); + private static final ValueSchema PUBLICATION_STATUS = enumSchema(TYPE_PUBLICATION_STATUS_ENUM, + value -> switch (value) { + case PUBLISHED -> 1; + case SKIPPED -> 2; + case FAILED -> 3; + }, + code -> switch (code) { + case 1 -> PublicationStatus.PUBLISHED; + case 2 -> PublicationStatus.SKIPPED; + case 3 -> PublicationStatus.FAILED; + default -> throw unknownEnum("PublicationStatus", code); + }); + private static final ValueSchema DURABILITY_POLICY = enumSchema( + TYPE_DURABILITY_POLICY_ENUM, + value -> switch (value) { + case STRICT_ABORT_ON_RESTART -> 1; + case DURABLE_MIN_STATE -> 2; + case DURABLE_ENCRYPTED_STATE -> 3; + }, + code -> switch (code) { + case 1 -> OrchestrationDurabilityPolicy.STRICT_ABORT_ON_RESTART; + case 2 -> OrchestrationDurabilityPolicy.DURABLE_MIN_STATE; + case 3 -> OrchestrationDurabilityPolicy.DURABLE_ENCRYPTED_STATE; + default -> throw unknownEnum("OrchestrationDurabilityPolicy", code); + }); + private static final ValueSchema SIGN_STATE = valueSchema(TYPE_SIGN_STATE_ENUM, + (writer, value) -> writer.writeUnsignedByte(value.persistentCode()), + reader -> { + int code = reader.readUnsignedByte(); + try { + return SignWorkflowStore.State.fromPersistentCode(code); + } catch (IllegalArgumentException ex) { + throw new IOException("unknown SignWorkflowStore.State code " + code, ex); + } + }); + + private static final ValueSchema PKI_ID = valueSchema(TYPE_PKI_ID, + (writer, value) -> writer.writeValue(STRING, value.value()), + reader -> new PkiId(reader.readValue(STRING))); + private static final ValueSchema KEY_REF = valueSchema(TYPE_KEY_REF, + (writer, value) -> writer.writeValue(STRING, value.value()), + reader -> new KeyRef(reader.readValue(STRING))); + private static final ValueSchema SUBJECT_REF = valueSchema(TYPE_SUBJECT_REF, + (writer, value) -> writer.writeValue(STRING, value.value()), + reader -> new SubjectRef(reader.readValue(STRING))); + private static final ValueSchema ISSUER_REF = valueSchema(TYPE_ISSUER_REF, + (writer, value) -> writer.writeValue(PKI_ID, value.caId()), + reader -> new IssuerRef(reader.readValue(PKI_ID))); + private static final ValueSchema FORMAT_ID = valueSchema(TYPE_FORMAT_ID, + (writer, value) -> writer.writeValue(STRING, value.value()), + reader -> new FormatId(reader.readValue(STRING))); + private static final ValueSchema VALIDITY = valueSchema(TYPE_VALIDITY, + (writer, value) -> { + writer.writeValue(INSTANT, value.notBefore()); + writer.writeValue(INSTANT, value.notAfter()); + }, + reader -> new Validity(reader.readValue(INSTANT), reader.readValue(INSTANT))); + private static final ValueSchema ENCODED_OBJECT = valueSchema(TYPE_ENCODED_OBJECT, + FsCodec::writeEncodedObject, FsCodec::readEncodedObject); + private static final ValueSchema PRINCIPAL = valueSchema(TYPE_PRINCIPAL, + (writer, value) -> { + writer.writeValue(STRING, value.type()); + writer.writeValue(STRING, value.name()); + }, + reader -> new Principal(reader.readValue(STRING), reader.readValue(STRING))); + private static final ValueSchema ATTRIBUTE_ID = valueSchema(TYPE_ATTRIBUTE_ID, + (writer, value) -> writer.writeValue(STRING, value.value()), + reader -> new AttributeId(reader.readValue(STRING))); + + private static final ValueSchema ATTRIBUTE_VALUE = valueSchema(TYPE_ATTRIBUTE_VALUE, + FsCodec::writeAttributeValue, FsCodec::readAttributeValue); + private static final ValueSchema> ATTRIBUTE_VALUES = listOf(ATTRIBUTE_VALUE); + private static final ValueSchema ATTRIBUTE_SET = valueSchema(TYPE_ATTRIBUTE_SET, + FsCodec::writeAttributeSet, FsCodec::readAttributeSet); + + private static final ValueSchema> STRINGS = listOf(STRING); + private static final ValueSchema> ATTRIBUTE_IDS = listOf(ATTRIBUTE_ID); + private static final ValueSchema POLICY_TRACE_STEP = valueSchema(TYPE_POLICY_TRACE_STEP, + (writer, value) -> { + writer.writeValue(STRING, value.ruleId()); + writer.writeValue(STRING, value.outcome()); + writer.writeValue(STRINGS, value.notes()); + }, + reader -> new PolicyTraceStep(reader.readValue(STRING), reader.readValue(STRING), + reader.readValue(STRINGS))); + private static final ValueSchema> POLICY_TRACE_STEPS = listOf(POLICY_TRACE_STEP); + private static final ValueSchema PUBLICATION_TARGET = valueSchema(TYPE_PUBLICATION_TARGET, + (writer, value) -> { + writer.writeValue(PUBLICATION_TARGET_TYPE, value.type()); + writer.writeValue(STRING, value.targetId()); + writer.writeValue(ATTRIBUTE_SET, value.attributes()); + }, + reader -> new PublicationTarget(reader.readValue(PUBLICATION_TARGET_TYPE), reader.readValue(STRING), + reader.readValue(ATTRIBUTE_SET))); + + private static final ValueSchema> OPTIONAL_VALIDITY = optionalOf(VALIDITY); + private static final ValueSchema> OPTIONAL_STRING = optionalOf(STRING); + private static final ValueSchema> OPTIONAL_INSTANT = optionalOf(INSTANT); + private static final ValueSchema> OPTIONAL_DURATION = optionalOf(DURATION); + private static final ValueSchema> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT); + + private static final ValueSchema CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD, + FsCodec::writeCredential, FsCodec::readCredential); + private static final ValueSchema> CREDENTIALS = listOf(CREDENTIAL_VALUE); + + /* package */ static final Schema CA_RECORD = topLevel(TOP_CA_RECORD, "CA_RECORD", + valueSchema(100, FsCodec::writeCaRecord, FsCodec::readCaRecord)); + /* package */ static final Schema CREDENTIAL = topLevel(TOP_CREDENTIAL, "CREDENTIAL", + CREDENTIAL_VALUE); + /* package */ static final Schema PARSED_REQUEST = topLevel(TOP_PARSED_REQUEST, + "PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest)); + /* package */ static final Schema REVOCATION = topLevel(TOP_REVOCATION, "REVOCATION", + valueSchema(102, FsCodec::writeRevocation, FsCodec::readRevocation)); + /* package */ static final Schema STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT", + valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject)); + /* package */ static final Schema PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION", + valueSchema(104, FsCodec::writePublication, FsCodec::readPublication)); + /* package */ static final Schema CERTIFICATE_PROFILE = topLevel(TOP_CERTIFICATE_PROFILE, + "CERTIFICATE_PROFILE", valueSchema(105, FsCodec::writeCertificateProfile, + FsCodec::readCertificateProfile)); + /* package */ static final Schema POLICY_TRACE = topLevel(TOP_POLICY_TRACE, "POLICY_TRACE", + valueSchema(106, FsCodec::writePolicyTrace, FsCodec::readPolicyTrace)); + /* package */ static final Schema WORKFLOW_STATE = topLevel(TOP_WORKFLOW_STATE, + "WORKFLOW_STATE", valueSchema(107, FsCodec::writeWorkflowState, FsCodec::readWorkflowState)); + /* package */ static final Schema SIGN_WORKFLOW_RECORD = topLevel( + TOP_SIGN_WORKFLOW_RECORD, "SIGN_WORKFLOW_RECORD", + valueSchema(108, FsCodec::writeSignWorkflowRecord, FsCodec::readSignWorkflowRecord)); + + private static final Map> TOP_LEVEL_SCHEMAS = Map.ofEntries( + Map.entry(TOP_CA_RECORD, CA_RECORD), + Map.entry(TOP_CREDENTIAL, CREDENTIAL), + Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST), + Map.entry(TOP_REVOCATION, REVOCATION), + Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), + Map.entry(TOP_PUBLICATION, PUBLICATION), + Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE), + Map.entry(TOP_POLICY_TRACE, POLICY_TRACE), + Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), + Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD)); private FsCodec() { // utility } - /* default */ static byte[] encode(final T value) { + /* package */ static byte[] encode(final Schema schema, final T value) { + Objects.requireNonNull(schema, "schema"); Objects.requireNonNull(value, "value"); try { - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - writeAnyCompact(bos, value); - return bos.toByteArray(); - } catch (IOException e) { - throw new IllegalStateException("encoding failed: " + value.getClass().getName(), e); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Writer writer = new Writer(output); + writer.writeInt(CODEC_MAGIC); + writer.writeUnsignedByte(CURRENT_CODEC_VERSION); + writer.writeUnsignedByte(schema.typeId); + schema.valueSchema.encoder.encode(writer, value); + return output.toByteArray(); + } catch (IOException ex) { + throw new IllegalStateException("Encoding failed: schema=" + schema.name + " code=ENCODE_FAILED", ex); } } - /* default */ static T decode(final byte[] data, final Class expectedType) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(expectedType, "expectedType"); + /* package */ static T decode(final Schema schema, final byte[] encoded) { + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(encoded, "encoded"); try { - ByteArrayInputStream bis = new ByteArrayInputStream(data); - Object decoded = readAny(bis, expectedType); - if (bis.available() != 0) { - throw new IllegalStateException("trailing data after " + expectedType.getName()); - } - return expectedType.cast(decoded); - } catch (IOException e) { - throw new IllegalStateException("decoding failed: " + expectedType.getName(), e); + return decodeCurrentPayload(schema, encoded); + } catch (IOException | IllegalArgumentException ex) { + throw new IllegalStateException( + "Decoding failed: schema=" + schema.name + " code=INVALID_CURRENT_PAYLOAD", ex); } } - private static void writeAnyCompact(final OutputStream out, final Object value) throws IOException { - Objects.requireNonNull(out, "out"); - Objects.requireNonNull(value, "value"); + private static T decodeCurrentPayload(Schema schema, byte[] encoded) throws IOException { + ByteArrayInputStream input = new ByteArrayInputStream(encoded); + Reader reader = new Reader(input); + if (reader.readInt() != CODEC_MAGIC) { + throw new IOException("codec magic mismatch"); + } + int version = reader.readUnsignedByte(); + if (version != CURRENT_CODEC_VERSION) { + throw new IOException("unsupported codec version"); + } + int typeId = reader.readUnsignedByte(); + Schema encodedSchema = TOP_LEVEL_SCHEMAS.get(typeId); + if (encodedSchema == null) { + throw new IOException("unknown top-level type"); + } + if (encodedSchema.typeId != schema.typeId) { + throw new IOException("top-level type mismatch"); + } + T decoded = schema.valueSchema.decoder.decode(reader); + if (input.available() != 0) { + throw new IOException("trailing payload data"); + } + return decoded; + } - out.write(MAGIC_COMPACT); + private static void writeEncodedObject(Writer writer, EncodedObject value) throws IOException { + writer.writeValue(ENCODING, value.encoding()); + byte[] bytes = value.bytes(); + try { + writer.writeValue(BYTES, bytes); + } finally { + Arrays.fill(bytes, (byte) 0); + } + } + private static EncodedObject readEncodedObject(Reader reader) throws IOException { + Encoding encoding = reader.readValue(ENCODING); + byte[] bytes = reader.readValue(BYTES); + try { + return new EncodedObject(encoding, bytes); + } finally { + Arrays.fill(bytes, (byte) 0); + } + } + + private static void writeAttributeValue(Writer writer, AttributeValue value) throws IOException { switch (value) { - case String s -> writeStringCompact(out, s); - case Integer i -> writeIntegerCompact(out, i); - case Long l -> writeLongCompact(out, l); - case Boolean b -> writeBooleanCompact(out, b); - case byte[] bytes -> writeBytesCompact(out, bytes); - case Instant instant -> writeInstantCompact(out, instant); - case Duration duration -> writeDurationCompact(out, duration); - case java.util.List list -> writeListCompact(out, list); - case java.util.Set set -> writeSetCompact(out, set); - case Optional optional -> writeOptionalCompact(out, optional); - default -> writeComplexCompact(out, value); + case AttributeValue.StringValue stringValue -> { + writer.writeUnsignedByte(ATTRIBUTE_STRING); + writer.writeValue(STRING, stringValue.value()); + } + case AttributeValue.BooleanValue booleanValue -> { + writer.writeUnsignedByte(ATTRIBUTE_BOOLEAN); + writer.writeValue(BOOLEAN, booleanValue.value()); + } + case AttributeValue.IntegerValue integerValue -> { + writer.writeUnsignedByte(ATTRIBUTE_INTEGER); + writer.writeValue(LONG, integerValue.value()); + } + case AttributeValue.InstantValue instantValue -> { + writer.writeUnsignedByte(ATTRIBUTE_INSTANT); + writer.writeValue(INSTANT, instantValue.value()); + } + case AttributeValue.BytesValue bytesValue -> { + writer.writeUnsignedByte(ATTRIBUTE_BYTES); + byte[] copy = bytesValue.value().clone(); + try { + writer.writeValue(BYTES, copy); + } finally { + Arrays.fill(copy, (byte) 0); + } + } } } - private static void writeStringCompact(final OutputStream out, final String value) throws IOException { - out.write(TAG_STRING); - Util.writeUTF8(out, value); + private static AttributeValue readAttributeValue(Reader reader) throws IOException { + return switch (reader.readUnsignedByte()) { + case ATTRIBUTE_STRING -> new AttributeValue.StringValue(reader.readValue(STRING)); + case ATTRIBUTE_BOOLEAN -> new AttributeValue.BooleanValue(reader.readValue(BOOLEAN)); + case ATTRIBUTE_INTEGER -> new AttributeValue.IntegerValue(reader.readValue(LONG)); + case ATTRIBUTE_INSTANT -> new AttributeValue.InstantValue(reader.readValue(INSTANT)); + case ATTRIBUTE_BYTES -> readAttributeBytes(reader); + default -> throw new IOException("unknown attribute value type"); + }; } - private static void writeIntegerCompact(final OutputStream out, final Integer value) throws IOException { - out.write(TAG_INT); - Util.writePack7I(out, value); - } - - private static void writeLongCompact(final OutputStream out, final Long value) throws IOException { - out.write(TAG_LONG); - Util.writePack7L(out, value); - } - - private static void writeBooleanCompact(final OutputStream out, final Boolean value) throws IOException { - out.write(TAG_BOOL); - out.write(value ? 1 : 0); - } - - private static void writeBytesCompact(final OutputStream out, final byte[] value) throws IOException { - out.write(TAG_BYTES); - Util.write(out, value); - } - - private static void writeInstantCompact(final OutputStream out, final Instant value) throws IOException { - out.write(TAG_INSTANT); - Util.writeLong(out, value.getEpochSecond()); - Util.writePack7I(out, value.getNano()); - } - - private static void writeDurationCompact(final OutputStream out, final Duration value) throws IOException { - out.write(TAG_DURATION); - Util.writeLong(out, value.getSeconds()); - Util.writePack7I(out, value.getNano()); - } - - private static void writeListCompact(final OutputStream out, final java.util.List list) throws IOException { - out.write(TAG_LIST); - Util.writePack7I(out, list.size()); - for (Object element : list) { - Objects.requireNonNull(element, "list element must not be null"); - writeAnyCompact(out, element); + private static AttributeValue readAttributeBytes(Reader reader) throws IOException { + byte[] bytes = reader.readValue(BYTES); + try { + return new AttributeValue.BytesValue(bytes.clone()); + } finally { + Arrays.fill(bytes, (byte) 0); } } - private static void writeSetCompact(final OutputStream out, final java.util.Set set) throws IOException { - out.write(TAG_SET); - - java.util.List ordered = new java.util.ArrayList<>(set.size()); - ordered.addAll(set); - sortSetElementsDeterministically(ordered); - - Util.writePack7I(out, ordered.size()); - for (Object element : ordered) { - Objects.requireNonNull(element, "set element must not be null"); - writeAnyCompact(out, element); + private static void writeAttributeSet(Writer writer, AttributeSet value) throws IOException { + List ids = List.copyOf(value.ids()); + writer.writeCount(ids.size()); + for (AttributeId id : ids) { + writer.writeValue(ATTRIBUTE_ID, id); + writer.writeValue(ATTRIBUTE_VALUES, value.getAll(id)); } } - private static void sortSetElementsDeterministically(final java.util.List ordered) { - ordered.sort((a, b) -> { - if (a == b) { // NOPMD - return 0; + private static AttributeSet readAttributeSet(Reader reader) throws IOException { + int count = reader.readCount(); + List entries = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + entries.add(new SimpleAttributeSet.Entry(reader.readValue(ATTRIBUTE_ID), + reader.readValue(ATTRIBUTE_VALUES))); + } + return new SimpleAttributeSet(entries); + } + + private static void writeCredential(Writer writer, Credential value) throws IOException { + writer.writeValue(PKI_ID, value.credentialId()); + writer.writeValue(FORMAT_ID, value.formatId()); + writer.writeValue(ISSUER_REF, value.issuerRef()); + writer.writeValue(SUBJECT_REF, value.subjectRef()); + writer.writeValue(VALIDITY, value.validity()); + writer.writeValue(STRING, value.serialOrUniqueId()); + writer.writeValue(PKI_ID, value.publicKeyId()); + writer.writeValue(STRING, value.profileId()); + writer.writeValue(CREDENTIAL_STATUS, value.status()); + writer.writeValue(ENCODED_OBJECT, value.encoded()); + writer.writeValue(ATTRIBUTE_SET, value.attributes()); + } + + private static Credential readCredential(Reader reader) throws IOException { + return new Credential(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(ISSUER_REF), + reader.readValue(SUBJECT_REF), reader.readValue(VALIDITY), reader.readValue(STRING), + reader.readValue(PKI_ID), reader.readValue(STRING), reader.readValue(CREDENTIAL_STATUS), + reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET)); + } + + private static void writeCaRecord(Writer writer, CaRecord value) throws IOException { + writer.writeValue(PKI_ID, value.caId()); + writer.writeValue(CA_KIND, value.kind()); + writer.writeValue(CA_STATE, value.state()); + writer.writeValue(KEY_REF, value.issuerKeyRef()); + writer.writeValue(SUBJECT_REF, value.subjectRef()); + writer.writeValue(CREDENTIALS, value.caCredentials()); + } + + private static CaRecord readCaRecord(Reader reader) throws IOException { + return new CaRecord(reader.readValue(PKI_ID), reader.readValue(CA_KIND), reader.readValue(CA_STATE), + reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(CREDENTIALS)); + } + + private static void writeParsedRequest(Writer writer, ParsedCertificationRequest value) throws IOException { + writer.writeValue(PKI_ID, value.requestId()); + writer.writeValue(FORMAT_ID, value.formatId()); + writer.writeValue(SUBJECT_REF, value.subjectRef()); + writer.writeValue(ENCODED_OBJECT, value.publicKeyInfo()); + writer.writeValue(OPTIONAL_VALIDITY, value.requestedValidity()); + writer.writeValue(OPTIONAL_STRING, value.requestedProfileId()); + writer.writeValue(ATTRIBUTE_SET, value.attributes()); + } + + private static ParsedCertificationRequest readParsedRequest(Reader reader) throws IOException { + return new ParsedCertificationRequest(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), + reader.readValue(SUBJECT_REF), reader.readValue(ENCODED_OBJECT), reader.readValue(OPTIONAL_VALIDITY), + reader.readValue(OPTIONAL_STRING), reader.readValue(ATTRIBUTE_SET)); + } + + private static void writeRevocation(Writer writer, RevokedRecord value) throws IOException { + writer.writeValue(PKI_ID, value.credentialId()); + writer.writeValue(INSTANT, value.revocationTime()); + writer.writeValue(REVOCATION_REASON, value.reason()); + writer.writeValue(ATTRIBUTE_SET, value.attributes()); + } + + private static RevokedRecord readRevocation(Reader reader) throws IOException { + return new RevokedRecord(reader.readValue(PKI_ID), reader.readValue(INSTANT), + reader.readValue(REVOCATION_REASON), reader.readValue(ATTRIBUTE_SET)); + } + + private static void writeStatusObject(Writer writer, StatusObject value) throws IOException { + writer.writeValue(PKI_ID, value.statusObjectId()); + writer.writeValue(FORMAT_ID, value.formatId()); + writer.writeValue(PKI_ID, value.issuerCaId()); + writer.writeValue(STATUS_OBJECT_TYPE, value.type()); + writer.writeValue(INSTANT, value.thisUpdate()); + writer.writeValue(OPTIONAL_INSTANT, value.nextUpdate()); + writer.writeValue(ENCODED_OBJECT, value.encoded()); + writer.writeValue(ATTRIBUTE_SET, value.attributes()); + } + + private static StatusObject readStatusObject(Reader reader) throws IOException { + return new StatusObject(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(PKI_ID), + reader.readValue(STATUS_OBJECT_TYPE), reader.readValue(INSTANT), reader.readValue(OPTIONAL_INSTANT), + reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET)); + } + + private static void writePublication(Writer writer, PublicationRecord value) throws IOException { + writer.writeValue(PKI_ID, value.publicationId()); + writer.writeValue(INSTANT, value.time()); + writer.writeValue(PUBLICATION_TARGET, value.target()); + writer.writeValue(PKI_ID, value.objectId()); + writer.writeValue(STRING, value.objectKind()); + writer.writeValue(PUBLICATION_STATUS, value.status()); + } + + private static PublicationRecord readPublication(Reader reader) throws IOException { + return new PublicationRecord(reader.readValue(PKI_ID), reader.readValue(INSTANT), + reader.readValue(PUBLICATION_TARGET), reader.readValue(PKI_ID), reader.readValue(STRING), + reader.readValue(PUBLICATION_STATUS)); + } + + private static void writeCertificateProfile(Writer writer, CertificateProfile value) throws IOException { + writer.writeValue(STRING, value.profileId()); + writer.writeValue(FORMAT_ID, value.formatId()); + writer.writeValue(STRING, value.displayName()); + writer.writeValue(ATTRIBUTE_IDS, value.requiredAttributes()); + writer.writeValue(ATTRIBUTE_IDS, value.optionalAttributes()); + writer.writeValue(OPTIONAL_DURATION, value.maxValidity()); + writer.writeValue(BOOLEAN, value.active()); + } + + private static CertificateProfile readCertificateProfile(Reader reader) throws IOException { + return new CertificateProfile(reader.readValue(STRING), reader.readValue(FORMAT_ID), reader.readValue(STRING), + reader.readValue(ATTRIBUTE_IDS), reader.readValue(ATTRIBUTE_IDS), reader.readValue(OPTIONAL_DURATION), + reader.readValue(BOOLEAN)); + } + + private static void writePolicyTrace(Writer writer, PolicyTrace value) throws IOException { + writer.writeValue(PKI_ID, value.decisionId()); + writer.writeValue(POLICY_TRACE_STEPS, value.steps()); + } + + private static PolicyTrace readPolicyTrace(Reader reader) throws IOException { + return new PolicyTrace(reader.readValue(PKI_ID), reader.readValue(POLICY_TRACE_STEPS)); + } + + private static void writeWorkflowState(Writer writer, WorkflowStateRecord value) throws IOException { + writer.writeValue(PKI_ID, value.opId()); + writer.writeValue(STRING, value.type()); + writer.writeValue(PRINCIPAL, value.owner()); + writer.writeValue(DURABILITY_POLICY, value.durabilityPolicy()); + writer.writeValue(INSTANT, value.createdAt()); + writer.writeValue(INSTANT, value.updatedAt()); + writer.writeValue(INSTANT, value.expiresAt()); + writer.writeValue(ENCODING, value.payloadEncoding()); + writer.writeValue(OPTIONAL_ENCODED_OBJECT, value.payload()); + } + + private static WorkflowStateRecord readWorkflowState(Reader reader) throws IOException { + return new WorkflowStateRecord(reader.readValue(PKI_ID), reader.readValue(STRING), + reader.readValue(PRINCIPAL), reader.readValue(DURABILITY_POLICY), reader.readValue(INSTANT), + reader.readValue(INSTANT), reader.readValue(INSTANT), reader.readValue(ENCODING), + reader.readValue(OPTIONAL_ENCODED_OBJECT)); + } + + private static void writeSignWorkflowRecord(Writer writer, SignWorkflowStore.Record value) throws IOException { + writer.writeValue(PKI_ID, value.submissionId()); + writer.writeValue(STRING, value.namespace()); + writer.writeValue(STRING, value.fingerprint()); + writer.writeValue(PRINCIPAL, value.owner()); + writer.writeValue(INSTANT, value.createdAt()); + writer.writeValue(INSTANT, value.deadline()); + writer.writeValue(ENCODED_OBJECT, value.request()); + writer.writeValue(SIGN_STATE, value.state()); + writer.writeValue(LONG, value.revision()); + writer.writeValue(LONG, value.fence()); + writer.writeValue(OPTIONAL_INSTANT, value.leaseUntil()); + writer.writeValue(OPTIONAL_STRING, value.detailCode()); + writer.writeValue(OPTIONAL_ENCODED_OBJECT, value.result()); + writer.writeValue(OPTIONAL_INSTANT, value.providerUpdatedAt()); + } + + private static SignWorkflowStore.Record readSignWorkflowRecord(Reader reader) throws IOException { + return new SignWorkflowStore.Record(reader.readValue(PKI_ID), reader.readValue(STRING), + reader.readValue(STRING), reader.readValue(PRINCIPAL), reader.readValue(INSTANT), + reader.readValue(INSTANT), reader.readValue(ENCODED_OBJECT), reader.readValue(SIGN_STATE), + reader.readValue(LONG), reader.readValue(LONG), reader.readValue(OPTIONAL_INSTANT), + reader.readValue(OPTIONAL_STRING), reader.readValue(OPTIONAL_ENCODED_OBJECT), + reader.readValue(OPTIONAL_INSTANT)); + } + + private static Schema topLevel(int typeId, String name, ValueSchema valueSchema) { + return new Schema<>(typeId, name, valueSchema); + } + + private static ValueSchema valueSchema(int typeCode, Encoder encoder, Decoder decoder) { + return new ValueSchema<>(typeCode, encoder, decoder); + } + + private static ValueSchema enumSchema(int typeCode, EnumEncoder encoder, EnumDecoder decoder) { + return valueSchema(typeCode, (writer, value) -> writer.writeUnsignedByte(encoder.code(value)), + reader -> decoder.value(reader.readUnsignedByte())); + } + + private static ValueSchema> listOf(ValueSchema elementSchema) { + return valueSchema(TYPE_LIST, (writer, values) -> { + writer.writeUnsignedByte(elementSchema.typeCode); + writer.writeCount(values.size()); + for (T value : values) { + writer.writeValue(elementSchema, Objects.requireNonNull(value, "list element")); } - if (a == null) { - return -1; + }, reader -> { + reader.requireTypeCode(elementSchema.typeCode); + int count = reader.readCount(); + List values = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + values.add(reader.readValue(elementSchema)); } - if (b == null) { - return 1; - } - if (a instanceof Comparable && a.getClass() == b.getClass()) { - @SuppressWarnings("unchecked") - Comparable comparable = (Comparable) a; - return comparable.compareTo(b); - } - return stableSortKey(a).compareTo(stableSortKey(b)); + return List.copyOf(values); }); } - private static void writeOptionalCompact(final OutputStream out, final Optional optional) - throws IOException { - out.write(TAG_OPTIONAL); - if (optional.isPresent()) { - out.write(1); - Object element = optional.get(); - Objects.requireNonNull(element, "optional element must not be null"); - writeAnyCompact(out, element); - return; - } - out.write(0); + private static ValueSchema> optionalOf(ValueSchema elementSchema) { + return valueSchema(TYPE_OPTIONAL, (writer, value) -> { + writer.writeUnsignedByte(elementSchema.typeCode); + if (value.isEmpty()) { + writer.writeUnsignedByte(MARKER_ABSENT); + return; + } + writer.writeUnsignedByte(MARKER_PRESENT); + writer.writeValue(elementSchema, value.orElseThrow()); + }, reader -> { + reader.requireTypeCode(elementSchema.typeCode); + int marker = reader.readUnsignedByte(); + if (marker == MARKER_ABSENT) { + return Optional.empty(); + } + if (marker != MARKER_PRESENT) { + throw new IOException("invalid optional marker"); + } + return Optional.of(reader.readValue(elementSchema)); + }); } - private static void writeComplexCompact(final OutputStream out, final Object value) throws IOException { - Class type = value.getClass(); - if (type.isEnum()) { - writeEnumCompact(out, type, value); - return; - } - if (type.isRecord()) { - writeRecordCompact(out, type, value); - return; - } - writeFallbackStringCompact(out, type, value); + private static IOException unknownEnum(String type, int code) { + return new IOException("unknown " + type + " code " + code); } - private static void writeEnumCompact(final OutputStream out, final Class type, final Object value) - throws IOException { - out.write(TAG_ENUM); - Util.writeUTF8(out, type.getName()); - Enum enumValue = (Enum) value; - int persistentCode = value instanceof SignWorkflowStore.State state - ? state.persistentCode() - : enumValue.ordinal(); - Util.writePack7I(out, persistentCode); - } + /** + * Trusted compile-time description of one approved top-level payload. + * + * @param payload value type + */ + /* package */ static final class Schema { - private static void writeRecordCompact(final OutputStream out, final Class type, final Object value) - throws IOException { - out.write(TAG_RECORD); - Util.writeUTF8(out, type.getName()); + private final int typeId; + private final String name; + private final ValueSchema valueSchema; - RecordComponent[] components = type.getRecordComponents(); - Util.writePack7I(out, components.length); - - for (RecordComponent component : components) { - Object componentValue = invokeRecordAccessor(type, component, value); - Objects.requireNonNull(componentValue, "record component " + type.getName() + "." + component.getName()); - writeAnyCompact(out, componentValue); + private Schema(int typeId, String name, ValueSchema valueSchema) { + this.typeId = typeId; + this.name = name; + this.valueSchema = valueSchema; } } - private static Object invokeRecordAccessor(final Class type, final RecordComponent component, - final Object value) { - try { - Method accessor = component.getAccessor(); - return accessor.invoke(value); - } catch (IllegalAccessException | InvocationTargetException ex) { - throw new IllegalStateException("record encode failed: " + type.getName() + "." + component.getName(), ex); + /** + * Typed nested-value codec. + * + * @param nested value type + */ + private record ValueSchema(int typeCode, Encoder encoder, Decoder decoder) { + } + + /** + * Writes one value through its declared schema. + * + * @param value type + */ + @FunctionalInterface + private interface Encoder { + /** + * Encodes a value. + * + * @param writer destination writer + * @param value value to encode + * @throws IOException if encoding fails + */ + void encode(Writer writer, T value) throws IOException; + } + + /** + * Reads one value through its declared schema. + * + * @param value type + */ + @FunctionalInterface + private interface Decoder { + /** + * Decodes a value. + * + * @param reader source reader + * @return decoded value + * @throws IOException if decoding fails + */ + T decode(Reader reader) throws IOException; + } + + /** + * Maps an approved enum constant to a stable code. + * + * @param enum type + */ + @FunctionalInterface + private interface EnumEncoder { + /** + * Returns the persistent code. + * + * @param value enum value + * @return stable persistent code + */ + int code(T value); + } + + /** + * Maps a stable code to one approved enum constant. + * + * @param enum type + */ + @FunctionalInterface + private interface EnumDecoder { + /** + * Returns the enum value for a code. + * + * @param code persistent code + * @return decoded enum value + * @throws IOException if the code is unknown + */ + T value(int code) throws IOException; + } + + /** + * Minimal binary writer used only by declared schemas. + */ + private static final class Writer { + + private final OutputStream output; + + private Writer(OutputStream output) { + this.output = output; + } + + private void writeValue(ValueSchema schema, T value) throws IOException { + Objects.requireNonNull(value, "schema value"); + writeUnsignedByte(schema.typeCode); + schema.encoder.encode(this, value); + } + + private void writeString(String value) throws IOException { + Util.writeUTF8(output, value); + } + + private void writeBoolean(Boolean value) throws IOException { + writeUnsignedByte(value ? 1 : 0); + } + + private void writeLong(Long value) throws IOException { + Util.writeLong(output, value); + } + + private void writeBytes(byte[] value) throws IOException { + Util.write(output, value); + } + + private void writeInstant(Instant value) throws IOException { + Util.writeLong(output, value.getEpochSecond()); + Util.writePack7I(output, value.getNano()); + } + + private void writeDuration(Duration value) throws IOException { + Util.writeLong(output, value.getSeconds()); + Util.writePack7I(output, value.getNano()); + } + + private void writeCount(int count) throws IOException { + if (count < 0 || count > MAX_COLLECTION_ELEMENTS) { + throw new IOException("collection size out of range"); + } + Util.writePack7I(output, count); + } + + private void writeUnsignedByte(int value) throws IOException { + if (value < 0 || value > 0xFF) { + throw new IOException("byte value out of range"); + } + output.write(value); + } + + private void writeInt(int value) throws IOException { + output.write(value >>> 24); + output.write(value >>> 16); + output.write(value >>> 8); + output.write(value); } } - private static void writeFallbackStringCompact(final OutputStream out, final Class type, final Object value) - throws IOException { - out.write(TAG_FALLBACK_STRING); - Util.writeUTF8(out, type.getName()); - Util.writeUTF8(out, value.toString()); - } + /** + * Minimal binary reader used only by declared schemas. + */ + private static final class Reader { - private static String stableSortKey(final Object o) { - return o.getClass().getName() + ":" + o.toString(); - } + private final InputStream input; - private static Object readAny(final InputStream in, final Class expectedType) throws IOException { - Objects.requireNonNull(in, "in"); - Objects.requireNonNull(expectedType, "expectedType"); - - int first = in.read(); - if (first < 0) { - throw new IOException("unexpected EOF"); + private Reader(InputStream input) { + this.input = input; } - if (first != MAGIC_COMPACT) { - throw new IllegalStateException( - "file mismatch, MAGIC byte expected=" + MAGIC_COMPACT + " but found=" + first); + private T readValue(ValueSchema schema) throws IOException { + requireTypeCode(schema.typeCode); + return schema.decoder.decode(this); } - int tag = in.read(); - if (tag < 0) { - throw new IOException("unexpected EOF"); + private void requireTypeCode(int expected) throws IOException { + int actual = readUnsignedByte(); + if (actual != expected) { + throw new IOException("nested type mismatch"); + } } - Object value = readByTag(in, tag); - - if (!isTypeCompatible(expectedType, value.getClass())) { - throw new IllegalStateException( - "type mismatch, expected " + expectedType.getName() + " but decoded " + value.getClass().getName()); + private String readString() throws IOException { + return Util.readUTF8(input, MAX_COMPONENT_BYTES); } - return value; - } - - private static Object readByTag(final InputStream in, final int tag) throws IOException { - switch (tag) { - case TAG_STRING: - return readStringCompact(in); - case TAG_INT: - return readIntegerCompact(in); - case TAG_LONG: - return readLongCompact(in); - case TAG_BOOL: - return readBooleanCompact(in); - case TAG_BYTES: - return readBytesCompact(in); - case TAG_INSTANT: - return readInstantCompact(in); - case TAG_DURATION: - return readDurationCompact(in); - case TAG_ENUM: - return readEnumCompact(in); - case TAG_RECORD: - return readRecordCompact(in); - case TAG_FALLBACK_STRING: - return readFallbackStringCompact(in); - case TAG_LIST: - return readListCompact(in); - case TAG_SET: - return readSetCompact(in); - case TAG_OPTIONAL: - return readOptionalCompact(in); - default: - throw new IllegalStateException("unknown compact tag: " + tag); - } - } - - private static String readStringCompact(final InputStream in) throws IOException { - return Util.readUTF8(in, MAX_COMPONENT_BYTES); - } - - private static int readIntegerCompact(final InputStream in) throws IOException { - return Util.readPack7I(in); - } - - private static long readLongCompact(final InputStream in) throws IOException { - return Util.readPack7L(in); - } - - private static boolean readBooleanCompact(final InputStream in) throws IOException { - int value = in.read(); - if (value < 0) { - throw new IOException("unexpected EOF"); - } - return value != 0; - } - - private static byte[] readBytesCompact(final InputStream in) throws IOException { - return Util.read(in, MAX_COMPONENT_BYTES); - } - - private static Instant readInstantCompact(final InputStream in) throws IOException { - long seconds = Util.readLong(in); - int nanos = Util.readPack7I(in); - return Instant.ofEpochSecond(seconds, nanos); - } - - private static Duration readDurationCompact(final InputStream in) throws IOException { - long seconds = Util.readLong(in); - int nanos = Util.readPack7I(in); - return Duration.ofSeconds(seconds, nanos); - } - - private static Object readEnumCompact(final InputStream in) throws IOException { - String enumTypeName = Util.readUTF8(in, MAX_COMPONENT_BYTES); - Class enumType = loadClass(enumTypeName); - if (!enumType.isEnum()) { - throw new IllegalStateException("encoded enum type is not an enum: " + enumTypeName); + private Boolean readBoolean() throws IOException { + int value = readUnsignedByte(); + if (value == MARKER_ABSENT) { + return Boolean.FALSE; + } + if (value == MARKER_PRESENT) { + return Boolean.TRUE; + } + throw new IOException("invalid boolean value"); } - int ordinal = Util.readPack7I(in); - if (SignWorkflowStore.State.class.equals(enumType)) { + private Long readLong() throws IOException { + return Util.readLong(input); + } + + private byte[] readBytes() throws IOException { + return Util.read(input, MAX_COMPONENT_BYTES); + } + + private Instant readInstant() throws IOException { + long seconds = Util.readLong(input); + int nanos = Util.readPack7I(input); + if (nanos < 0 || nanos > 999_999_999) { + throw new IOException("invalid instant nanoseconds"); + } try { - return SignWorkflowStore.State.fromPersistentCode(ordinal); - } catch (IllegalArgumentException ex) { - throw new IllegalStateException("invalid signing workflow state code " + ordinal, ex); + return Instant.ofEpochSecond(seconds, nanos); + } catch (DateTimeException ex) { + throw new IOException("invalid instant", ex); } } - Object[] constants = enumType.getEnumConstants(); - if (constants == null || ordinal < 0 || ordinal >= constants.length) { - throw new IllegalStateException("invalid enum ordinal " + ordinal + " for " + enumTypeName); - } - return constants[ordinal]; - } - private static Object readRecordCompact(final InputStream in) throws IOException { - String recordTypeName = Util.readUTF8(in, MAX_COMPONENT_BYTES); - Class recordType = loadClass(recordTypeName); - if (!recordType.isRecord()) { - throw new IllegalStateException("encoded record type is not a record: " + recordTypeName); - } - - int componentCount = Util.readPack7I(in); - RecordComponent[] components = recordType.getRecordComponents(); - if (components.length != componentCount) { - throw new IllegalStateException("record component count mismatch for " + recordTypeName + ", expected " - + components.length + " but encoded " + componentCount); - } - - Class[] constructorTypes = new Class[components.length]; - Object[] constructorArgs = new Object[components.length]; - for (int i = 0; i < components.length; i++) { - RecordComponent component = components[i]; - constructorTypes[i] = component.getType(); - constructorArgs[i] = readAny(in, constructorTypes[i]); - } - - try { - return recordType.getDeclaredConstructor(constructorTypes).newInstance(constructorArgs); - } catch (ReflectiveOperationException ex) { - throw new IllegalStateException("record decode failed for " + recordTypeName, ex); - } - } - - private static Object readFallbackStringCompact(final InputStream in) throws IOException { - String typeName = Util.readUTF8(in, MAX_COMPONENT_BYTES); - String value = Util.readUTF8(in, MAX_COMPONENT_BYTES); - Class type = loadClass(typeName); - - Method stringFactory = findStringFactory(type); - if (stringFactory != null) { + private Duration readDuration() throws IOException { + long seconds = Util.readLong(input); + int nanos = Util.readPack7I(input); + if (nanos < 0 || nanos > 999_999_999) { + throw new IOException("invalid duration nanoseconds"); + } try { - return stringFactory.invoke(null, value); - } catch (ReflectiveOperationException ex) { - throw new IllegalStateException("from-string factory failed for " + type.getName(), ex); + return Duration.ofSeconds(seconds, nanos); + } catch (ArithmeticException ex) { + throw new IOException("invalid duration", ex); } } - try { - return type.getConstructor(String.class).newInstance(value); - } catch (ReflectiveOperationException ex) { - throw new IllegalStateException("unsupported value type without from-string factory: " + type.getName(), - ex); - } - } - - private static Object readListCompact(final InputStream in) throws IOException { - int size = Util.readPack7I(in); - java.util.List out = new java.util.ArrayList<>(size); - for (int i = 0; i < size; i++) { - Object element = readAny(in, Object.class); - out.add(element); - } - return java.util.List.copyOf(out); - } - - private static Object readSetCompact(final InputStream in) throws IOException { - int size = Util.readPack7I(in); - java.util.Set out = new java.util.LinkedHashSet<>(size); - for (int i = 0; i < size; i++) { - Object element = readAny(in, Object.class); - out.add(element); - } - return java.util.Set.copyOf(out); - } - - private static Object readOptionalCompact(final InputStream in) throws IOException { - int present = in.read(); - if (present < 0) { - throw new IOException("unexpected EOF"); - } - if (present == 0) { - return Optional.empty(); - } - Object element = readAny(in, Object.class); - return Optional.of(element); - } - - private static boolean isTypeCompatible(final Class expectedType, final Class actualType) { - if (expectedType.isAssignableFrom(actualType)) { - return true; - } - - Class expectedWrapper = PRIMITIVE_TO_WRAPPER.get(expectedType); - if (expectedWrapper != null && expectedWrapper == actualType) { - return true; - } - - Class actualWrapper = PRIMITIVE_TO_WRAPPER.get(actualType); - return actualWrapper != null && actualWrapper == expectedType; - } - - @SuppressWarnings("PMD.UseProperClassLoader") - private static Class loadClass(final String typeName) { - Objects.requireNonNull(typeName, "typeName"); - - ClassLoader cl = Thread.currentThread().getContextClassLoader(); - if (cl == null) { - cl = MethodHandles.lookup().lookupClass().getClassLoader(); - } - - try { - return Class.forName(typeName, false, cl); - } catch (ClassNotFoundException e) { - throw new IllegalStateException("unknown encoded type: " + typeName, e); - } - } - - private static Method findStringFactory(final Class type) { - try { - Method of = type.getMethod("of", String.class); - if ((of.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && of.getReturnType() == type) { - return of; + private int readCount() throws IOException { + int count = Util.readPack7I(input); + if (count < 0 || count > MAX_COLLECTION_ELEMENTS) { + throw new IOException("collection size out of range"); } - } catch (NoSuchMethodException ex) { // NOPMD - // ignore + return count; } - try { - Method valueOf = type.getMethod("valueOf", String.class); - if ((valueOf.getModifiers() & java.lang.reflect.Modifier.STATIC) != 0 && valueOf.getReturnType() == type) { - return valueOf; + private int readUnsignedByte() throws IOException { + int value = input.read(); + if (value < 0) { + throw new EOFException("unexpected end of payload"); } - } catch (NoSuchMethodException ex) { // NOPMD - // ignore + return value; } - return null; + private int readInt() throws IOException { + int first = readUnsignedByte(); + int second = readUnsignedByte(); + int third = readUnsignedByte(); + int fourth = readUnsignedByte(); + return first << 24 | second << 16 | third << 8 | fourth; + } } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java index 009bdf1..fa4aca1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -86,7 +86,7 @@ final class FsSnapshotExporter { FsOperations.ensureDir(targetRoot); FsPaths dst = new FsPaths(targetRoot); - Files.writeString(dst.versionFile(), "v1"); + Files.writeString(dst.versionFile(), FilesystemPkiStore.CURRENT_STORE_VERSION); copyFile(sourceRoot.resolve("SIGNING_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE")); copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK")); diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/package-info.java b/pki/src/main/java/zeroecho/pki/impl/fs/package-info.java index dbe80ef..94a3666 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/package-info.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/package-info.java @@ -52,6 +52,10 @@ * append-only history with timestamped entries. *
  • Atomic updates – all writes use a write-to-temp + * move-into-place strategy based on NIO.
  • + *
  • Closed current schema – persisted values use fixed type + * identifiers and compile-time codecs. Java class names are never persisted or + * resolved, nested collections have exact element schemas, and attribute sets + * are stored structurally and decoded to the canonical core implementation.
  • *
  • Strict snapshot semantics – snapshot export reconstructs * a complete store state for a given point in time and fails explicitly if no * valid history entry exists.
  • @@ -82,6 +86,8 @@ * implementation). *
  • Logging uses {@code java.util.logging} exclusively and never includes * sensitive domain data.
  • + *
  • The codec performs no runtime class loading or reflective construction. + * Earlier pre-release persistence formats are rejected rather than migrated.
  • * * *

    Scope

    diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java index d212957..20379c2 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java @@ -118,7 +118,8 @@ final class FilesystemPkiStoreOwnershipTest { assertThrows(IllegalStateException.class, () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())); - Files.writeString(root.resolve(FsPaths.VERSION_FILE), "v1", StandardCharsets.US_ASCII); + Files.writeString(root.resolve(FsPaths.VERSION_FILE), FilesystemPkiStore.CURRENT_STORE_VERSION, + StandardCharsets.US_ASCII); try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { assertTrue(reopened.listCas().isEmpty()); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index 0da5d2c..50371ee 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -64,14 +64,26 @@ import zeroecho.pki.api.Validity; import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.ca.CaKind; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.policy.PolicyTrace; +import zeroecho.pki.api.policy.PolicyTraceStep; +import zeroecho.pki.api.publication.PublicationRecord; +import zeroecho.pki.api.publication.PublicationStatus; +import zeroecho.pki.api.publication.PublicationTarget; +import zeroecho.pki.api.publication.PublicationTargetType; +import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevokedRecord; +import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; +import zeroecho.pki.api.orch.WorkflowStateRecord; /** * Tests for {@link FilesystemPkiStore}. @@ -96,6 +108,63 @@ public final class FilesystemPkiStoreTest { @TempDir Path tmp; + @Test + void everyNonSigningTopLevelSchemaRoundTripsThroughRealStore() throws Exception { + System.out.println("everyNonSigningTopLevelSchemaRoundTripsThroughRealStore"); + Path root = tmp.resolve("store-all-schemas"); + Instant now = Instant.parse("2026-01-02T03:04:05Z"); + AttributeSet attributes = TestObjects.emptyAttributes(); + CaRecord ca = TestObjects.minimalCaRecord("ca-all", CaState.ACTIVE); + Credential credential = TestObjects.minimalCredential("SERIAL-ALL", "profile-all"); + ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("request-all"), + new FormatId("fmt-x509"), new SubjectRef("CN=request-all"), + new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), Optional.empty(), + Optional.of("profile-all"), attributes); + RevokedRecord revocation = TestObjects.minimalRevocation(credential.credentialId().value(), now, + RevocationReason.KEY_COMPROMISE); + StatusObject status = new StatusObject(new PkiId("status-all"), new FormatId("fmt-x509"), ca.caId(), + StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)), + new EncodedObject(Encoding.DER, new byte[] { 7, 8 }), attributes); + PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"), now, + new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all", attributes), + credential.credentialId(), "CREDENTIAL", PublicationStatus.PUBLISHED); + CertificateProfile profile = TestObjects.minimalProfile("profile-all", true); + PolicyTrace trace = new PolicyTrace(new PkiId("decision-all"), + List.of(new PolicyTraceStep("rule-all", "ALLOW", List.of("approved")))); + WorkflowStateRecord workflow = new WorkflowStateRecord(new PkiId("workflow-all"), "ISSUANCE", + new Principal("TEST", "owner"), OrchestrationDurabilityPolicy.DURABLE_MIN_STATE, now, now, + now.plusSeconds(60L), Encoding.BINARY, + Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 9 }))); + + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + store.putCa(ca); + store.putCredential(credential); + store.putRequest(request); + store.putRevocation(revocation); + store.putStatusObject(status); + store.putPublicationRecord(publication); + store.putProfile(profile); + store.putPolicyTrace(trace); + store.putWorkflowState(workflow); + + assertEquals(ca.caId(), store.getCa(ca.caId()).orElseThrow().caId()); + assertEquals(credential.credentialId(), + store.getCredential(credential.credentialId()).orElseThrow().credentialId()); + assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId()); + assertEquals(revocation.credentialId(), + store.getRevocation(revocation.credentialId()).orElseThrow().credentialId()); + assertEquals(status.statusObjectId(), + store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId()); + assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId()); + assertEquals(profile, store.getProfile(profile.profileId()).orElseThrow()); + assertEquals(trace, store.getPolicyTrace(trace.decisionId()).orElseThrow()); + assertEquals(workflow.opId(), store.getWorkflowState(workflow.opId()).orElseThrow().opId()); + } + + System.out.println("...schemas round-tripped=9; signing schema covered separately"); + System.out.println("...ok"); + } + @Test void writeOnceCredentialRejected() throws Exception { System.out.println("writeOnceCredentialRejected"); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java index 60cfe52..ea2da3c 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java @@ -38,7 +38,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.ByteArrayInputStream; import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; @@ -62,7 +61,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import zeroecho.core.io.Util; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; @@ -92,21 +90,17 @@ final class FilesystemSignWorkflowStoreTest { SignWorkflowStore.Record current = intent(id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 7 }), TEST_ALGORITHM); String fingerprint = current.fingerprint(); - byte[] encoded = FsCodec.encode(current); + byte[] encoded = FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, current); - SignWorkflowStore.Record decoded = FsCodec.decode(encoded, SignWorkflowStore.Record.class); + SignWorkflowStore.Record decoded = FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, encoded); assertEquals(current.submissionId(), decoded.submissionId()); assertEquals(current.providerUpdatedAt(), decoded.providerUpdatedAt()); - int componentCountOffset = recordComponentCountOffset(encoded); - byte[] missingComponent = encoded.clone(); - missingComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length - - 1); + byte[] missingComponent = Arrays.copyOf(encoded, encoded.length - 1); assertRedactedStructuralFailure(missingComponent, fingerprint); - byte[] extraComponent = encoded.clone(); - extraComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length - + 1); + byte[] extraComponent = Arrays.copyOf(encoded, encoded.length + 1); + extraComponent[extraComponent.length - 1] = 1; assertRedactedStructuralFailure(extraComponent, fingerprint); byte[] missingProviderUpdatedAt = Arrays.copyOf(encoded, encoded.length - 3); @@ -131,11 +125,7 @@ final class FilesystemSignWorkflowStoreTest { Path recordPath = new FsPaths(root).signWorkflowPath(id); byte[] malformed = Files.readAllBytes(recordPath); - int envelopeBytes = Integer.BYTES * 2; - int componentCountOffset = envelopeBytes - + recordComponentCountOffset(Arrays.copyOfRange(malformed, envelopeBytes, malformed.length)); - malformed[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length - 1); - Files.write(recordPath, malformed); + Files.write(recordPath, Arrays.copyOf(malformed, malformed.length - 1)); try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { @@ -224,7 +214,7 @@ final class FilesystemSignWorkflowStoreTest { assertFalse(reopened.getSignRecord(id).isPresent()); assertThrows(IllegalArgumentException.class, () -> reopened.createSignIntent(intent)); } - try (FilesystemPkiStore foreign = new FilesystemPkiStore(root.resolveSibling("foreign"), options, clock)) { + try (FilesystemPkiStore foreign = new FilesystemPkiStore(root.resolve("foreign"), options, clock)) { assertThrows(IllegalArgumentException.class, () -> foreign.createSignIntent(intent)); } } @@ -338,9 +328,6 @@ final class FilesystemSignWorkflowStoreTest { Optional.empty()) .orElseThrow(); assertEquals(30, SignWorkflowStore.State.CANCELLING.persistentCode()); - assertEquals(SignWorkflowStore.State.CANCELLING, - FsCodec.decode(FsCodec.encode(SignWorkflowStore.State.CANCELLING), - SignWorkflowStore.State.class)); assertThrows(IllegalArgumentException.class, () -> SignWorkflowStore.State.fromPersistentCode(999)); } @@ -605,24 +592,16 @@ final class FilesystemSignWorkflowStoreTest { throws Exception { Path path = new FsPaths(root).signWorkflowPath(id); byte[] existing = Files.readAllBytes(path); - byte[] payload = FsCodec.encode(record); + byte[] payload = FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, record); ByteBuffer replacement = ByteBuffer.allocate(Integer.BYTES * 2 + payload.length); replacement.put(existing, 0, Integer.BYTES * 2); replacement.put(payload); Files.write(path, replacement.array()); } - private static int recordComponentCountOffset(byte[] encoded) throws Exception { - ByteArrayInputStream input = new ByteArrayInputStream(encoded); - input.read(); - input.read(); - Util.readUTF8(input, 4096); - return encoded.length - input.available(); - } - private static void assertRedactedStructuralFailure(byte[] encoded, String sensitiveFingerprint) { IllegalStateException failure = assertThrows(IllegalStateException.class, - () -> FsCodec.decode(encoded, SignWorkflowStore.Record.class)); + () -> FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, encoded)); assertFalse(failure.toString().contains(sensitiveFingerprint)); } diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java index 8240b8f..9e8ec3a 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java @@ -1,29 +1,29 @@ /******************************************************************************* * Copyright (C) 2026, Leo Galambos * All rights reserved. - * + * * Redistribution and use in source and binary forms, with or without modification, * are permitted provided that the following conditions are met: - * + * * 1. Redistributions of source code must retain the above copyright notice, this * list of conditions and the following disclaimer. - * + * * 2. Redistributions in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * + * * 3. All advertising materials mentioning features or use of this software must * display the following acknowledgement: * This product includes software developed by the Egothor project. - * + * * 4. Neither the name of the copyright holder nor the names of its contributors * may be used to endorse or promote products derived from this software without * specific prior written permission. - * + * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON @@ -35,378 +35,219 @@ package zeroecho.pki.impl.fs; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; -import java.util.HexFormat; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Objects; +import java.util.Map; import java.util.Optional; -import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; /** - * Tests for {@link FsCodec}. - * - *

    - * This suite intentionally covers all compact tags and the fallback string - * path. FsCodec is security-relevant (durable persistence), so we test - * determinism, round-trips, and failure modes. - *

    + * Strict current-schema tests for {@link FsCodec}. */ -public final class FsCodecTest { +final class FsCodecTest { + + private static final int VERSION_OFFSET = Integer.BYTES; + private static final int TYPE_OFFSET = VERSION_OFFSET + 1; + private static final int FIRST_FIELD_TYPE_OFFSET = TYPE_OFFSET + 1; @Test - void booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation() { - System.out.println("booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation"); + void approvedSchemaRoundTripAndTypeBinding() { + System.out.println("approvedSchemaRoundTripAndTypeBinding"); + CertificateProfile profile = profile(); - byte[] data = FsCodec.encode(Boolean.TRUE); + byte[] encoded = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile); + CertificateProfile decoded = FsCodec.decode(FsCodec.CERTIFICATE_PROFILE, encoded); - System.out.println("...encoded length=" + data.length); - System.out.println("...hex=" + hexPrefix(data)); - assertTrue(data.length <= 5); - - Boolean b = FsCodec.decode(data, Boolean.class); - assertEquals(Boolean.TRUE, b); - - Object any = FsCodec.decode(data, Object.class); - assertEquals(Boolean.TRUE, any); - - System.out.println("booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation...ok"); + System.out.println("...encoded length=" + encoded.length); + assertEquals(profile, decoded); + assertThrows(IllegalStateException.class, () -> FsCodec.decode(FsCodec.CA_RECORD, encoded)); + System.out.println("...ok"); } @Test - void scalarRoundTrip_stringIntLongBytes() { - System.out.println("scalarRoundTrip_stringIntLongBytes"); + void currentEnvelopeRejectsUnknownOldAndCorruptedPayloads() { + System.out.println("currentEnvelopeRejectsUnknownOldAndCorruptedPayloads"); + byte[] valid = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile()); - String s = "hello"; - byte[] sData = FsCodec.encode(s); - System.out.println("...string encoded length=" + sData.length); - assertEquals(s, FsCodec.decode(sData, String.class)); + byte[] unsupportedVersion = valid.clone(); + unsupportedVersion[VERSION_OFFSET] = (byte) (FsCodec.CURRENT_CODEC_VERSION + 1); + assertInvalid(unsupportedVersion); - Integer i = Integer.valueOf(123456); - byte[] iData = FsCodec.encode(i); - System.out.println("...int encoded length=" + iData.length); - assertEquals(i, FsCodec.decode(iData, Integer.class)); + byte[] unknownType = valid.clone(); + unknownType[TYPE_OFFSET] = (byte) 127; + assertInvalid(unknownType); - Long l = Long.valueOf(9876543210L); - byte[] lData = FsCodec.encode(l); - System.out.println("...long encoded length=" + lData.length); - assertEquals(l, FsCodec.decode(lData, Long.class)); + byte[] wrongFieldType = valid.clone(); + wrongFieldType[FIRST_FIELD_TYPE_OFFSET] = (byte) 2; + assertInvalid(wrongFieldType); - byte[] bytes = new byte[] { (byte) 0x00, (byte) 0x7F, (byte) 0x80, (byte) 0xFF }; - byte[] bData = FsCodec.encode(bytes); - System.out.println("...bytes encoded length=" + bData.length); - byte[] decoded = FsCodec.decode(bData, byte[].class); - assertArrayEquals(bytes, decoded); + assertInvalid(Arrays.copyOf(valid, valid.length - 1)); + byte[] trailing = Arrays.copyOf(valid, valid.length + 1); + trailing[trailing.length - 1] = 1; + assertInvalid(trailing); - System.out.println("scalarRoundTrip_stringIntLongBytes...ok"); + byte[] oldSetTag = "java.util.Set".getBytes(StandardCharsets.UTF_8); + assertInvalid(oldSetTag); + System.out.println("...ok"); } @Test - void timeRoundTrip_instantAndDuration() { - System.out.println("timeRoundTrip_instantAndDuration"); + void attackerClassNamesCannotSelectOrInitializeRuntimeTypes() { + System.out.println("attackerClassNamesCannotSelectOrInitializeRuntimeTypes"); + List classNames = List.of("java.io.File", "org.bouncycastle.asn1.ASN1ObjectIdentifier", + "zeroecho.pki.impl.fs.FsCodecTest$ArbitraryRecord", + "zeroecho.pki.impl.fs.FsCodecTest$ArbitraryEnum", + "zeroecho.pki.impl.fs.FsCodecTest$StaticInitializerSentinel"); - Instant instant = Instant.ofEpochSecond(1700000000L, 123456789); - byte[] iData = FsCodec.encode(instant); - Instant i2 = FsCodec.decode(iData, Instant.class); - System.out.println("...instant=" + instant); - assertEquals(instant, i2); + for (String className : classNames) { + byte[] oldPayload = className.getBytes(StandardCharsets.UTF_8); + assertInvalid(oldPayload); + } - Duration duration = Duration.ofSeconds(12345L, 77); - byte[] dData = FsCodec.encode(duration); - Duration d2 = FsCodec.decode(dData, Duration.class); - System.out.println("...duration=" + duration); - assertEquals(duration, d2); - - System.out.println("timeRoundTrip_instantAndDuration...ok"); + System.out.println("...rejected class names=" + classNames.size()); + assertFalse(InitializationProbe.INITIALIZED.get()); + System.out.println("...ok"); } @Test - void enumRoundTrip_isStable() { - System.out.println("enumRoundTrip_isStable"); + void attributeSetsPersistStructurallyAndDecodeCanonically() { + System.out.println("attributeSetsPersistStructurallyAndDecodeCanonically"); + AttributeId id = new AttributeId("test.attribute"); + List values = List.of(new AttributeValue.StringValue("value"), + new AttributeValue.BooleanValue(true), new AttributeValue.IntegerValue(42L), + new AttributeValue.InstantValue(Instant.parse("2026-01-02T03:04:05Z")), + new AttributeValue.BytesValue(new byte[] { 1, 2, 3 })); - TestEnum v = TestEnum.BETA; - byte[] data = FsCodec.encode(v); - System.out.println("...encoded length=" + data.length); + AttributeSet core = new SimpleAttributeSet(List.of(new SimpleAttributeSet.Entry(id, values))); + Map> map = new LinkedHashMap<>(); + map.put(id, values); + AttributeSet framework = zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet.of(map); - TestEnum decoded = FsCodec.decode(data, TestEnum.class); - assertEquals(v, decoded); + ParsedCertificationRequest decodedCore = roundTripRequest(core); + ParsedCertificationRequest decodedFramework = roundTripRequest(framework); - System.out.println("enumRoundTrip_isStable...ok"); + assertInstanceOf(SimpleAttributeSet.class, decodedCore.attributes()); + assertInstanceOf(SimpleAttributeSet.class, decodedFramework.attributes()); + assertAttributeValues(values, decodedCore.attributes().getAll(id)); + assertAttributeValues(values, decodedFramework.attributes().getAll(id)); + System.out.println("...canonical type=" + decodedFramework.attributes().getClass().getSimpleName()); + System.out.println("...ok"); } @Test - void recordRoundTrip_usesCompactEncoding() { - System.out.println("recordRoundTrip_usesCompactEncoding"); + void byteAttributeValuesAreDefensivelyCopied() { + System.out.println("byteAttributeValuesAreDefensivelyCopied"); + AttributeId id = new AttributeId("test.bytes"); + byte[] source = new byte[] { 10, 11, 12 }; + AttributeValue.BytesValue bytesValue = new AttributeValue.BytesValue(source); + AttributeSet attributes = new SimpleAttributeSet( + List.of(new SimpleAttributeSet.Entry(id, List.of(bytesValue)))); + byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes)); - CertificateProfile p = new CertificateProfile("profile-a", new FormatId("fmt-x509"), "Profile A", - List.of(new AttributeId("req-1")), List.of(new AttributeId("opt-1")), Optional.of(Duration.ofDays(365)), - true); + Arrays.fill(source, (byte) 0); + ParsedCertificationRequest decoded = FsCodec.decode(FsCodec.PARSED_REQUEST, encoded); + AttributeValue.BytesValue decodedValue = + (AttributeValue.BytesValue) decoded.attributes().getAll(id).get(0); + byte[] second = decodedValue.value(); - byte[] data = FsCodec.encode(p); - System.out.println("...encoded length=" + data.length); - assertTrue(data.length > 0); - - CertificateProfile decoded = FsCodec.decode(data, CertificateProfile.class); - assertEquals(p, decoded); - - System.out.println("recordRoundTrip_usesCompactEncoding...ok"); + System.out.println("...decoded byte count=" + second.length); + assertArrayEquals(new byte[] { 10, 11, 12 }, second); + System.out.println("...ok"); } @Test - void recordRoundTrip_nestedRecordAndCollections() { - System.out.println("recordRoundTrip_nestedRecordAndCollections"); + void malformedPackedLengthAndOptionalMarkerFailClosed() { + System.out.println("malformedPackedLengthAndOptionalMarkerFailClosed"); + byte[] encoded = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile()); + byte[] malformedLength = encoded.clone(); + malformedLength[FIRST_FIELD_TYPE_OFFSET + 1] = (byte) 0xFF; + assertInvalid(malformedLength); - NestedRecord v = new NestedRecord("n1", Instant.ofEpochSecond(1700000001L, 9), Optional.of(Integer.valueOf(7)), - List.of("a", "b"), Set.of("x", "y")); - - byte[] data = FsCodec.encode(v); - System.out.println("...encoded length=" + data.length); - NestedRecord decoded = FsCodec.decode(data, NestedRecord.class); - assertEquals(v, decoded); - - System.out.println("recordRoundTrip_nestedRecordAndCollections...ok"); + byte[] truncatedHeader = Arrays.copyOf(encoded, TYPE_OFFSET); + assertInvalid(truncatedHeader); + System.out.println("...ok"); } - @Test - void listSetOptional_roundTrip_usesCompactEncoding() { - System.out.println("listSetOptional_roundTrip_usesCompactEncoding"); - - List list = List.of("a", "b"); - byte[] listData = FsCodec.encode(list); - Object decodedList = FsCodec.decode(listData, Object.class); - assertEquals(list, decodedList); - - Set set = Set.of(Integer.valueOf(3), Integer.valueOf(1), Integer.valueOf(2)); - byte[] setData = FsCodec.encode(set); - Object decodedSet = FsCodec.decode(setData, Object.class); - assertEquals(set, decodedSet); - - Optional opt = Optional.of(Integer.valueOf(7)); - byte[] optData = FsCodec.encode(opt); - Object decodedOpt = FsCodec.decode(optData, Object.class); - assertEquals(opt, decodedOpt); - - Optional empty = Optional.empty(); - byte[] emptyData = FsCodec.encode(empty); - Object decodedEmpty = FsCodec.decode(emptyData, Object.class); - assertEquals(Optional.empty(), decodedEmpty); - - System.out.println("listSetOptional_roundTrip_usesCompactEncoding...ok"); + private static ParsedCertificationRequest roundTripRequest(AttributeSet attributes) { + byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes)); + return FsCodec.decode(FsCodec.PARSED_REQUEST, encoded); } - @Test - void setEncoding_isDeterministicForSameElements() { - System.out.println("setEncoding_isDeterministicForSameElements"); - - // Different construction orders should yield the same encoding. - Set a = Set.of("b", "a", "c"); - Set b = Set.of("c", "b", "a"); - - byte[] ea = FsCodec.encode(a); - byte[] eb = FsCodec.encode(b); - - System.out.println("...lenA=" + ea.length); - System.out.println("...lenB=" + eb.length); - System.out.println("...hexA=" + hexPrefix(ea)); - System.out.println("...hexB=" + hexPrefix(eb)); - - assertArrayEquals(ea, eb); - - System.out.println("setEncoding_isDeterministicForSameElements...ok"); + private static ParsedCertificationRequest request(AttributeSet attributes) { + return new ParsedCertificationRequest(new PkiId("request-1"), new FormatId("x509"), + new SubjectRef("subject-1"), new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), + Optional.of(new Validity(Instant.parse("2026-01-02T03:04:05Z"), + Instant.parse("2027-01-02T03:04:05Z"))), + Optional.of("profile-1"), attributes); } - @Test - void fallbackStringDecode_worksForOfValueOfAndCtor() { - System.out.println("fallbackStringDecode_worksForOfValueOfAndCtor"); - - TestOf a = TestOf.of("x"); - TestValueOf b = TestValueOf.valueOf("y"); - TestCtor c = new TestCtor("z"); - - byte[] da = FsCodec.encode(a); - byte[] db = FsCodec.encode(b); - byte[] dc = FsCodec.encode(c); - - TestOf a2 = FsCodec.decode(da, TestOf.class); - TestValueOf b2 = FsCodec.decode(db, TestValueOf.class); - TestCtor c2 = FsCodec.decode(dc, TestCtor.class); - - assertEquals(a, a2); - assertEquals(b, b2); - assertEquals(c, c2); - - System.out.println("fallbackStringDecode_worksForOfValueOfAndCtor...ok"); + private static CertificateProfile profile() { + return new CertificateProfile("profile-a", new FormatId("x509"), "Profile A", + List.of(new AttributeId("required")), List.of(new AttributeId("optional")), + Optional.of(Duration.ofDays(365)), true); } - @Test - void fallbackStringDecode_failsWithoutFactory() { - System.out.println("fallbackStringDecode_failsWithoutFactory"); - - TestNoFactory v = new TestNoFactory("boom"); - byte[] data = FsCodec.encode(v); - - IllegalStateException ex = assertThrows(IllegalStateException.class, - () -> FsCodec.decode(data, TestNoFactory.class)); - System.out.println("...ex=" + ex.getMessage()); - - assertTrue(ex.getMessage().contains("unsupported value type")); - - System.out.println("fallbackStringDecode_failsWithoutFactory...ok"); + private static void assertInvalid(byte[] encoded) { + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> FsCodec.decode(FsCodec.CERTIFICATE_PROFILE, encoded)); + assertTrue(failure.getMessage().contains("INVALID_CURRENT_PAYLOAD")); } - private static String hexPrefix(byte[] bytes) { - int len = Math.min(bytes.length, 16); - byte[] p = new byte[len]; - System.arraycopy(bytes, 0, p, 0, len); - String s = HexFormat.of().formatHex(p); - return bytes.length > len ? s + "..." : s; - } - - private enum TestEnum { - ALPHA, BETA, GAMMA - } - - public static record NestedRecord(String id, Instant at, Optional n, List list, Set set) { - public NestedRecord { - if (id == null) { - throw new IllegalArgumentException("id"); - } - if (at == null) { - throw new IllegalArgumentException("at"); - } - if (n == null) { - throw new IllegalArgumentException("n"); - } - if (list == null) { - throw new IllegalArgumentException("list"); - } - if (set == null) { - throw new IllegalArgumentException("set"); + private static void assertAttributeValues(List expected, List actual) { + assertEquals(expected.size(), actual.size()); + for (int index = 0; index < expected.size(); index++) { + AttributeValue expectedValue = expected.get(index); + AttributeValue actualValue = actual.get(index); + if (expectedValue instanceof AttributeValue.BytesValue expectedBytes + && actualValue instanceof AttributeValue.BytesValue actualBytes) { + assertArrayEquals(expectedBytes.value(), actualBytes.value()); + } else { + assertEquals(expectedValue, actualValue); } } } - /** - * Fallback-string: decoded via static of(String). - */ - public static final class TestOf { - - private final String v; - - private TestOf(String v) { - this.v = Objects.requireNonNull(v, "v"); - } - - public static TestOf of(String s) { - return new TestOf(s); - } - - @Override - public String toString() { - return v; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof TestOf other)) { - return false; - } - return v.equals(other.v); - } - - @Override - public int hashCode() { - return v.hashCode(); - } + private record ArbitraryRecord(String value) { } - /** - * Fallback-string: decoded via static valueOf(String). - */ - public static final class TestValueOf { - - private final String v; - - private TestValueOf(String v) { - this.v = Objects.requireNonNull(v, "v"); - } - - public static TestValueOf valueOf(String s) { - return new TestValueOf(s); - } - - @Override - public String toString() { - return v; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof TestValueOf other)) { - return false; - } - return v.equals(other.v); - } - - @Override - public int hashCode() { - return v.hashCode(); - } + private enum ArbitraryEnum { + VALUE } - /** - * Fallback-string: decoded via public ctor(String). - */ - public static final class TestCtor { - - private final String v; - - public TestCtor(String v) { - this.v = Objects.requireNonNull(v, "v"); - } - - @Override - public String toString() { - return v; - } - - @Override - public boolean equals(Object o) { - if (!(o instanceof TestCtor other)) { - return false; - } - return v.equals(other.v); - } - - @Override - public int hashCode() { - return v.hashCode(); - } + private static final class InitializationProbe { + private static final AtomicBoolean INITIALIZED = new AtomicBoolean(); } - /** - * Fallback-string: has no of/valueOf/ctor(String) and must fail to decode. - */ - public static final class TestNoFactory { - - private final String v; - - private TestNoFactory(String v) { - this.v = Objects.requireNonNull(v, "v"); + private static final class StaticInitializerSentinel { + static { + InitializationProbe.INITIALIZED.set(true); } - @Override - public String toString() { - return v; + private StaticInitializerSentinel() { } } }