security(pki): replace reflective persistence decoding with strict schemas

This commit is contained in:
2026-07-29 16:33:37 +02:00
parent 07e04e0eed
commit 8af75a9508
8 changed files with 1200 additions and 831 deletions

View File

@@ -120,9 +120,11 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
* <h2>Security Notes</h2> * <h2>Security Notes</h2>
* *
* <p> * <p>
* This reference implementation stores objects as-is. It does not implement * This reference implementation stores domain objects through a closed,
* encryption at rest. It also must not persist private key material; higher * current-version schema. It does not implement encryption at rest. It also
* layers must respect the SPI security requirements. * must not persist private key material; higher layers must respect the SPI
* security requirements. Earlier pre-release formats are rejected rather than
* migrated.
* </p> * </p>
* *
* <p> * <p>
@@ -135,9 +137,9 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); 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_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 int SIGN_RECORD_HEADER_BYTES = Integer.BYTES * 2;
private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:"; private static final String SIGN_FINGERPRINT_PREFIX = "signfp:v1:";
private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64; private static final int SIGN_FINGERPRINT_HEX_LENGTH = 64;
@@ -248,21 +250,21 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
PkiId caId = record.caId(); PkiId caId = record.caId();
Path current = this.paths.caCurrent(caId); Path current = this.paths.caCurrent(caId);
writeWithHistory(this.paths.caHistoryDir(caId), current, FsCodec.encode(record), this.options.caHistoryPolicy(), writeWithHistory(this.paths.caHistoryDir(caId), current, FsCodec.encode(FsCodec.CA_RECORD, record),
"CA", FsUtil.safeId(caId)); this.options.caHistoryPolicy(), "CA", FsUtil.safeId(caId));
} }
@Override @Override
public Optional<CaRecord> getCa(final PkiId caId) { public Optional<CaRecord> getCa(final PkiId caId) {
Objects.requireNonNull(caId, "caId"); Objects.requireNonNull(caId, "caId");
Path p = this.paths.caCurrent(caId); Path p = this.paths.caCurrent(caId);
return readOptional(p, CaRecord.class); return readOptional(p, FsCodec.CA_RECORD);
} }
@Override @Override
public List<CaRecord> listCas() { public List<CaRecord> listCas() {
Path casRoot = this.paths.root().resolve("cas").resolve("by-id"); Path casRoot = this.paths.root().resolve("cas").resolve("by-id");
return listCurrentRecords(casRoot, CaRecord.class); return listCurrentRecords(casRoot, FsCodec.CA_RECORD);
} }
@Override @Override
@@ -270,26 +272,27 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
Objects.requireNonNull(credential, "credential"); Objects.requireNonNull(credential, "credential");
PkiId id = credential.credentialId(); PkiId id = credential.credentialId();
Path p = this.paths.credentialPath(id); 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 @Override
public Optional<Credential> getCredential(final PkiId credentialId) { public Optional<Credential> getCredential(final PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId"); Objects.requireNonNull(credentialId, "credentialId");
return readOptional(this.paths.credentialPath(credentialId), Credential.class); return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL);
} }
@Override @Override
public void putRequest(final ParsedCertificationRequest request) { public void putRequest(final ParsedCertificationRequest request) {
Objects.requireNonNull(request, "request"); Objects.requireNonNull(request, "request");
PkiId id = request.requestId(); 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 @Override
public Optional<ParsedCertificationRequest> getRequest(final PkiId requestId) { public Optional<ParsedCertificationRequest> getRequest(final PkiId requestId) {
Objects.requireNonNull(requestId, "requestId"); Objects.requireNonNull(requestId, "requestId");
return readOptional(this.paths.requestPath(requestId), ParsedCertificationRequest.class); return readOptional(this.paths.requestPath(requestId), FsCodec.PARSED_REQUEST);
} }
@Override @Override
@@ -298,33 +301,34 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
PkiId credId = record.credentialId(); PkiId credId = record.credentialId();
Path current = this.paths.revocationCurrent(credId); 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)); this.options.revocationHistoryPolicy(), "REVOCATION", FsUtil.safeId(credId));
} }
@Override @Override
public Optional<RevokedRecord> getRevocation(final PkiId credentialId) { public Optional<RevokedRecord> getRevocation(final PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId"); Objects.requireNonNull(credentialId, "credentialId");
return readOptional(this.paths.revocationCurrent(credentialId), RevokedRecord.class); return readOptional(this.paths.revocationCurrent(credentialId), FsCodec.REVOCATION);
} }
@Override @Override
public List<RevokedRecord> listRevocations() { public List<RevokedRecord> listRevocations() {
Path root = this.paths.root().resolve("revocations").resolve("by-credential"); Path root = this.paths.root().resolve("revocations").resolve("by-credential");
return listCurrentRecords(root, RevokedRecord.class); return listCurrentRecords(root, FsCodec.REVOCATION);
} }
@Override @Override
public void putStatusObject(final StatusObject object) { public void putStatusObject(final StatusObject object) {
Objects.requireNonNull(object, "object"); Objects.requireNonNull(object, "object");
PkiId id = object.statusObjectId(); 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 @Override
public Optional<StatusObject> getStatusObject(final PkiId statusObjectId) { public Optional<StatusObject> getStatusObject(final PkiId statusObjectId) {
Objects.requireNonNull(statusObjectId, "statusObjectId"); Objects.requireNonNull(statusObjectId, "statusObjectId");
return readOptional(this.paths.statusObjectPath(statusObjectId), StatusObject.class); return readOptional(this.paths.statusObjectPath(statusObjectId), FsCodec.STATUS_OBJECT);
} }
@Override @Override
@@ -335,7 +339,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
// This is acceptable for a reference implementation; indexes can be added // This is acceptable for a reference implementation; indexes can be added
// later. // later.
Path byId = this.paths.root().resolve("status").resolve("by-id"); Path byId = this.paths.root().resolve("status").resolve("by-id");
List<StatusObject> all = listBinaryFiles(byId, StatusObject.class); List<StatusObject> all = listBinaryFiles(byId, FsCodec.STATUS_OBJECT);
List<StatusObject> out = new ArrayList<>(); List<StatusObject> out = new ArrayList<>();
for (StatusObject o : all) { for (StatusObject o : all) {
if (issuerCaId.equals(o.issuerCaId())) { if (issuerCaId.equals(o.issuerCaId())) {
@@ -349,13 +353,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
public void putPublicationRecord(final PublicationRecord record) { public void putPublicationRecord(final PublicationRecord record) {
Objects.requireNonNull(record, "record"); Objects.requireNonNull(record, "record");
PkiId id = record.publicationId(); 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 @Override
public List<PublicationRecord> listPublicationRecords() { public List<PublicationRecord> listPublicationRecords() {
Path byId = this.paths.root().resolve("publications").resolve("by-id"); Path byId = this.paths.root().resolve("publications").resolve("by-id");
return listBinaryFiles(byId, PublicationRecord.class); return listBinaryFiles(byId, FsCodec.PUBLICATION);
} }
@Override @Override
@@ -364,7 +369,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
String profileId = profile.profileId(); String profileId = profile.profileId();
Path current = this.paths.profileCurrent(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)); this.options.profileHistoryPolicy(), "PROFILE", FsUtil.safeSegment(profileId));
} }
@@ -373,26 +379,27 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
if (profileId == null || profileId.isBlank()) { if (profileId == null || profileId.isBlank()) {
throw new IllegalArgumentException("profileId must not be null/blank"); 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 @Override
public List<CertificateProfile> listProfiles() { public List<CertificateProfile> listProfiles() {
Path root = this.paths.root().resolve("profiles").resolve("by-id"); Path root = this.paths.root().resolve("profiles").resolve("by-id");
return listCurrentRecords(root, CertificateProfile.class); return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE);
} }
@Override @Override
public void putPolicyTrace(final PolicyTrace trace) { public void putPolicyTrace(final PolicyTrace trace) {
Objects.requireNonNull(trace, "trace"); Objects.requireNonNull(trace, "trace");
PkiId id = trace.decisionId(); 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 @Override
public Optional<PolicyTrace> getPolicyTrace(final PkiId decisionId) { public Optional<PolicyTrace> getPolicyTrace(final PkiId decisionId) {
Objects.requireNonNull(decisionId, "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); Path current = this.paths.workflowCurrent(opId);
// Never log payload; writeWithHistory logs only type + safe identifiers. // 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)); this.options.workflowHistoryPolicy(), "WORKFLOW", FsUtil.safeId(opId));
} }
@Override @Override
public Optional<WorkflowStateRecord> getWorkflowState(final PkiId opId) { public Optional<WorkflowStateRecord> getWorkflowState(final PkiId opId) {
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, "opId");
return readOptional(this.paths.workflowCurrent(opId), WorkflowStateRecord.class); return readOptional(this.paths.workflowCurrent(opId), FsCodec.WORKFLOW_STATE);
} }
@Override @Override
@@ -432,7 +439,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
@Override @Override
public List<WorkflowStateRecord> listWorkflowStates() { public List<WorkflowStateRecord> listWorkflowStates() {
// List by operation directory (workflows/by-op/<opId>/current.bin) // List by operation directory (workflows/by-op/<opId>/current.bin)
return listCurrentRecords(this.paths.workflowRoot(), WorkflowStateRecord.class); return listCurrentRecords(this.paths.workflowRoot(), FsCodec.WORKFLOW_STATE);
} }
@Override @Override
@@ -710,12 +717,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
if (input.getInt() != SIGN_RECORD_MAGIC) { if (input.getInt() != SIGN_RECORD_MAGIC) {
throw new IllegalStateException("Invalid signing record envelope"); 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"); throw new IllegalStateException("Unsupported signing record version");
} }
byte[] payload = new byte[input.remaining()]; byte[] payload = new byte[input.remaining()];
input.get(payload); input.get(payload);
return FsCodec.decode(payload, SignWorkflowStore.Record.class); return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload);
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalStateException("Failed to read authoritative signing record", 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) { private void writeSignRecord(SignWorkflowStore.Record record) {
validateSignRecord(record.submissionId(), record); validateSignRecord(record.submissionId(), record);
try { 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); ByteBuffer envelope = ByteBuffer.allocate(SIGN_RECORD_HEADER_BYTES + payload.length);
envelope.putInt(SIGN_RECORD_MAGIC); envelope.putInt(SIGN_RECORD_MAGIC);
envelope.putInt(SIGN_RECORD_VERSION); envelope.putInt(CURRENT_SIGN_RECORD_VERSION);
envelope.put(payload); envelope.put(payload);
FsOperations.writeAtomic(paths.signWorkflowPath(record.submissionId()), envelope.array()); FsOperations.writeAtomic(paths.signWorkflowPath(record.submissionId()), envelope.array());
} catch (IOException ex) { } catch (IOException ex) {
@@ -970,7 +977,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private static IllegalStateException invalidSignRecord(SignWorkflowStore.Record record, String code) { private static IllegalStateException invalidSignRecord(SignWorkflowStore.Record record, String code) {
return new IllegalStateException("Signing record corruption: type=sign-workflow version=" 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) { private static void requirePositive(Duration value, String name) {
@@ -1145,28 +1152,28 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private void ensureVersionFile() throws IOException { private void ensureVersionFile() throws IOException {
Path vf = this.paths.versionFile(); Path vf = this.paths.versionFile();
if (!Files.exists(vf)) { if (!Files.exists(vf)) {
FsOperations.writeAtomic(vf, VERSION_V1.getBytes()); FsOperations.writeAtomic(vf, CURRENT_STORE_VERSION.getBytes());
return; return;
} }
String ver = Files.readString(vf).trim(); String ver = Files.readString(vf).trim();
if (!VERSION_V1.equals(ver)) { if (!CURRENT_STORE_VERSION.equals(ver)) {
throw new IllegalStateException("unsupported store version: " + ver); throw new IllegalStateException("unsupported store version: " + ver);
} }
} }
private static <T> Optional<T> readOptional(final Path path, final Class<T> type) { private static <T> Optional<T> readOptional(final Path path, final FsCodec.Schema<T> schema) {
try { try {
if (!Files.exists(path)) { if (!Files.exists(path)) {
return Optional.empty(); return Optional.empty();
} }
byte[] data = FsOperations.readAll(path); byte[] data = FsOperations.readAll(path);
return Optional.of(FsCodec.decode(data, type)); return Optional.of(FsCodec.decode(schema, data));
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("read failed: " + path, e); throw new IllegalStateException("read failed: " + path, e);
} }
} }
private static <T> List<T> listBinaryFiles(final Path byIdDir, final Class<T> type) { private static <T> List<T> listBinaryFiles(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) { if (!Files.isDirectory(byIdDir)) {
return List.of(); return List.of();
} }
@@ -1174,7 +1181,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return Files.list(byIdDir).filter(Files::isRegularFile) return Files.list(byIdDir).filter(Files::isRegularFile)
.sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> { .sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> {
try { try {
return FsCodec.decode(FsOperations.readAll(p), type); return FsCodec.decode(schema, FsOperations.readAll(p));
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("read failed: " + p, e); throw new IllegalStateException("read failed: " + p, e);
} }
@@ -1184,7 +1191,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
} }
private static <T> List<T> listCurrentRecords(final Path byIdDir, final Class<T> type) { private static <T> List<T> listCurrentRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) { if (!Files.isDirectory(byIdDir)) {
return List.of(); return List.of();
} }
@@ -1196,7 +1203,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
for (Path entityDir : entityDirs) { for (Path entityDir : entityDirs) {
Path current = entityDir.resolve(FsPaths.CURRENT_FILE); Path current = entityDir.resolve(FsPaths.CURRENT_FILE);
if (Files.exists(current)) { if (Files.exists(current)) {
out.add(FsCodec.decode(FsOperations.readAll(current), type)); out.add(FsCodec.decode(schema, FsOperations.readAll(current)));
} }
} }
return out; return out;

File diff suppressed because it is too large Load Diff

View File

@@ -86,7 +86,7 @@ final class FsSnapshotExporter {
FsOperations.ensureDir(targetRoot); FsOperations.ensureDir(targetRoot);
FsPaths dst = new FsPaths(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_NAMESPACE"), targetRoot.resolve("SIGNING_NAMESPACE"));
copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK")); copyFile(sourceRoot.resolve("SIGNING_TIME_WATERMARK"), targetRoot.resolve("SIGNING_TIME_WATERMARK"));

View File

@@ -52,6 +52,10 @@
* append-only history with timestamped entries.</li> * append-only history with timestamped entries.</li>
* <li><strong>Atomic updates</strong> all writes use a write-to-temp + * <li><strong>Atomic updates</strong> all writes use a write-to-temp +
* move-into-place strategy based on NIO.</li> * move-into-place strategy based on NIO.</li>
* <li><strong>Closed current schema</strong> 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.</li>
* <li><strong>Strict snapshot semantics</strong> snapshot export reconstructs * <li><strong>Strict snapshot semantics</strong> snapshot export reconstructs
* a complete store state for a given point in time and fails explicitly if no * a complete store state for a given point in time and fails explicitly if no
* valid history entry exists.</li> * valid history entry exists.</li>
@@ -82,6 +86,8 @@
* implementation).</li> * implementation).</li>
* <li>Logging uses {@code java.util.logging} exclusively and never includes * <li>Logging uses {@code java.util.logging} exclusively and never includes
* sensitive domain data.</li> * sensitive domain data.</li>
* <li>The codec performs no runtime class loading or reflective construction.
* Earlier pre-release persistence formats are rejected rather than migrated.</li>
* </ul> * </ul>
* *
* <h2>Scope</h2> * <h2>Scope</h2>

View File

@@ -118,7 +118,8 @@ final class FilesystemPkiStoreOwnershipTest {
assertThrows(IllegalStateException.class, assertThrows(IllegalStateException.class,
() -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())); () -> 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())) { try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
assertTrue(reopened.listCas().isEmpty()); assertTrue(reopened.listCas().isEmpty());

View File

@@ -64,14 +64,26 @@ import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.ca.CaKind; import zeroecho.pki.api.ca.CaKind;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.profile.CertificateProfile; 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.RevocationReason;
import zeroecho.pki.api.revocation.RevokedRecord; 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}. * Tests for {@link FilesystemPkiStore}.
@@ -96,6 +108,63 @@ public final class FilesystemPkiStoreTest {
@TempDir @TempDir
Path tmp; 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 @Test
void writeOnceCredentialRejected() throws Exception { void writeOnceCredentialRejected() throws Exception {
System.out.println("writeOnceCredentialRejected"); System.out.println("writeOnceCredentialRejected");

View File

@@ -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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.Util;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
@@ -92,21 +90,17 @@ final class FilesystemSignWorkflowStoreTest {
SignWorkflowStore.Record current = intent(id, createdAt, SignWorkflowStore.Record current = intent(id, createdAt,
new EncodedObject(Encoding.BINARY, new byte[] { 7 }), TEST_ALGORITHM); new EncodedObject(Encoding.BINARY, new byte[] { 7 }), TEST_ALGORITHM);
String fingerprint = current.fingerprint(); 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.submissionId(), decoded.submissionId());
assertEquals(current.providerUpdatedAt(), decoded.providerUpdatedAt()); assertEquals(current.providerUpdatedAt(), decoded.providerUpdatedAt());
int componentCountOffset = recordComponentCountOffset(encoded); byte[] missingComponent = Arrays.copyOf(encoded, encoded.length - 1);
byte[] missingComponent = encoded.clone();
missingComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length
- 1);
assertRedactedStructuralFailure(missingComponent, fingerprint); assertRedactedStructuralFailure(missingComponent, fingerprint);
byte[] extraComponent = encoded.clone(); byte[] extraComponent = Arrays.copyOf(encoded, encoded.length + 1);
extraComponent[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length extraComponent[extraComponent.length - 1] = 1;
+ 1);
assertRedactedStructuralFailure(extraComponent, fingerprint); assertRedactedStructuralFailure(extraComponent, fingerprint);
byte[] missingProviderUpdatedAt = Arrays.copyOf(encoded, encoded.length - 3); byte[] missingProviderUpdatedAt = Arrays.copyOf(encoded, encoded.length - 3);
@@ -131,11 +125,7 @@ final class FilesystemSignWorkflowStoreTest {
Path recordPath = new FsPaths(root).signWorkflowPath(id); Path recordPath = new FsPaths(root).signWorkflowPath(id);
byte[] malformed = Files.readAllBytes(recordPath); byte[] malformed = Files.readAllBytes(recordPath);
int envelopeBytes = Integer.BYTES * 2; Files.write(recordPath, Arrays.copyOf(malformed, malformed.length - 1));
int componentCountOffset = envelopeBytes
+ recordComponentCountOffset(Arrays.copyOfRange(malformed, envelopeBytes, malformed.length));
malformed[componentCountOffset] = (byte) (SignWorkflowStore.Record.class.getRecordComponents().length - 1);
Files.write(recordPath, malformed);
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock);
InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) {
@@ -224,7 +214,7 @@ final class FilesystemSignWorkflowStoreTest {
assertFalse(reopened.getSignRecord(id).isPresent()); assertFalse(reopened.getSignRecord(id).isPresent());
assertThrows(IllegalArgumentException.class, () -> reopened.createSignIntent(intent)); 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)); assertThrows(IllegalArgumentException.class, () -> foreign.createSignIntent(intent));
} }
} }
@@ -338,9 +328,6 @@ final class FilesystemSignWorkflowStoreTest {
Optional.empty()) Optional.empty())
.orElseThrow(); .orElseThrow();
assertEquals(30, SignWorkflowStore.State.CANCELLING.persistentCode()); assertEquals(30, SignWorkflowStore.State.CANCELLING.persistentCode());
assertEquals(SignWorkflowStore.State.CANCELLING,
FsCodec.decode(FsCodec.encode(SignWorkflowStore.State.CANCELLING),
SignWorkflowStore.State.class));
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> SignWorkflowStore.State.fromPersistentCode(999)); () -> SignWorkflowStore.State.fromPersistentCode(999));
} }
@@ -605,24 +592,16 @@ final class FilesystemSignWorkflowStoreTest {
throws Exception { throws Exception {
Path path = new FsPaths(root).signWorkflowPath(id); Path path = new FsPaths(root).signWorkflowPath(id);
byte[] existing = Files.readAllBytes(path); 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); ByteBuffer replacement = ByteBuffer.allocate(Integer.BYTES * 2 + payload.length);
replacement.put(existing, 0, Integer.BYTES * 2); replacement.put(existing, 0, Integer.BYTES * 2);
replacement.put(payload); replacement.put(payload);
Files.write(path, replacement.array()); 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) { private static void assertRedactedStructuralFailure(byte[] encoded, String sensitiveFingerprint) {
IllegalStateException failure = assertThrows(IllegalStateException.class, IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> FsCodec.decode(encoded, SignWorkflowStore.Record.class)); () -> FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, encoded));
assertFalse(failure.toString().contains(sensitiveFingerprint)); assertFalse(failure.toString().contains(sensitiveFingerprint));
} }

View File

@@ -23,7 +23,7 @@
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * 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 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * 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 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON * 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.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.HexFormat; import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test; 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.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.AttributeId;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
/** /**
* Tests for {@link FsCodec}. * Strict current-schema tests for {@link FsCodec}.
*
* <p>
* 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.
* </p>
*/ */
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 @Test
void booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation() { void approvedSchemaRoundTripAndTypeBinding() {
System.out.println("booleanEncodingIsCompactAndDecodesIntoPrimitiveExpectation"); 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("...encoded length=" + encoded.length);
System.out.println("...hex=" + hexPrefix(data)); assertEquals(profile, decoded);
assertTrue(data.length <= 5); assertThrows(IllegalStateException.class, () -> FsCodec.decode(FsCodec.CA_RECORD, encoded));
System.out.println("...ok");
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");
} }
@Test @Test
void scalarRoundTrip_stringIntLongBytes() { void currentEnvelopeRejectsUnknownOldAndCorruptedPayloads() {
System.out.println("scalarRoundTrip_stringIntLongBytes"); System.out.println("currentEnvelopeRejectsUnknownOldAndCorruptedPayloads");
byte[] valid = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile());
String s = "hello"; byte[] unsupportedVersion = valid.clone();
byte[] sData = FsCodec.encode(s); unsupportedVersion[VERSION_OFFSET] = (byte) (FsCodec.CURRENT_CODEC_VERSION + 1);
System.out.println("...string encoded length=" + sData.length); assertInvalid(unsupportedVersion);
assertEquals(s, FsCodec.decode(sData, String.class));
Integer i = Integer.valueOf(123456); byte[] unknownType = valid.clone();
byte[] iData = FsCodec.encode(i); unknownType[TYPE_OFFSET] = (byte) 127;
System.out.println("...int encoded length=" + iData.length); assertInvalid(unknownType);
assertEquals(i, FsCodec.decode(iData, Integer.class));
Long l = Long.valueOf(9876543210L); byte[] wrongFieldType = valid.clone();
byte[] lData = FsCodec.encode(l); wrongFieldType[FIRST_FIELD_TYPE_OFFSET] = (byte) 2;
System.out.println("...long encoded length=" + lData.length); assertInvalid(wrongFieldType);
assertEquals(l, FsCodec.decode(lData, Long.class));
byte[] bytes = new byte[] { (byte) 0x00, (byte) 0x7F, (byte) 0x80, (byte) 0xFF }; assertInvalid(Arrays.copyOf(valid, valid.length - 1));
byte[] bData = FsCodec.encode(bytes); byte[] trailing = Arrays.copyOf(valid, valid.length + 1);
System.out.println("...bytes encoded length=" + bData.length); trailing[trailing.length - 1] = 1;
byte[] decoded = FsCodec.decode(bData, byte[].class); assertInvalid(trailing);
assertArrayEquals(bytes, decoded);
System.out.println("scalarRoundTrip_stringIntLongBytes...ok"); byte[] oldSetTag = "java.util.Set".getBytes(StandardCharsets.UTF_8);
assertInvalid(oldSetTag);
System.out.println("...ok");
} }
@Test @Test
void timeRoundTrip_instantAndDuration() { void attackerClassNamesCannotSelectOrInitializeRuntimeTypes() {
System.out.println("timeRoundTrip_instantAndDuration"); System.out.println("attackerClassNamesCannotSelectOrInitializeRuntimeTypes");
List<String> 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); for (String className : classNames) {
byte[] iData = FsCodec.encode(instant); byte[] oldPayload = className.getBytes(StandardCharsets.UTF_8);
Instant i2 = FsCodec.decode(iData, Instant.class); assertInvalid(oldPayload);
System.out.println("...instant=" + instant); }
assertEquals(instant, i2);
Duration duration = Duration.ofSeconds(12345L, 77); System.out.println("...rejected class names=" + classNames.size());
byte[] dData = FsCodec.encode(duration); assertFalse(InitializationProbe.INITIALIZED.get());
Duration d2 = FsCodec.decode(dData, Duration.class); System.out.println("...ok");
System.out.println("...duration=" + duration);
assertEquals(duration, d2);
System.out.println("timeRoundTrip_instantAndDuration...ok");
} }
@Test @Test
void enumRoundTrip_isStable() { void attributeSetsPersistStructurallyAndDecodeCanonically() {
System.out.println("enumRoundTrip_isStable"); System.out.println("attributeSetsPersistStructurallyAndDecodeCanonically");
AttributeId id = new AttributeId("test.attribute");
List<AttributeValue> 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; AttributeSet core = new SimpleAttributeSet(List.of(new SimpleAttributeSet.Entry(id, values)));
byte[] data = FsCodec.encode(v); Map<AttributeId, List<AttributeValue>> map = new LinkedHashMap<>();
System.out.println("...encoded length=" + data.length); map.put(id, values);
AttributeSet framework = zeroecho.pki.impl.framework.x509.bc.SimpleAttributeSet.of(map);
TestEnum decoded = FsCodec.decode(data, TestEnum.class); ParsedCertificationRequest decodedCore = roundTripRequest(core);
assertEquals(v, decoded); 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 @Test
void recordRoundTrip_usesCompactEncoding() { void byteAttributeValuesAreDefensivelyCopied() {
System.out.println("recordRoundTrip_usesCompactEncoding"); 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", Arrays.fill(source, (byte) 0);
List.of(new AttributeId("req-1")), List.of(new AttributeId("opt-1")), Optional.of(Duration.ofDays(365)), ParsedCertificationRequest decoded = FsCodec.decode(FsCodec.PARSED_REQUEST, encoded);
true); AttributeValue.BytesValue decodedValue =
(AttributeValue.BytesValue) decoded.attributes().getAll(id).get(0);
byte[] second = decodedValue.value();
byte[] data = FsCodec.encode(p); System.out.println("...decoded byte count=" + second.length);
System.out.println("...encoded length=" + data.length); assertArrayEquals(new byte[] { 10, 11, 12 }, second);
assertTrue(data.length > 0); System.out.println("...ok");
CertificateProfile decoded = FsCodec.decode(data, CertificateProfile.class);
assertEquals(p, decoded);
System.out.println("recordRoundTrip_usesCompactEncoding...ok");
} }
@Test @Test
void recordRoundTrip_nestedRecordAndCollections() { void malformedPackedLengthAndOptionalMarkerFailClosed() {
System.out.println("recordRoundTrip_nestedRecordAndCollections"); 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)), byte[] truncatedHeader = Arrays.copyOf(encoded, TYPE_OFFSET);
List.of("a", "b"), Set.of("x", "y")); assertInvalid(truncatedHeader);
System.out.println("...ok");
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");
} }
@Test private static ParsedCertificationRequest roundTripRequest(AttributeSet attributes) {
void listSetOptional_roundTrip_usesCompactEncoding() { byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes));
System.out.println("listSetOptional_roundTrip_usesCompactEncoding"); return FsCodec.decode(FsCodec.PARSED_REQUEST, encoded);
List<String> list = List.of("a", "b");
byte[] listData = FsCodec.encode(list);
Object decodedList = FsCodec.decode(listData, Object.class);
assertEquals(list, decodedList);
Set<Integer> 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<Integer> opt = Optional.of(Integer.valueOf(7));
byte[] optData = FsCodec.encode(opt);
Object decodedOpt = FsCodec.decode(optData, Object.class);
assertEquals(opt, decodedOpt);
Optional<Object> 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");
} }
@Test private static ParsedCertificationRequest request(AttributeSet attributes) {
void setEncoding_isDeterministicForSameElements() { return new ParsedCertificationRequest(new PkiId("request-1"), new FormatId("x509"),
System.out.println("setEncoding_isDeterministicForSameElements"); new SubjectRef("subject-1"), new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }),
Optional.of(new Validity(Instant.parse("2026-01-02T03:04:05Z"),
// Different construction orders should yield the same encoding. Instant.parse("2027-01-02T03:04:05Z"))),
Set<String> a = Set.of("b", "a", "c"); Optional.of("profile-1"), attributes);
Set<String> 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");
} }
@Test private static CertificateProfile profile() {
void fallbackStringDecode_worksForOfValueOfAndCtor() { return new CertificateProfile("profile-a", new FormatId("x509"), "Profile A",
System.out.println("fallbackStringDecode_worksForOfValueOfAndCtor"); List.of(new AttributeId("required")), List.of(new AttributeId("optional")),
Optional.of(Duration.ofDays(365)), true);
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");
} }
@Test private static void assertInvalid(byte[] encoded) {
void fallbackStringDecode_failsWithoutFactory() { IllegalStateException failure = assertThrows(IllegalStateException.class,
System.out.println("fallbackStringDecode_failsWithoutFactory"); () -> FsCodec.decode(FsCodec.CERTIFICATE_PROFILE, encoded));
assertTrue(failure.getMessage().contains("INVALID_CURRENT_PAYLOAD"));
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 String hexPrefix(byte[] bytes) { private static void assertAttributeValues(List<AttributeValue> expected, List<AttributeValue> actual) {
int len = Math.min(bytes.length, 16); assertEquals(expected.size(), actual.size());
byte[] p = new byte[len]; for (int index = 0; index < expected.size(); index++) {
System.arraycopy(bytes, 0, p, 0, len); AttributeValue expectedValue = expected.get(index);
String s = HexFormat.of().formatHex(p); AttributeValue actualValue = actual.get(index);
return bytes.length > len ? s + "..." : s; if (expectedValue instanceof AttributeValue.BytesValue expectedBytes
} && actualValue instanceof AttributeValue.BytesValue actualBytes) {
assertArrayEquals(expectedBytes.value(), actualBytes.value());
private enum TestEnum { } else {
ALPHA, BETA, GAMMA assertEquals(expectedValue, actualValue);
}
public static record NestedRecord(String id, Instant at, Optional<Integer> n, List<String> list, Set<String> 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 record ArbitraryRecord(String value) {
* 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 enum ArbitraryEnum {
* Fallback-string: decoded via static valueOf(String). VALUE
*/
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 static final class InitializationProbe {
* Fallback-string: decoded via public ctor(String). private static final AtomicBoolean INITIALIZED = new AtomicBoolean();
*/
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 StaticInitializerSentinel {
* Fallback-string: has no of/valueOf/ctor(String) and must fail to decode. static {
*/ InitializationProbe.INITIALIZED.set(true);
public static final class TestNoFactory {
private final String v;
private TestNoFactory(String v) {
this.v = Objects.requireNonNull(v, "v");
} }
@Override private StaticInitializerSentinel() {
public String toString() {
return v;
} }
} }
} }