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>
*
* <p>
* 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.
* </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 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<CaRecord> 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<CaRecord> 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<Credential> 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<ParsedCertificationRequest> 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<RevokedRecord> 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<RevokedRecord> 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<StatusObject> 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<StatusObject> all = listBinaryFiles(byId, StatusObject.class);
List<StatusObject> all = listBinaryFiles(byId, FsCodec.STATUS_OBJECT);
List<StatusObject> 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<PublicationRecord> 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<CertificateProfile> 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<PolicyTrace> 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<WorkflowStateRecord> 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<WorkflowStateRecord> listWorkflowStates() {
// 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
@@ -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 <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 {
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 <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)) {
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 <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)) {
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;

File diff suppressed because it is too large Load Diff

View File

@@ -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"));

View File

@@ -52,6 +52,10 @@
* append-only history with timestamped entries.</li>
* <li><strong>Atomic updates</strong> all writes use a write-to-temp +
* 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
* a complete store state for a given point in time and fails explicitly if no
* valid history entry exists.</li>
@@ -82,6 +86,8 @@
* implementation).</li>
* <li>Logging uses {@code java.util.logging} exclusively and never includes
* 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>
*
* <h2>Scope</h2>

View File

@@ -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());

View File

@@ -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");

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.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));
}

View File

@@ -23,7 +23,7 @@
* 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}.
*
* <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>
* 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<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);
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<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;
byte[] data = FsCodec.encode(v);
System.out.println("...encoded length=" + data.length);
AttributeSet core = new SimpleAttributeSet(List.of(new SimpleAttributeSet.Entry(id, values)));
Map<AttributeId, List<AttributeValue>> 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<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");
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<String> a = Set.of("b", "a", "c");
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");
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<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 static void assertAttributeValues(List<AttributeValue> expected, List<AttributeValue> 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() {
}
}
}