security(pki): replace reflective persistence decoding with strict schemas
This commit is contained in:
@@ -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
@@ -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"));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
/*******************************************************************************
|
||||
* Copyright (C) 2026, Leo Galambos
|
||||
* All rights reserved.
|
||||
*
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
*
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
*
|
||||
* 3. All advertising materials mentioning features or use of this software must
|
||||
* display the following acknowledgement:
|
||||
* This product includes software developed by the Egothor project.
|
||||
*
|
||||
*
|
||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
||||
* may be used to endorse or promote products derived from this software without
|
||||
* specific prior written permission.
|
||||
*
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR
|
||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
@@ -35,378 +35,219 @@ package zeroecho.pki.impl.fs;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.SubjectRef;
|
||||
import zeroecho.pki.api.Validity;
|
||||
import zeroecho.pki.api.attr.AttributeId;
|
||||
import zeroecho.pki.api.attr.AttributeSet;
|
||||
import zeroecho.pki.api.attr.AttributeValue;
|
||||
import zeroecho.pki.api.profile.CertificateProfile;
|
||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||
|
||||
/**
|
||||
* Tests for {@link FsCodec}.
|
||||
*
|
||||
* <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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user