From 0a07dc6ee224f274b4e353831bfe6342c0adddbf Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Sat, 1 Aug 2026 22:18:08 +0200 Subject: [PATCH] refactor(pki): normalize credential authority and snapshots Make standalone Credential records the sole durable certificate-content authority and replace embedded CaRecord credentials with stable credential ID references. Rework snapshot export and restore to stream certificate content, mint target-store references, preserve dependency ordering, and reject legacy embedded-credential and v2 persistence formats. Validated: - lib tests: 516/516 - FilesystemPkiStoreTest: 16/16 - PMD and JavaDoc pass - app compilation passes - only the 29 independently classified revocation fixture failures remain --- .../main/java/zeroecho/pki/api/CaService.java | 11 + .../java/zeroecho/pki/api/ca/CaRecord.java | 31 +- .../pki/impl/core/DefaultCaService.java | 31 +- .../pki/impl/core/DefaultIssuanceService.java | 10 +- .../impl/core/DefaultStatusObjectService.java | 9 +- .../pki/impl/fs/FilesystemPkiStore.java | 42 +- .../java/zeroecho/pki/impl/fs/FsCodec.java | 6 +- .../zeroecho/pki/impl/fs/FsOperations.java | 55 +++ .../java/zeroecho/pki/impl/fs/FsPaths.java | 4 + .../pki/impl/fs/FsSnapshotExporter.java | 444 +++++++++++++++++- .../java/zeroecho/pki/spi/store/PkiStore.java | 20 +- .../zeroecho/pki/api/ca/CaRecordTest.java | 72 +++ .../e2e/CaProfileIssuanceEnforcementTest.java | 71 +-- .../java/zeroecho/pki/e2e/PkiCoreE2eTest.java | 17 +- .../zeroecho/pki/e2e/PkiProofGateE2eTest.java | 42 +- .../DefaultStatusObjectServiceCrlTest.java | 4 +- .../pki/impl/fs/FilesystemPkiStoreTest.java | 219 ++++++++- .../zeroecho/pki/impl/fs/FsCodecTest.java | 37 ++ .../zeroecho/pki/testkit/PkiTestRuntime.java | 7 + 19 files changed, 1002 insertions(+), 130 deletions(-) create mode 100644 pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java diff --git a/pki/src/main/java/zeroecho/pki/api/CaService.java b/pki/src/main/java/zeroecho/pki/api/CaService.java index 5b8c4f4..9a5470b 100644 --- a/pki/src/main/java/zeroecho/pki/api/CaService.java +++ b/pki/src/main/java/zeroecho/pki/api/CaService.java @@ -152,6 +152,12 @@ public interface CaService { /** * Retrieves a CA record. * + *

+ * The returned record contains ordered credential identifiers. Credential + * content and metadata remain authoritative only through the configured + * credential store. + *

+ * * @param caId CA identifier * @return CA record * @throws IllegalArgumentException if {@code caId} is invalid @@ -162,6 +168,11 @@ public interface CaService { /** * Lists CA records matching query constraints. * + *

+ * Returned records contain ordered credential identifiers rather than embedded + * credential values. + *

+ * * @param query query constraints * @return list of CA records * @throws IllegalArgumentException if {@code query} is invalid diff --git a/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java b/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java index 9c2042e..ed6917a 100644 --- a/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java +++ b/pki/src/main/java/zeroecho/pki/api/ca/CaRecord.java @@ -33,15 +33,17 @@ ******************************************************************************/ package zeroecho.pki.api.ca; +import java.util.HashSet; import java.util.List; +import java.util.Set; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.SubjectRef; -import zeroecho.pki.api.credential.Credential; /** - * Represents a CA entity and its issued CA credentials. + * Represents a CA entity and the ordered identifiers of its issued CA + * credentials. * *

* A CA entity may have multiple CA credentials to support: @@ -58,16 +60,19 @@ import zeroecho.pki.api.credential.Credential; * @param issuerKeyRef key reference used for issuing operations (private key * reference) * @param subjectRef normalized subject reference - * @param caCredentials CA credentials currently associated with the entity - * (historical and active) + * @param credentialIds ordered identifiers of the credentials currently + * associated with the entity (historical and active); + * duplicates and {@code null} elements are rejected */ public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyRef, SubjectRef subjectRef, - List caCredentials) { + List credentialIds) { /** * Creates a CA record. * - * @throws IllegalArgumentException if inputs are null + * @throws IllegalArgumentException if a mandatory input or credential + * identifier is {@code null}, or if a + * credential identifier is duplicated */ public CaRecord { if (caId == null) { @@ -85,8 +90,18 @@ public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyR if (subjectRef == null) { throw new IllegalArgumentException("subjectRef must not be null"); } - if (caCredentials == null) { - throw new IllegalArgumentException("caCredentials must not be null"); + if (credentialIds == null) { + throw new IllegalArgumentException("credentialIds must not be null"); } + Set uniqueCredentialIds = new HashSet<>(credentialIds.size()); + for (PkiId credentialId : credentialIds) { + if (credentialId == null) { + throw new IllegalArgumentException("credentialIds must not contain null"); + } + if (!uniqueCredentialIds.add(credentialId)) { + throw new IllegalArgumentException("credentialIds must not contain duplicates"); + } + } + credentialIds = List.copyOf(credentialIds); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java index 7bcbecf..fee1734 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java @@ -359,10 +359,10 @@ public final class DefaultCaService implements CaService { CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); requireCaCertificateMatches(credential, credential, request, caId, CREATE_ROOT_REJECTED, BACKEND_CRED_MISMATCH); - CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, request.subjectRef(), - List.of(credential)); - store.putCa(ca); store.putCredential(credential); + CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, request.subjectRef(), + List.of(credential.credentialId())); + store.putCa(ca); return caId; } @@ -447,7 +447,7 @@ public final class DefaultCaService implements CaService { requireValidImportedRoot(command, holder); store.putCredential(credential); CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), request.subjectRef(), - List.of(credential)); + List.of(credential.credentialId())); store.putCa(ca); return caId; } @@ -499,7 +499,7 @@ public final class DefaultCaService implements CaService { CaRecord issuer = getCa(command.issuerCaId()); ensureActive(issuer, "issuer"); - if (issuer.caCredentials().isEmpty()) { + if (issuer.credentialIds().isEmpty()) { throw new PkiException("Issuer CA has no credentials"); } if (!framework.formatId().equals(command.formatId())) { @@ -552,7 +552,7 @@ public final class DefaultCaService implements CaService { store.putCredential(cred); CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(), - issue.subjectRef(), List.of(cred)); + issue.subjectRef(), List.of(cred.credentialId())); store.putCa(subject); return caId; } @@ -645,8 +645,8 @@ public final class DefaultCaService implements CaService { BACKEND_CRED_MISMATCH); store.putCredential(cred); - List updated = new ArrayList<>(subject.caCredentials()); - updated.add(cred); + List updated = new ArrayList<>(subject.credentialIds()); + updated.add(cred.credentialId()); CaRecord updatedCa = new CaRecord(subject.caId(), subject.kind(), subject.state(), subject.issuerKeyRef(), subject.subjectRef(), List.copyOf(updated)); store.putCa(updatedCa); @@ -731,7 +731,7 @@ public final class DefaultCaService implements CaService { } CaRecord updated = new CaRecord(existing.caId(), existing.kind(), state, existing.issuerKeyRef(), - existing.subjectRef(), existing.caCredentials()); + existing.subjectRef(), existing.credentialIds()); store.putCa(updated); if (LOG.isLoggable(Level.INFO)) { @@ -783,10 +783,11 @@ public final class DefaultCaService implements CaService { return false; } if (query.formatId().isPresent()) { - if (r.caCredentials().isEmpty()) { + if (r.credentialIds().isEmpty()) { return false; } - Credential last = r.caCredentials().get(r.caCredentials().size() - 1); + PkiId lastId = r.credentialIds().get(r.credentialIds().size() - 1); + Credential last = requireCredential(lastId); return query.formatId().get().equals(last.formatId()); } return true; @@ -803,7 +804,8 @@ public final class DefaultCaService implements CaService { EffectiveCredentialStatusResolver.Evaluation evaluation) { Credential lastRejected = null; EffectiveCredentialStatus lastStatus = null; - for (Credential credential : issuer.caCredentials()) { + for (PkiId credentialId : issuer.credentialIds()) { + Credential credential = requireCredential(credentialId); if (credential == null || !formatId.equals(credential.formatId())) { continue; } @@ -828,6 +830,11 @@ public final class DefaultCaService implements CaService { throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } + private Credential requireCredential(PkiId credentialId) { + return store.getCredential(credentialId) + .orElseThrow(() -> new PkiException("CA credential not found")); + } + private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) { try { CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(command.keyRef(), command.formatId(), diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java index 3878015..14cc188 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java @@ -234,7 +234,7 @@ public final class DefaultIssuanceService implements IssuanceService { if (issuer.state() != CaState.ACTIVE) { throw new PkiException("Issuer CA not ACTIVE"); } - if (issuer.caCredentials().isEmpty()) { + if (issuer.credentialIds().isEmpty()) { throw new PkiException("Issuer CA has no credentials"); } @@ -305,7 +305,8 @@ public final class DefaultIssuanceService implements IssuanceService { Credential lastRejected = null; EffectiveCredentialStatus lastStatus = null; - for (Credential c : issuer.caCredentials()) { + for (PkiId credentialId : issuer.credentialIds()) { + Credential c = requireIssuerCredential(credentialId); if (c == null || !formatId.equals(c.formatId())) { continue; } @@ -330,6 +331,11 @@ public final class DefaultIssuanceService implements IssuanceService { throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } + private Credential requireIssuerCredential(PkiId credentialId) { + return store.getCredential(credentialId) + .orElseThrow(() -> new PkiException("Issuer CA credential not found")); + } + private VerifiedIssuanceCandidate verifyIssuanceCandidate(IssueEndEntityCommand command) { ParsedCertificationRequest supplied = command.request(); byte[] csrDer = extractCsrDer(supplied); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java index 78ac0fc..9d01636 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java @@ -214,7 +214,7 @@ public final class DefaultStatusObjectService implements StatusObjectService { if (ca.state() != CaState.ACTIVE) { throw new PkiException("Issuer CA not ACTIVE"); } - if (ca.caCredentials().isEmpty()) { + if (ca.credentialIds().isEmpty()) { throw new PkiException("Issuer CA has no credentials"); } EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); @@ -466,9 +466,10 @@ public final class DefaultStatusObjectService implements StatusObjectService { EffectiveCredentialStatusResolver.Evaluation evaluation) { Credential lastRejected = null; EffectiveCredentialStatus lastStatus = null; - List credentials = ca.caCredentials(); - for (int index = credentials.size() - 1; index >= 0; index--) { - Credential credential = credentials.get(index); + List credentialIds = ca.credentialIds(); + for (int index = credentialIds.size() - 1; index >= 0; index--) { + Credential credential = store.getCredential(credentialIds.get(index)) + .orElseThrow(DefaultStatusObjectService::crlGenerationFailure); if (credential == null || !command.formatId().equals(credential.formatId())) { continue; } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index f4e3f3b..f3a7dae 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -166,7 +166,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName()); - /* package */ static final String CURRENT_STORE_VERSION = "v2"; + /* package */ static final String CURRENT_STORE_VERSION = "v3"; private static final String SIGN_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record"; private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner"; private static final int CURRENT_SIGN_RECORD_VERSION = 2; @@ -401,8 +401,11 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { *

* This method is an implementation-only feature. It does not modify the current * store; it clones a new store layout and reconstructs {@code current.bin} for - * history-tracked entities. The signing namespace, monotonic signing-time - * watermark, and authoritative signing workflow records are safety metadata: + * history-tracked entities. Standalone credential authority is reconstructed by + * streaming validated live-source credential content into new target-owned + * references before publishing selected ID-only CA records. The signing namespace, + * monotonic signing-time watermark, and authoritative signing workflow records + * are safety metadata: * their current export-time values are copied regardless of {@code at}, because * historical reconstruction could permit identifier reuse or lose a terminal * result. @@ -417,13 +420,30 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { requireStoreUsable(); Objects.requireNonNull(targetRoot, "targetRoot"); Objects.requireNonNull(at, "at"); - new FsSnapshotExporter(this.options).exportSnapshot(this.paths.root(), targetRoot, at); + new FsSnapshotExporter(this.options).exportSnapshot(this, targetRoot, at); + } + + /* default */ Path snapshotRoot() { + return paths.root(); + } + + /* default */ Set snapshotNonCredentialContentIds() { + requireStoreUsable(); + Set contentIds = new HashSet<>(); + for (StatusObject status : listBinaryFiles(paths.statusRoot(), FsCodec.STATUS_OBJECT)) { + contentIds.add(status.content().contentId()); + } + for (StoredSign stored : listStoredSigns()) { + stored.reference().map(DurableContentReference::contentId).ifPresent(contentIds::add); + } + return Set.copyOf(contentIds); } @Override public void putCa(final CaRecord record) { requireStoreUsable(); Objects.requireNonNull(record, "record"); + validateCaCredentialReferences(record); PkiId caId = record.caId(); Path current = this.paths.caCurrent(caId); @@ -436,14 +456,15 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { requireStoreUsable(); Objects.requireNonNull(caId, "caId"); Path p = this.paths.caCurrent(caId); - return readOptional(p, FsCodec.CA_RECORD); + return readOptional(p, FsCodec.CA_RECORD).map(this::validateCaCredentialReferences); } @Override public List listCas() { requireStoreUsable(); Path casRoot = this.paths.root().resolve("cas").resolve("by-id"); - return listCurrentRecords(casRoot, FsCodec.CA_RECORD); + return listCurrentRecords(casRoot, FsCodec.CA_RECORD).stream().map(this::validateCaCredentialReferences) + .toList(); } @Override @@ -469,6 +490,15 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + private CaRecord validateCaCredentialReferences(CaRecord record) { + for (PkiId credentialId : record.credentialIds()) { + if (getCredential(credentialId).isEmpty()) { + throw new IllegalStateException("CA credential reference is missing"); + } + } + return record; + } + @Override public void putRequest(final ParsedCertificationRequest request) { requireStoreUsable(); diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java index feca88e..392718f 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -448,7 +448,7 @@ final class FsCodec { private static final ValueSchema CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD, FsCodec::writeCredential, FsCodec::readCredential); - private static final ValueSchema> CREDENTIALS = listOf(CREDENTIAL_VALUE); + private static final ValueSchema> PKI_IDS = listOf(PKI_ID); /* package */ static final Schema CA_RECORD = topLevel(TOP_CA_RECORD, "CA_RECORD", valueSchema(100, FsCodec::writeCaRecord, FsCodec::readCaRecord)); @@ -711,12 +711,12 @@ final class FsCodec { writer.writeValue(CA_STATE, value.state()); writer.writeValue(KEY_REF, value.issuerKeyRef()); writer.writeValue(SUBJECT_REF, value.subjectRef()); - writer.writeValue(CREDENTIALS, value.caCredentials()); + writer.writeValue(PKI_IDS, value.credentialIds()); } private static CaRecord readCaRecord(Reader reader) throws IOException { return new CaRecord(reader.readValue(PKI_ID), reader.readValue(CA_KIND), reader.readValue(CA_STATE), - reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(CREDENTIALS)); + reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(PKI_IDS)); } private static void writeParsedRequest(Writer writer, ParsedCertificationRequest value) throws IOException { diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java index 9db35ad..87cc25e 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java @@ -158,6 +158,61 @@ final class FsOperations { forceDirectoryBestEffort(parent); } + /** + * Copies one file through a bounded buffer and atomically replaces the target. + * + * @param source source file + * @param target target file + * @param bufferBytes positive transfer-buffer size + * @throws IOException if streaming or atomic publication fails + */ + /* default */ static void copyAtomic(final Path source, final Path target, final int bufferBytes) + throws IOException { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(target, "target"); + if (bufferBytes <= 0) { + throw new IllegalArgumentException("bufferBytes must be positive"); + } + Path parent = requireParent(target); + ensureDir(parent); + Path temporary = Files.createTempFile(parent, ".snapshot-copy-", ".tmp", fileAttributesIfSupported()); + byte[] buffer = new byte[bufferBytes]; + boolean published = false; + try { + try (InputStream input = Files.newInputStream(source); + OutputStream output = Files.newOutputStream(temporary, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + long transferred = 0L; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read == 0) { + int value = input.read(); + if (value < 0) { + break; + } + output.write(value); + transferred = Math.addExact(transferred, 1L); + } else { + output.write(buffer, 0, read); + transferred = Math.addExact(transferred, read); + } + } + output.flush(); + } catch (ArithmeticException failure) { + throw new IOException("File transfer length overflow", failure); + } + forceFileBestEffort(temporary); + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + published = true; + forceDirectoryBestEffort(parent); + } finally { + java.util.Arrays.fill(buffer, (byte) 0); + if (!published) { + Files.deleteIfExists(temporary); + } + } + } + /** * Strictly persists one authoritative revocation journal image. * diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index 88efa11..2d09c56 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -186,6 +186,10 @@ final class FsPaths { return this.root.resolve("status").resolve(BY_ID).resolve(FsUtil.safeId(statusObjectId) + ".bin"); } + /* default */ Path statusRoot() { + return this.root.resolve("status").resolve(BY_ID); + } + // ------------------------------------------------------------------------- // Policy traces (immutable .bin) // ------------------------------------------------------------------------- diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java index 57aa73b..26744e9 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -34,21 +34,35 @@ package zeroecho.pki.impl.fs; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.content.DurableContentReference; +import zeroecho.pki.api.credential.Credential; import zeroecho.pki.impl.ProfileLifecycleFailure; import zeroecho.pki.impl.ProfileLifecycleFailure.Code; +import zeroecho.pki.spi.store.ContentSink; /** * Snapshot exporter ("time travel") for {@link FilesystemPkiStore}. @@ -66,20 +80,28 @@ import zeroecho.pki.impl.ProfileLifecycleFailure.Code; *

  • For entities with history enabled, the exporter selects the latest * history entry with timestamp {@code <= at}. If none exists, it may fall back * to {@code current.bin} only when strict mode is disabled.
  • - *
  • Write-once objects are copied as-is (they are immutable). This exporter - * does not attempt to prune them by time unless an upstream index exists.
  • + *
  • Write-once objects other than credentials are copied as-is. Every valid + * standalone credential is streamed from the live source into a new target-owned + * content reference before selected ID-only CA records are published.
  • *
  • Signing namespace, signing-time watermark, and authoritative * {@code sign-workflows} records are current safety metadata copied at export * time. They are intentionally not reconstructed at {@code at}: rolling them * back could make a stable identifier reusable or discard a completed signing * result.
  • * + * + *

    + * Export requires time linear in copied files plus selected credential bytes, + * with auxiliary memory linear in selected CA and standalone credential metadata, + * plus one fixed-size transfer buffer. + *

    */ final class FsSnapshotExporter { private static final Logger LOG = Logger.getLogger(FsSnapshotExporter.class.getName()); private static final String ACTIVE_POINTER_FILE = "active.bin"; private static final String BINARY_EXTENSION = ".bin"; + private static final int TRANSFER_BUFFER_BYTES = 16 * 1024; private final FsPkiStoreOptions options; @@ -92,42 +114,350 @@ final class FsSnapshotExporter { * bearing causes or filesystem paths. */ @SuppressWarnings("PMD.PreserveStackTrace") - /* default */ void exportSnapshot(final Path sourceRoot, final Path targetRoot, final Instant at) { - Objects.requireNonNull(sourceRoot, "sourceRoot"); + /* default */ void exportSnapshot(final FilesystemPkiStore source, final Path targetRoot, final Instant at) { + Objects.requireNonNull(source, "source"); Objects.requireNonNull(targetRoot, "targetRoot"); Objects.requireNonNull(at, "at"); try { + requireNewTarget(targetRoot); + Path sourceRoot = source.snapshotRoot(); List profiles = preflightImportedProfiles(sourceRoot.resolve("profiles"), at); - FsOperations.ensureDir(targetRoot); - FsPaths dst = new FsPaths(targetRoot); + SnapshotAuthority authority = new AuthorityPlanner(source, options).plan(at); + Set nonCredentialContentIds = source.snapshotNonCredentialContentIds(); + SnapshotPlan plan = new SnapshotPlan(profiles, authority, nonCredentialContentIds); + new SnapshotPublisher(source, options, plan).publish(targetRoot, at); + } catch (SnapshotProfileFailure failure) { + throw new IllegalStateException(failure.getMessage()); + } catch (IOException e) { + throw new IllegalStateException("Snapshot export failed: code=SNAPSHOT_EXPORT_FAILED"); + } + } - Files.writeString(dst.versionFile(), FilesystemPkiStore.CURRENT_STORE_VERSION); + private static DurableContentReference transferCredentialContent(FilesystemPkiStore source, + FilesystemPkiStore target, DurableContentReference sourceReference) throws IOException { + try (RepeatableContent content = source.stagedContent().openContent(sourceReference); + InputStream input = content.openStream(); + ContentSink sink = target.stagedContent().beginContent(sourceReference.encoding(), + DurableContentReference.Lifecycle.PERSISTED); + OutputStream output = sink.outputStream()) { + byte[] buffer = new byte[TRANSFER_BUFFER_BYTES]; + try { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + output.write(buffer, 0, read); + } + } + return sink.complete(); + } finally { + Arrays.fill(buffer, (byte) 0); + } + } + } + + private static Credential withContent(Credential source, DurableContentReference content) { + return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(), + source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(), + source.status(), content, source.attributes()); + } + + private static void requireNewTarget(Path targetRoot) { + if (Files.exists(targetRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("Snapshot target already exists"); + } + } + + private static List selectMutableRecords(Path srcTree, Instant at, FsHistoryPolicy policy, boolean strict) + throws IOException { + if (!Files.exists(srcTree)) { + return List.of(); + } + List selected = new ArrayList<>(); + try (java.util.stream.Stream paths = Files.walk(srcTree)) { + for (Path current : paths.filter(Files::isRegularFile) + .filter(path -> FsPaths.CURRENT_FILE.equals(path.getFileName().toString())) + .sorted(Comparator.comparing(Path::toString)).toList()) { + Path selectedRecord = null; + if (policy.enabled()) { + Path historyDir = current.getParent().resolve(FsPaths.HISTORY_DIR); + if (Files.isDirectory(historyDir)) { + selectedRecord = selectHistoryEntry(historyDir, at); + } + } + if (selectedRecord == null) { + if (strict && policy.enabled()) { + throw new IllegalStateException("No CA history entry is available for snapshot time"); + } + selectedRecord = current; + } + selected.add(selectedRecord); + } + } + return List.copyOf(selected); + } + + /** Builds the validated logical credential and selected-CA export plan. */ + private static final class AuthorityPlanner { + private final FilesystemPkiStore source; + private final FsPkiStoreOptions options; + private final FsPaths sourcePaths; + + private AuthorityPlanner(FilesystemPkiStore source, FsPkiStoreOptions options) { + this.source = source; + this.options = options; + this.sourcePaths = new FsPaths(source.snapshotRoot()); + } + + private SnapshotAuthority plan(Instant at) throws IOException { + CredentialInventory inventory = inventoryCredentials(); + List cas = selectCas(at, inventory.credentials()); + return new SnapshotAuthority(cas, inventory.credentials(), inventory.contentIds()); + } + + private CredentialInventory inventoryCredentials() throws IOException { + Map credentials = new LinkedHashMap<>(); + Set contentIds = new HashSet<>(); + Path credentialsRoot = source.snapshotRoot().resolve("credentials").resolve("by-id"); + if (!Files.isDirectory(credentialsRoot)) { + return new CredentialInventory(credentials, contentIds); + } + try (java.util.stream.Stream records = Files.list(credentialsRoot)) { + for (Path record : records.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(BINARY_EXTENSION)) + .sorted(Comparator.comparing(Path::toString)).toList()) { + loadCredential(record, credentials, contentIds); + } + } + return new CredentialInventory(credentials, contentIds); + } + + private void loadCredential(Path record, Map credentials, Set contentIds) + throws IOException { + Credential decoded; + try { + decoded = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(record), source.stagedContent()); + } catch (IllegalStateException failure) { + rejectCredential(); + return; + } + contentIds.add(decoded.content().contentId()); + if (!sourcePaths.credentialPath(decoded.credentialId()).equals(record) + || credentials.containsKey(decoded.credentialId())) { + rejectCredential(); + return; + } + try { + Credential validated = source.getCredential(decoded.credentialId()).orElse(null); + if (validated == null) { + rejectCredential(); + return; + } + credentials.put(decoded.credentialId(), validated); + } catch (IllegalStateException failure) { + rejectCredential(); + } + } + + private List selectCas(Instant at, Map credentials) throws IOException { + List selectedRecords = selectMutableRecords(source.snapshotRoot().resolve("cas"), at, + options.caHistoryPolicy(), options.strictSnapshotExport()); + List selected = new ArrayList<>(); + for (Path record : selectedRecords) { + CaRecord ca = loadCa(record); + if (ca != null && credentials.keySet().containsAll(ca.credentialIds())) { + selected.add(ca); + } else if (ca != null) { + rejectCa(); + } + } + return List.copyOf(selected); + } + + private CaRecord loadCa(Path record) throws IOException { + try { + return FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(record), source.stagedContent()); + } catch (IllegalStateException failure) { + rejectCa(); + return null; + } + } + + private void rejectCredential() { + if (options.strictSnapshotExport()) { + throw new SnapshotAuthorityFailure("Snapshot credential preflight failed"); + } + } + + private void rejectCa() { + if (options.strictSnapshotExport()) { + throw new SnapshotAuthorityFailure("Snapshot CA dependency preflight failed"); + } + } + } + + /** Owns the sibling temporary root until one atomic target publication. */ + private static final class SnapshotPublisher { + private final FilesystemPkiStore source; + private final FsPkiStoreOptions options; + private final SnapshotPlan plan; + + private SnapshotPublisher(FilesystemPkiStore source, FsPkiStoreOptions options, SnapshotPlan plan) { + this.source = source; + this.options = options; + this.plan = plan; + } + + private void publish(Path targetRoot, Instant at) throws IOException { + Path absoluteTarget = targetRoot.toAbsolutePath(); + Path parent = absoluteTarget.getParent(); + if (parent == null) { + throw new IOException("Snapshot target has no parent"); + } + FsOperations.ensureDir(parent); + Path temporaryRoot = Files.createTempDirectory(parent, ".zeroecho-snapshot-"); + boolean published = false; + try { + build(temporaryRoot, at); + Files.move(temporaryRoot, absoluteTarget, java.nio.file.StandardCopyOption.ATOMIC_MOVE); + published = true; + } catch (IOException | IllegalStateException failure) { + if (!published) { + addCleanupFailure(failure, temporaryRoot); + } + throw failure; + } + } + + private void build(Path targetRoot, Instant at) throws IOException { + Path sourceRoot = source.snapshotRoot(); + FsPaths destination = new FsPaths(targetRoot); + Files.writeString(destination.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")); - - // copy write-once trees as-is (best-effort, deterministic order) - copyTreeIfExists(sourceRoot.resolve("credentials"), targetRoot.resolve("credentials")); copyTreeIfExists(sourceRoot.resolve("requests"), targetRoot.resolve("requests")); copyTreeIfExists(sourceRoot.resolve("status"), targetRoot.resolve("status")); copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy")); copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); - copyTreeIfExists(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content")); + copyStagedContent(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content"), + plan.authority().sourceCredentialContentIds(), plan.nonCredentialContentIds()); copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations")); - copyImportedProfilesAsOf(profiles, targetRoot.resolve("profiles")); - - // reconstruct mutable entities from history (CAS and profiles) - reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at, - this.options.caHistoryPolicy(), this.options.strictSnapshotExport()); - // reconstruct workflow continuation state from history + copyImportedProfilesAsOf(plan.profiles(), targetRoot.resolve("profiles")); reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at, - this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport()); + options.workflowHistoryPolicy(), options.strictSnapshotExport()); + new AuthorityRestorer(source, options, plan.authority()).restore(targetRoot); + } - } catch (SnapshotProfileFailure failure) { - throw new IllegalStateException(failure.getMessage()); - } catch (IOException e) { - throw new IllegalStateException("Snapshot export failed: code=SNAPSHOT_EXPORT_FAILED"); + private static void addCleanupFailure(Exception primary, Path temporaryRoot) { + try { + deleteOwnedTree(temporaryRoot); + } catch (IOException cleanupFailure) { + primary.addSuppressed(new IOException("Snapshot temporary cleanup failed")); + } + } + } + + /** Reconstructs target credential authority before publishing selected CAs. */ + private static final class AuthorityRestorer { + private final FilesystemPkiStore source; + private final FsPkiStoreOptions options; + private final SnapshotAuthority authority; + + private AuthorityRestorer(FilesystemPkiStore source, FsPkiStoreOptions options, + SnapshotAuthority authority) { + this.source = source; + this.options = options; + this.authority = authority; + } + + private void restore(Path targetRoot) throws IOException { + Set transferred = new HashSet<>(); + try (FilesystemPkiStore target = new FilesystemPkiStore(targetRoot, options)) { + for (Map.Entry entry : authority.credentials().entrySet()) { + if (persistCredential(target, entry.getValue())) { + transferred.add(entry.getKey()); + } + } + for (CaRecord ca : authority.cas()) { + if (transferred.containsAll(ca.credentialIds())) { + persistCa(target, ca); + } + } + } + } + + private boolean persistCredential(FilesystemPkiStore target, Credential sourceCredential) + throws IOException { + if (options.strictSnapshotExport()) { + return persistCredentialStrict(target, sourceCredential); + } + try (CredentialTransfer transfer = new CredentialTransfer(source, target, sourceCredential)) { + transfer.persist(); + return true; + } catch (IOException | IllegalArgumentException | IllegalStateException failure) { + return false; + } + } + + private boolean persistCredentialStrict(FilesystemPkiStore target, Credential sourceCredential) + throws IOException { + try (CredentialTransfer transfer = new CredentialTransfer(source, target, sourceCredential)) { + transfer.persist(); + return true; + } + } + + private boolean persistCa(FilesystemPkiStore target, CaRecord ca) { + if (options.strictSnapshotExport()) { + target.putCa(ca); + return true; + } + try { + target.putCa(ca); + return true; + } catch (IllegalArgumentException | IllegalStateException failure) { + return false; + } + } + } + + /** Owns a completed target reference until its credential is persisted. */ + private static final class CredentialTransfer implements AutoCloseable { + private final FilesystemPkiStore target; + private final Credential sourceCredential; + private final DurableContentReference targetReference; + private boolean persisted; + + private CredentialTransfer(FilesystemPkiStore source, FilesystemPkiStore target, + Credential sourceCredential) throws IOException { + this.target = target; + this.sourceCredential = sourceCredential; + this.targetReference = transferCredentialContent(source, target, sourceCredential.content()); + } + + private void persist() { + target.putCredential(withContent(sourceCredential, targetReference)); + persisted = true; + } + + @Override + public void close() throws IOException { + if (!persisted) { + target.stagedContent().retireUnownedContent(targetReference); + } + } + } + + private static void deleteOwnedTree(Path root) throws IOException { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return; + } + try (java.util.stream.Stream paths = Files.walk(root)) { + List ordered = paths.sorted(Comparator.reverseOrder()).toList(); + for (Path path : ordered) { + Files.delete(path); + } } } @@ -291,6 +621,42 @@ final class FsSnapshotExporter { }); } + private static void copyStagedContent(Path src, Path dst, Set credentialContentIds, + Set nonCredentialContentIds) throws IOException { + if (!Files.exists(src)) { + return; + } + try (java.util.stream.Stream paths = Files.walk(src)) { + for (Path path : paths.sorted(Comparator.comparing(Path::toString)).toList()) { + Path relative = src.relativize(path); + Path output = dst.resolve(relative); + if (Files.isDirectory(path)) { + FsOperations.ensureDir(output); + } else if (Files.isRegularFile(path) + && shouldCopyStagedArtifact(path, credentialContentIds, nonCredentialContentIds)) { + FsOperations.ensureDir(output.getParent()); + FsOperations.copyAtomic(path, output, TRANSFER_BUFFER_BYTES); + } + } + } + } + + private static boolean shouldCopyStagedArtifact(Path path, Set credentialContentIds, + Set nonCredentialContentIds) { + String fileName = path.getFileName().toString(); + int separator = fileName.lastIndexOf('.'); + if (separator <= 0) { + return true; + } + String extension = fileName.substring(separator); + if (!".content".equals(extension) && !".meta".equals(extension) && !".owners".equals(extension)) { + return true; + } + String contentId = fileName.substring(0, separator); + return !credentialContentIds.contains(contentId) + || nonCredentialContentIds.contains(contentId) && !".owners".equals(extension); + } + private static void copyFile(final Path source, final Path target) throws IOException { if (!Files.isRegularFile(source)) { throw new IllegalStateException("Required snapshot metadata is missing: " + source.getFileName()); @@ -309,6 +675,40 @@ final class FsSnapshotExporter { } } + private record CredentialInventory(Map credentials, Set contentIds) { + private CredentialInventory { + credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials)); + contentIds = Set.copyOf(contentIds); + } + } + + private record SnapshotAuthority(List cas, Map credentials, + Set sourceCredentialContentIds) { + private SnapshotAuthority { + cas = List.copyOf(cas); + credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials)); + sourceCredentialContentIds = Set.copyOf(sourceCredentialContentIds); + } + } + + private record SnapshotPlan(List profiles, SnapshotAuthority authority, + Set nonCredentialContentIds) { + private SnapshotPlan { + profiles = List.copyOf(profiles); + Objects.requireNonNull(authority, "authority"); + nonCredentialContentIds = Set.copyOf(nonCredentialContentIds); + } + } + + /** Cause-free marker for invalid source credential or CA authority. */ + private static final class SnapshotAuthorityFailure extends IllegalStateException { + private static final long serialVersionUID = -8051398945014616477L; + + private SnapshotAuthorityFailure(String message) { + super(message); + } + } + /** Cause-free internal marker for profile preflight rejection. */ private static final class SnapshotProfileFailure extends RuntimeException { private static final long serialVersionUID = -4451876406166515230L; diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index baf1ea2..0d37b59 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -88,12 +88,15 @@ public interface PkiStore extends SignWorkflowStore { * *

    * Implementations must store CA records atomically. Replacing an existing - * record should be either fully visible or not visible at all. + * record should be either fully visible or not visible at all. Every + * identifier in {@link CaRecord#credentialIds()} must resolve through + * {@link #getCredential(PkiId)} before the CA record is published. *

    * * @param record CA record (never {@code null}) * @throws NullPointerException if {@code record} is {@code null} - * @throws IllegalStateException if persistence fails + * @throws IllegalStateException if persistence fails or a referenced + * credential is missing or invalid */ void putCa(CaRecord record); @@ -101,17 +104,22 @@ public interface PkiStore extends SignWorkflowStore { * Retrieves a CA record. * * @param caId CA identifier (never {@code null}) - * @return CA record if present + * @return CA record if present; all credential identifiers have been validated + * against the standalone credential authority * @throws NullPointerException if {@code caId} is {@code null} - * @throws IllegalStateException if retrieval fails + * @throws IllegalStateException if retrieval fails or a referenced credential + * is missing or invalid */ Optional getCa(PkiId caId); /** * Lists all stored CA records. * - * @return list of CA records (never {@code null}) - * @throws IllegalStateException if listing fails + * @return list of CA records (never {@code null}); every returned credential + * identifier has been validated against the standalone credential + * authority + * @throws IllegalStateException if listing fails or a referenced credential is + * missing or invalid */ List listCas(); diff --git a/pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java b/pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java new file mode 100644 index 0000000..3502026 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java @@ -0,0 +1,72 @@ +/******************************************************************************* + * 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 + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + ******************************************************************************/ +package zeroecho.pki.api.ca; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; + +final class CaRecordTest { + + @Test + void credentialIdentifiersAreOrderedImmutableAndUnique() { + System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique"); + PkiId first = new PkiId("credential-first"); + PkiId second = new PkiId("credential-second"); + List source = new ArrayList<>(List.of(first, second)); + + CaRecord record = record(source); + source.clear(); + + assertEquals(List.of(first, second), record.credentialIds()); + assertThrows(UnsupportedOperationException.class, + () -> record.credentialIds().add(new PkiId("credential-third"))); + assertThrows(IllegalArgumentException.class, () -> record(List.of(first, first))); + assertThrows(IllegalArgumentException.class, () -> record(java.util.Arrays.asList(first, null))); + System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique...ok"); + } + + private static CaRecord record(List credentialIds) { + return new CaRecord(new PkiId("ca-test"), CaKind.ROOT, CaState.ACTIVE, new KeyRef("key-test"), + new SubjectRef("CN=Test"), credentialIds); + } +} diff --git a/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java index 326218d..851facb 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java @@ -130,7 +130,7 @@ final class CaProfileIssuanceEnforcementTest { "fixed-intermediate-ca", Optional.empty(), new SimpleAttributeSet())); CaRecord intermediate = runtime.caService().getCa(intermediateId); - Credential credential = intermediate.caCredentials().get(0); + Credential credential = runtime.caCredential(intermediate, 0); X509CertificateHolder holder = new X509CertificateHolder(runtime.credentialBytes(credential)); X509CertificateHolder additionalHolder = new X509CertificateHolder(runtime.credentialBytes(additional)); assertEquals("Fixed Organization", @@ -140,7 +140,7 @@ final class CaProfileIssuanceEnforcementTest { assertEquals(holder.getSubject().toString(), intermediate.subjectRef().value()); assertEquals(intermediate.subjectRef(), credential.subjectRef()); assertEquals(intermediate.subjectRef(), additional.subjectRef()); - assertEquals(2, intermediate.caCredentials().size()); + assertEquals(2, intermediate.credentialIds().size()); } } @@ -167,19 +167,19 @@ final class CaProfileIssuanceEnforcementTest { Credential additional = runtime.caService() .issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())); - rootProfile = ((CaProfileBinding) runtime.caService().getCa(rootId).caCredentials().get(0).profileBinding()) + rootProfile = ((CaProfileBinding) runtime.caCredential(runtime.caService().getCa(rootId), 0).profileBinding()) .reference(); intermediateProfile = ((CaProfileBinding) additional.profileBinding()).reference(); - assertCaCertificate(runtime, runtime.caService().getCa(rootId).caCredentials().get(0), 1); + assertCaCertificate(runtime, runtime.caCredential(runtime.caService().getCa(rootId), 0), 1); assertCaCertificate(runtime, additional, 0); } try (PkiTestRuntime reopened = PkiTestRuntime.create(store, directory.resolve("reopened-bus.log"), Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { assertEquals(rootProfile, - ((CaProfileBinding) reopened.caService().getCa(rootId).caCredentials().get(0).profileBinding()) + ((CaProfileBinding) reopened.caCredential(reopened.caService().getCa(rootId), 0).profileBinding()) .reference()); - assertEquals(intermediateProfile, ((CaProfileBinding) reopened.caService().getCa(intermediateId) - .caCredentials().get(1).profileBinding()).reference()); + assertEquals(intermediateProfile, ((CaProfileBinding) reopened + .caCredential(reopened.caService().getCa(intermediateId), 1).profileBinding()).reference()); } } @@ -221,10 +221,11 @@ final class CaProfileIssuanceEnforcementTest { .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootId, new SubjectRef("CN=Historical Intermediate"), "intermediate-ca", Optional.of(intermediateRef), new SimpleAttributeSet())); - CaProfileBinding issuerBinding = (CaProfileBinding) runtime.caService().getCa(rootId).caCredentials().get(0) + CaProfileBinding issuerBinding = (CaProfileBinding) runtime + .caCredential(runtime.caService().getCa(rootId), 0) .profileBinding(); assertEquals(1, issuerBinding.reference().profileVersion()); - assertEquals(1, runtime.caService().getCa(intermediateId).caCredentials().size()); + assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size()); } } @@ -260,7 +261,7 @@ final class CaProfileIssuanceEnforcementTest { runtime.profileService().activateProfile(profileId, 1); caA = createRoot(runtime, keyRefA, "CN=Version Switch Root A", profileId); - Credential issuedA = onlyCredential(runtime.caService(), caA); + Credential issuedA = onlyCredential(runtime, caA); assertCaProfileCredential(runtime, "A", issuedA, versionOne, 1); credentialA = issuedA.credentialId(); @@ -276,13 +277,13 @@ final class CaProfileIssuanceEnforcementTest { assertEquals(versionOne, runtime.profileService().getActiveReference(profileId).orElseThrow()); caB = createRoot(runtime, keyRefB, "CN=Version Switch Root B", profileId); - Credential issuedB = onlyCredential(runtime.caService(), caB); + Credential issuedB = onlyCredential(runtime, caB); assertCaProfileCredential(runtime, "B", issuedB, versionOne, 1); credentialB = issuedB.credentialId(); assertEquals(versionTwo, runtime.profileService().activateProfile(profileId, 2)); caC = createRoot(runtime, keyRefC, "CN=Version Switch Root C", profileId); - Credential issuedC = onlyCredential(runtime.caService(), caC); + Credential issuedC = onlyCredential(runtime, caC); assertCaProfileCredential(runtime, "C", issuedC, versionTwo, 2); credentialC = issuedC.credentialId(); @@ -347,7 +348,7 @@ final class CaProfileIssuanceEnforcementTest { rootProfile = runtime.profileService().importProfile(rootProfileDocument); runtime.profileService().activateProfile(rootProfileId, 1); rootId = createRoot(runtime, rootKeyRef, "CN=Version Switch Issuer Root", rootProfileId); - Credential rootCredential = onlyCredential(runtime.caService(), rootId); + Credential rootCredential = onlyCredential(runtime, rootId); assertCaProfileCredential(runtime, "issuer", rootCredential, rootProfile, 2); versionOne = runtime.profileService().importProfile(versionOneDocument); @@ -357,10 +358,10 @@ final class CaProfileIssuanceEnforcementTest { caA = createIntermediate(runtime, rootId, keyRefA, "CN=Version Switch Intermediate A", intermediateProfileId); - Credential issuedA = onlyCredential(runtime.caService(), caA); + Credential issuedA = onlyCredential(runtime, caA); assertCaProfileCredential(runtime, "A", issuedA, versionOne, 0); credentialA = issuedA.credentialId(); - assertCaProfileCredential(runtime, "issuer-after-A", onlyCredential(runtime.caService(), rootId), rootProfile, 2); + assertCaProfileCredential(runtime, "issuer-after-A", onlyCredential(runtime, rootId), rootProfile, 2); ImportedCertificateProfileVersion storedOne = runtime.profileService() .getImportedVersion(intermediateProfileId, 1).orElseThrow(); @@ -375,14 +376,14 @@ final class CaProfileIssuanceEnforcementTest { caB = createIntermediate(runtime, rootId, keyRefB, "CN=Version Switch Intermediate B", intermediateProfileId); - Credential issuedB = onlyCredential(runtime.caService(), caB); + Credential issuedB = onlyCredential(runtime, caB); assertCaProfileCredential(runtime, "B", issuedB, versionOne, 0); credentialB = issuedB.credentialId(); assertEquals(versionTwo, runtime.profileService().activateProfile(intermediateProfileId, 2)); caC = createIntermediate(runtime, rootId, keyRefC, "CN=Version Switch Intermediate C", intermediateProfileId); - Credential issuedC = onlyCredential(runtime.caService(), caC); + Credential issuedC = onlyCredential(runtime, caC); assertCaProfileCredential(runtime, "C", issuedC, versionTwo, 1); credentialC = issuedC.credentialId(); @@ -390,7 +391,7 @@ final class CaProfileIssuanceEnforcementTest { 0); assertCaProfileCredential(runtime, "B-reread", runtime.store().getCredential(credentialB).orElseThrow(), versionOne, 0); - assertCaProfileCredential(runtime, "issuer-reread", onlyCredential(runtime.caService(), rootId), rootProfile, 2); + assertCaProfileCredential(runtime, "issuer-reread", onlyCredential(runtime, rootId), rootProfile, 2); ImportedCertificateProfileVersion unchanged = runtime.profileService() .getImportedVersion(intermediateProfileId, 1).orElseThrow(); assertArrayEquals(persistedVersionOne, unchanged.canonicalJson()); @@ -405,7 +406,7 @@ final class CaProfileIssuanceEnforcementTest { versionOne, 0); assertCaProfileCredential(reopened, "C-restart", reopened.store().getCredential(credentialC).orElseThrow(), versionTwo, 1); - assertCaProfileCredential(reopened, "issuer-restart", onlyCredential(reopened.caService(), rootId), + assertCaProfileCredential(reopened, "issuer-restart", onlyCredential(reopened, rootId), rootProfile, 2); ImportedCertificateProfileVersion unchanged = reopened.profileService() .getImportedVersion(intermediateProfileId, 1).orElseThrow(); @@ -429,12 +430,14 @@ final class CaProfileIssuanceEnforcementTest { .replace("\"profileId\":\"root-ca\"", "\"profileId\":\"other-format-root\"") .replace("\"formatId\":\"x509\"", "\"formatId\":\"other\"").getBytes(StandardCharsets.UTF_8)); CaRecord root = runtime.caService().getCa(rootId); - Credential original = root.caCredentials().get(0); - Credential mutated = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), - original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), - new CaProfileBinding(wrongFormat), original.status(), original.content(), original.attributes()); + Credential original = runtime.caCredential(root, 0); + Credential mutated = new Credential(new PkiId(original.credentialId().value() + ":wrong-profile"), + original.formatId(), original.issuerRef(), original.subjectRef(), original.validity(), + original.serialOrUniqueId(), original.publicKeyId(), new CaProfileBinding(wrongFormat), + original.status(), original.content(), original.attributes()); + runtime.store().putCredential(mutated); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(mutated))); + root.subjectRef(), List.of(mutated.credentialId()))); int signCount = runtime.submittedSignCount(); assertThrows(PkiException.class, @@ -491,7 +494,7 @@ final class CaProfileIssuanceEnforcementTest { service.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())); profiles.assertAndReset("intermediate-ca"); - rootCertificate = runtime.credentialBytes(service.getCa(rootId).caCredentials().get(0)); + rootCertificate = runtime.credentialBytes(runtime.caCredential(service.getCa(rootId), 0)); } try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("import"), directory.resolve("import-bus.log"), Map.of(rootRef, rootKey))) { @@ -518,7 +521,7 @@ final class CaProfileIssuanceEnforcementTest { new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, rootId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); assertEquals(signCount, runtime.submittedSignCount()); - assertEquals(1, runtime.caService().getCa(rootId).caCredentials().size()); + assertEquals(1, runtime.caService().getCa(rootId).credentialIds().size()); } } @@ -545,7 +548,7 @@ final class CaProfileIssuanceEnforcementTest { runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca", Optional.of(invalid), new SimpleAttributeSet()))); assertEquals(signCount, runtime.submittedSignCount()); - assertEquals(1, runtime.caService().getCa(intermediateId).caCredentials().size()); + assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size()); } } @@ -558,14 +561,14 @@ final class CaProfileIssuanceEnforcementTest { directory.resolve("source-bus.log"), Map.of(rootRef, rootKey))) { PkiId rootId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), new SubjectRef("CN=Imported Root"), "root-ca", Optional.of(rootRef), new SimpleAttributeSet())); - encoded = source.credentialBytes(source.caService().getCa(rootId).caCredentials().get(0)); + encoded = source.credentialBytes(source.caCredential(source.caService().getCa(rootId), 0)); } try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("target"), directory.resolve("target-bus.log"), Map.of(rootRef, rootKey))) { PkiId imported = target.caService() .importRoot(new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Imported Root"), "root-ca", rootRef, target.stageCredential(encoded), new SimpleAttributeSet())); - Credential credential = target.caService().getCa(imported).caCredentials().get(0); + Credential credential = target.caCredential(target.caService().getCa(imported), 0); assertEquals(target.profileService().getActiveReference("root-ca").orElseThrow(), ((CaProfileBinding) credential.profileBinding()).reference()); } @@ -583,7 +586,7 @@ final class CaProfileIssuanceEnforcementTest { .createRoot(new CaCreateCommand(source.framework().formatId(), new SubjectRef("CN=Import Mutation Root"), "root-ca", Optional.of(rootRef), new SimpleAttributeSet())); - Credential sourceCredential = source.caService().getCa(rootId).caCredentials().get(0); + Credential sourceCredential = source.caCredential(source.caService().getCa(rootId), 0); encoded = mutation.mutate(source, sourceCredential, rootKey); } try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("target"), @@ -665,10 +668,10 @@ final class CaProfileIssuanceEnforcementTest { issuerId, new SubjectRef(subject), profileId, Optional.of(keyRef), new SimpleAttributeSet())); } - private static Credential onlyCredential(CaService service, PkiId caId) { - CaRecord ca = service.getCa(caId); - assertEquals(1, ca.caCredentials().size()); - return ca.caCredentials().get(0); + private static Credential onlyCredential(PkiTestRuntime runtime, PkiId caId) { + CaRecord ca = runtime.caService().getCa(caId); + assertEquals(1, ca.credentialIds().size()); + return runtime.caCredential(ca, 0); } private static byte[] caProfileDocument(String builtInProfileId, String profileId, long profileVersion, diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java index c552761..52b20b0 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java @@ -137,11 +137,12 @@ public final class PkiCoreE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), new SubjectRef("CN=Matrix Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes())); - Credential usable = runtime.caService().getCa(rootCaId).caCredentials().get(0); + Credential usable = runtime.caCredential(runtime.caService().getCa(rootCaId), 0); Credential unusable = copyWithId(usable, new PkiId("credential:matrix-unusable")); + runtime.store().putCredential(unusable); CaRecord root = runtime.caService().getCa(rootCaId); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(unusable, usable))); + root.subjectRef(), List.of(unusable.credentialId(), usable.credentialId()))); List resolved = new ArrayList<>(); EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> { @@ -173,7 +174,7 @@ public final class PkiCoreE2eTest { resolved.clear(); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(usable, unusable))); + root.subjectRef(), List.of(usable.credentialId(), unusable.credentialId()))); statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes())); assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); @@ -201,7 +202,7 @@ public final class PkiCoreE2eTest { .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, new SubjectRef("CN=H6 Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); - PkiId rootCredentialId = runtime.caService().getCa(rootCaId).caCredentials().get(0).credentialId(); + PkiId rootCredentialId = runtime.caService().getCa(rootCaId).credentialIds().get(0); runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId, RevocationReason.KEY_COMPROMISE, emptyAttributes())); int submissionsBeforeRejections = runtime.submittedSignCount(); @@ -230,7 +231,7 @@ public final class PkiCoreE2eTest { assertEquals(submissionsBeforeRejections, runtime.submittedSignCount()); assertTrue(runtime.store().getCredential(rootCredentialId).isPresent()); - assertTrue(runtime.caService().getCa(intermediateCaId).caCredentials().size() == 1); + assertTrue(runtime.caService().getCa(intermediateCaId).credentialIds().size() == 1); } } @@ -320,7 +321,7 @@ public final class PkiCoreE2eTest { .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Rejected Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); - Credential rootCredential = runtime.caService().getCa(rootCaId).caCredentials().get(0); + Credential rootCredential = runtime.caCredential(runtime.caService().getCa(rootCaId), 0); EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> status, resolutionFailure); CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend()); IssuanceService issuance = runtime.issuanceService(backend, resolver); @@ -332,7 +333,7 @@ public final class PkiCoreE2eTest { int signCount = runtime.submittedSignCount(); int caCount = runtime.store().listCas().size(); int statusCount = runtime.store().listStatusObjects(rootCaId).size(); - int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).caCredentials().size(); + int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).credentialIds().size(); assertThrows(PkiException.class, () -> issuance .issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()))); @@ -355,7 +356,7 @@ public final class PkiCoreE2eTest { assertEquals(caCount, runtime.store().listCas().size()); assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size()); assertEquals(intermediateCredentialCount, - runtime.caService().getCa(intermediateCaId).caCredentials().size()); + runtime.caService().getCa(intermediateCaId).credentialIds().size()); assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent()); assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL")); } diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java index 73e443d..f30348c 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java @@ -472,7 +472,7 @@ final class PkiProofGateE2eTest { new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), approved)); - Credential first = runtime.caService().getCa(intermediateCaId).caCredentials().get(0); + Credential first = runtime.caCredential(runtime.caService().getCa(intermediateCaId), 0); X509CertificateHolder firstHolder = new X509CertificateHolder(runtime.credentialBytes(first)); assertEquals("CN=Root", firstHolder.getIssuer().toString()); assertEquals("CN=Intermediate", firstHolder.getSubject().toString()); @@ -538,7 +538,7 @@ final class PkiProofGateE2eTest { runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); assertEquals(7, runtime.submittedSignCount()); - assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size()); assertTrue(runtime.store().listWorkflowStates().isEmpty()); } @@ -551,13 +551,13 @@ final class PkiProofGateE2eTest { PkiId rootCaId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); rootCertificate = source - .credentialBytes(source.caService().getCa(rootCaId).caCredentials().get(0)).clone(); + .credentialBytes(source.caCredential(source.caService().getCa(rootCaId), 0)).clone(); PkiId intermediateCaId = source.caService() .createIntermediate(new IntermediateCreateCommand(source.framework().formatId(), rootCaId, new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet())); intermediateCertificate = source - .credentialBytes(source.caService().getCa(intermediateCaId).caCredentials().get(0)).clone(); + .credentialBytes(source.caCredential(source.caService().getCa(intermediateCaId), 0)).clone(); ParsedCertificationRequest leaf = parse(source, makeCsr(subjectKey, subjectKey, "CN=Leaf")); leafCertificate = source.credentialBytes(source.issuanceService() .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty())) @@ -585,10 +585,10 @@ final class PkiProofGateE2eTest { .importRoot(new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Root"), "root-ca", rootKeyRef, target.stageCredential(callerOwnedCertificate), new SimpleAttributeSet())); - assertTrue(target.caService().getCa(importedCaId).caCredentials().get(0) + assertTrue(target.caCredential(target.caService().getCa(importedCaId), 0) .profileBinding() instanceof CaProfileBinding); assertArrayEquals(expectedImportedCertificate, - target.credentialBytes(target.caService().getCa(importedCaId).caCredentials().get(0))); + target.credentialBytes(target.caCredential(target.caService().getCa(importedCaId), 0))); } assertInvalidRootImport(tempDir.resolve("import-leaf"), rootKeyRef, rootKey, leafCertificate, "CN=Leaf"); @@ -787,12 +787,14 @@ final class PkiProofGateE2eTest { runtime.store().getCredential(returned.credential().credentialId()).orElseThrow())); CaRecord root = runtime.caService().getCa(rootCaId); - Credential original = root.caCredentials().get(0); - Credential revoked = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), + Credential original = runtime.caCredential(root, 0); + Credential revoked = new Credential(new PkiId(original.credentialId().value() + ":revoked"), + original.formatId(), original.issuerRef(), original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(), CredentialStatus.REVOKED, original.content(), original.attributes()); + runtime.store().putCredential(revoked); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(revoked))); + root.subjectRef(), List.of(revoked.credentialId()))); int before = runtime.submittedSignCount(); assertThrows(PkiException.class, () -> runtime.issuanceService() .issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty()))); @@ -800,17 +802,19 @@ final class PkiProofGateE2eTest { Validity expiredValidity = new Validity(Instant.now().minus(Duration.ofDays(2)), Instant.now().minus(Duration.ofDays(1))); - Credential expired = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), - original.subjectRef(), expiredValidity, original.serialOrUniqueId(), original.publicKeyId(), - original.profileBinding(), CredentialStatus.ISSUED, original.content(), original.attributes()); + Credential expired = new Credential(new PkiId(original.credentialId().value() + ":expired"), + original.formatId(), original.issuerRef(), original.subjectRef(), expiredValidity, + original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(), + CredentialStatus.ISSUED, original.content(), original.attributes()); + runtime.store().putCredential(expired); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(expired))); + root.subjectRef(), List.of(expired.credentialId()))); assertThrows(PkiException.class, () -> runtime.issuanceService() .issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty()))); assertEquals(before, runtime.submittedSignCount()); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), - root.subjectRef(), List.of(original))); + root.subjectRef(), List.of(original.credentialId()))); ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet()); AttributeSet hostileAttributes = new AttributeSet() { @Override @@ -857,7 +861,7 @@ final class PkiProofGateE2eTest { Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); - assertTrue(runtime.caService().getCa(rootCaId).caCredentials().get(0) + assertTrue(runtime.caCredential(runtime.caService().getCa(rootCaId), 0) .profileBinding() instanceof CaProfileBinding); CredentialIssuerBackend delegate = runtime.issuerBackend(); for (BindingVariantMutation mutation : BindingVariantMutation.values()) { @@ -910,7 +914,7 @@ final class PkiProofGateE2eTest { Optional.empty(), new SimpleAttributeSet())), mutation.name()); assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), mutation.name()); - assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size(), mutation.name()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), mutation.name()); if (produced.get() != null) { assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), mutation.name()); } @@ -935,7 +939,7 @@ final class PkiProofGateE2eTest { () -> wrongSubjectService.issueIntermediateCertificate( new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); - assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size()); CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { @Override @@ -960,7 +964,7 @@ final class PkiProofGateE2eTest { () -> invalidSignatureService.issueIntermediateCertificate( new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); - assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size()); for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) { CaService maliciousExtensionService = runtime @@ -970,7 +974,7 @@ final class PkiProofGateE2eTest { runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())), variant.name()); - assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size(), variant.name()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), variant.name()); } AtomicReference rawCredential = new AtomicReference<>(); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java index 3ebbc27..4ff2503 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java @@ -122,7 +122,7 @@ final class DefaultStatusObjectServiceCrlTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"), Map.of(rootKeyRef, rootKey))) { PkiId caId = createRoot(runtime, rootKeyRef, "CRL Generator Root"); - Credential issuer = runtime.caService().getCa(caId).caCredentials().get(0); + Credential issuer = runtime.caCredential(runtime.caService().getCa(caId), 0); StatusObjectGenerateCommand command = crlCommand(runtime, caId, issuer, rootKeyRef); List reasons = activeReasons(); List serials = serials(reasons.size()); @@ -188,7 +188,7 @@ final class DefaultStatusObjectServiceCrlTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"), Map.of(rootKeyRef, rootKey))) { PkiId caId = createRoot(runtime, rootKeyRef, "CRL Failure Root"); - Credential template = runtime.caService().getCa(caId).caCredentials().get(0); + Credential template = runtime.caCredential(runtime.caService().getCa(caId), 0); StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(caId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes()); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index b5ee4e0..5eac025 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -34,6 +34,7 @@ package zeroecho.pki.impl.fs; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -218,7 +219,7 @@ public final class FilesystemPkiStoreTest { store.putCa(ca1); CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(), - ca1.caCredentials()); + ca1.credentialIds()); store.putCa(ca2); Optional loaded = store.getCa(ca1.caId()); @@ -232,6 +233,216 @@ public final class FilesystemPkiStoreTest { System.out.println("caHistoryCreatesCurrentAndHistory...ok"); } + @Test + void caReferencesRequireValidStandaloneCredentialsAndPreserveOrder() throws Exception { + System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder"); + Path root = tmp.resolve("store-ca-authority"); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + Credential first = TestObjects.minimalCredential(store, "SERIAL-FIRST", "profile-ca"); + Credential second = TestObjects.minimalCredential(store, "SERIAL-SECOND", "profile-ca"); + store.putCredential(first); + store.putCredential(second); + CaRecord ordered = new CaRecord(new PkiId("ca-ordered"), CaKind.ROOT, CaState.ACTIVE, + new KeyRef("key-ordered"), new SubjectRef("CN=Ordered"), + List.of(second.credentialId(), first.credentialId())); + store.putCa(ordered); + + assertEquals(List.of(second.credentialId(), first.credentialId()), + store.getCa(ordered.caId()).orElseThrow().credentialIds()); + CaRecord missing = new CaRecord(new PkiId("ca-missing"), CaKind.ROOT, CaState.ACTIVE, + new KeyRef("key-missing"), new SubjectRef("CN=Missing"), + List.of(new PkiId("credential-missing"))); + assertThrows(IllegalStateException.class, () -> store.putCa(missing)); + + Files.write(root.resolve("staged-content").resolve(first.content().contentId() + ".content"), + new byte[] { 9, 9, 9 }); + assertThrows(IllegalStateException.class, () -> store.getCa(ordered.caId())); + assertThrows(IllegalStateException.class, store::listCas); + } + System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder...ok"); + } + + @Test + void oldStoreVersionIsRejected() throws Exception { + System.out.println("oldStoreVersionIsRejected"); + Path root = tmp.resolve("store-old-version"); + Files.createDirectories(root); + Files.writeString(root.resolve(FsPaths.VERSION_FILE), "v2"); + + assertThrows(IllegalStateException.class, + () -> new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())); + System.out.println("oldStoreVersionIsRejected...ok"); + } + + @Test + void snapshotReconstructsSharedCredentialOnceWithNewTargetReference() throws Exception { + System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference"); + Path root = tmp.resolve("store-snapshot-authority"); + Path snapshot = tmp.resolve("snapshot-authority"); + FsPkiStoreOptions options = nonStrictSnapshotOptions(); + PkiId credentialId; + String sourceContentId; + try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) { + Credential shared = TestObjects.minimalCredential(source, "SERIAL-SHARED", "profile-ca"); + source.putCredential(shared); + credentialId = shared.credentialId(); + sourceContentId = shared.content().contentId(); + source.putCa(new CaRecord(new PkiId("ca-shared-one"), CaKind.ROOT, CaState.ACTIVE, + new KeyRef("key-shared-one"), new SubjectRef("CN=Shared One"), List.of(credentialId))); + source.putCa(new CaRecord(new PkiId("ca-shared-two"), CaKind.ROOT, CaState.ACTIVE, + new KeyRef("key-shared-two"), new SubjectRef("CN=Shared Two"), List.of(credentialId))); + source.exportSnapshot(snapshot, Instant.now()); + } + + try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshot, options)) { + assertEquals(2, restored.listCas().size()); + Credential credential = restored.getCredential(credentialId).orElseThrow(); + assertFalse(sourceContentId.equals(credential.content().contentId())); + assertEquals(List.of(credentialId), + restored.getCa(new PkiId("ca-shared-one")).orElseThrow().credentialIds()); + } + try (java.util.stream.Stream records = Files.list(snapshot.resolve("credentials").resolve("by-id"))) { + assertEquals(1, records.filter(Files::isRegularFile).count()); + } + assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".content"))); + assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".meta"))); + assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".owners"))); + System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference...ok"); + } + + @Test + void snapshotPreservesStandaloneCredentialWithNewReferenceAndResolvableCa() throws Exception { + System.out.println("snapshotPreservesStandaloneCredentialWithNewReferenceAndResolvableCa"); + Path root = tmp.resolve("store-snapshot-standalone"); + Path snapshot = tmp.resolve("snapshot-standalone"); + FsPkiStoreOptions options = nonStrictSnapshotOptions(); + PkiId standaloneId; + String standaloneSourceContentId; + PkiId caId; + try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) { + Credential standalone = TestObjects.minimalCredential(source, "SERIAL-END-ENTITY", "profile-leaf"); + source.putCredential(standalone); + standaloneId = standalone.credentialId(); + standaloneSourceContentId = standalone.content().contentId(); + CaRecord ca = TestObjects.minimalCaRecord(source, "ca-with-authority", CaState.ACTIVE); + source.putCa(ca); + caId = ca.caId(); + source.exportSnapshot(snapshot, Instant.now()); + } + + try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshot, options)) { + Credential standalone = restored.getCredential(standaloneId).orElseThrow(); + assertFalse(standaloneSourceContentId.equals(standalone.content().contentId())); + CaRecord ca = restored.getCa(caId).orElseThrow(); + assertTrue(restored.getCredential(ca.credentialIds().get(0)).isPresent()); + } + System.out.println("snapshotPreservesStandaloneCredentialWithNewReferenceAndResolvableCa...ok"); + } + + @Test + void snapshotPreservesSharedStatusContentWithoutStaleCredentialOwner() throws Exception { + System.out.println("snapshotPreservesSharedStatusContentWithoutStaleCredentialOwner"); + Path root = tmp.resolve("store-snapshot-shared-status"); + Path snapshot = tmp.resolve("snapshot-shared-status"); + FsPkiStoreOptions options = nonStrictSnapshotOptions(); + PkiId credentialId; + PkiId statusId = new PkiId("status-shared-content"); + String sourceContentId; + byte[] expected = new byte[] { 1, 2, 3 }; + try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) { + Credential credential = TestObjects.minimalCredential(source, "SERIAL-SHARED-STATUS", "profile-ca"); + source.putCredential(credential); + credentialId = credential.credentialId(); + sourceContentId = credential.content().contentId(); + StatusObject status = new StatusObject(statusId, credential.formatId(), credential.issuerRef().caId(), + StatusObjectType.CRL, Instant.EPOCH, Optional.empty(), credential.content(), + TestObjects.emptyAttributes()); + source.putStatusObject(status); + source.exportSnapshot(snapshot, Instant.now()); + } + + try (FilesystemPkiStore restored = new FilesystemPkiStore(snapshot, options)) { + Credential credential = restored.getCredential(credentialId).orElseThrow(); + StatusObject status = restored.getStatusObject(statusId).orElseThrow(); + assertFalse(sourceContentId.equals(credential.content().contentId())); + assertEquals(sourceContentId, status.content().contentId()); + assertArrayEquals(expected, zeroecho.pki.testkit.PkiTestRuntime.readContent(restored, credential.content())); + assertArrayEquals(expected, zeroecho.pki.testkit.PkiTestRuntime.readContent(restored, status.content())); + } + assertTrue(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".content"))); + assertTrue(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".meta"))); + assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".owners"))); + System.out.println("snapshotPreservesSharedStatusContentWithoutStaleCredentialOwner...ok"); + } + + @Test + void snapshotBuildFailureCleansOwnedTemporaryRootAndPublishesNothing() throws Exception { + System.out.println("snapshotBuildFailureCleansOwnedTemporaryRootAndPublishesNothing"); + Path root = tmp.resolve("store-snapshot-build-failure"); + Path snapshot = tmp.resolve("snapshot-build-failure"); + FsPkiStoreOptions options = nonStrictSnapshotOptions(); + try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) { + Credential standalone = TestObjects.minimalCredential(source, "SERIAL-BUILD-FAIL", "profile-leaf"); + source.putCredential(standalone); + Files.delete(root.resolve("SIGNING_TIME_WATERMARK")); + + assertThrows(IllegalStateException.class, () -> source.exportSnapshot(snapshot, Instant.now())); + assertFalse(Files.exists(snapshot)); + } + try (java.util.stream.Stream siblings = Files.list(tmp)) { + assertFalse(siblings.anyMatch(path -> path.getFileName().toString().startsWith(".zeroecho-snapshot-"))); + } + System.out.println("snapshotBuildFailureCleansOwnedTemporaryRootAndPublishesNothing...ok"); + } + + @Test + void snapshotRejectsExistingTargetWithoutChangingIt() throws Exception { + System.out.println("snapshotRejectsExistingTargetWithoutChangingIt"); + Path root = tmp.resolve("store-snapshot-existing-target"); + Path snapshot = tmp.resolve("snapshot-existing-target"); + Path sentinel = snapshot.resolve("sentinel.txt"); + Files.createDirectories(snapshot); + Files.writeString(sentinel, "owner data"); + + try (FilesystemPkiStore source = new FilesystemPkiStore(root, nonStrictSnapshotOptions())) { + assertThrows(IllegalStateException.class, () -> source.exportSnapshot(snapshot, Instant.now())); + } + + assertEquals("owner data", Files.readString(sentinel)); + try (java.util.stream.Stream siblings = Files.list(tmp)) { + assertFalse(siblings.anyMatch(path -> path.getFileName().toString().startsWith(".zeroecho-snapshot-"))); + } + System.out.println("snapshotRejectsExistingTargetWithoutChangingIt...ok"); + } + + @Test + void snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa() throws Exception { + System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa"); + Path root = tmp.resolve("store-snapshot-inconsistent"); + Path strictSnapshot = tmp.resolve("snapshot-inconsistent-strict"); + Path nonStrictSnapshot = tmp.resolve("snapshot-inconsistent-nonstrict"); + FsPkiStoreOptions nonStrict = nonStrictSnapshotOptions(); + try (FilesystemPkiStore source = new FilesystemPkiStore(root, nonStrict)) { + CaRecord valid = TestObjects.minimalCaRecord(source, "ca-valid", CaState.ACTIVE); + CaRecord inconsistent = TestObjects.minimalCaRecord(source, "ca-inconsistent", CaState.ACTIVE); + source.putCa(valid); + source.putCa(inconsistent); + Files.delete(new FsPaths(root).credentialPath(inconsistent.credentialIds().get(0))); + + FsPkiStoreOptions strict = strictSnapshotOptions(); + assertThrows(IllegalStateException.class, + () -> new FsSnapshotExporter(strict).exportSnapshot(source, strictSnapshot, Instant.now())); + assertFalse(Files.exists(strictSnapshot)); + + source.exportSnapshot(nonStrictSnapshot, Instant.now()); + } + try (FilesystemPkiStore restored = new FilesystemPkiStore(nonStrictSnapshot, nonStrict)) { + assertEquals(List.of("ca-valid"), restored.listCas().stream() + .map(ca -> ca.caId().value()).toList()); + } + System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa...ok"); + } + @Test void revocationJournalPersistsLegalTransitions() throws Exception { System.out.println("revocationJournalPersistsLegalTransitions"); @@ -553,9 +764,9 @@ public final class FilesystemPkiStoreTest { SubjectRef subjectRef = new SubjectRef("CN=" + caId); Credential cred = minimalCredential(store, "CA-" + caId, "profile-ca"); - List caCredentials = List.of(cred); - - return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials); + store.putCredential(cred); + return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, + List.of(cred.credentialId())); } static CertificateProfile minimalProfile(String profileId) { diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java index a0f5583..e85db0f 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java @@ -59,12 +59,16 @@ import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuerRef; +import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.Validity; import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.ca.CaKind; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialProfileBinding; @@ -242,6 +246,30 @@ final class FsCodecTest { assertInvalid(encoded); } + @Test + void caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag() { + System.out.println("caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag"); + PkiId first = new PkiId("credential-first"); + PkiId second = new PkiId("credential-second"); + CaRecord original = new CaRecord(new PkiId("ca-codec"), CaKind.ROOT, CaState.ACTIVE, + new KeyRef("key-codec"), new SubjectRef("CN=Codec"), List.of(first, second)); + + byte[] encoded = FsCodec.encode(FsCodec.CA_RECORD, original); + CaRecord decoded = FsCodec.decode(FsCodec.CA_RECORD, encoded); + assertEquals(original, decoded); + assertEquals(List.of(first, second), decoded.credentialIds()); + + byte[] embeddedValueTag = encoded.clone(); + int firstIdentifier = indexOf(embeddedValueTag, first.value().getBytes(StandardCharsets.UTF_8)); + assertTrue(firstIdentifier > 0); + int pkiIdTag = findPrevious(embeddedValueTag, firstIdentifier, (byte) 20); + assertTrue(pkiIdTag >= 0); + embeddedValueTag[pkiIdTag] = 34; + assertThrows(IllegalStateException.class, + () -> FsCodec.decode(FsCodec.CA_RECORD, embeddedValueTag)); + System.out.println("caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag...ok"); + } + private static int indexOf(byte[] source, byte[] target) { for (int index = 0; index <= source.length - target.length; index++) { boolean matches = true; @@ -258,6 +286,15 @@ final class FsCodecTest { return -1; } + private static int findPrevious(byte[] source, int startExclusive, byte value) { + for (int index = startExclusive - 1; index >= 0; index--) { + if (source[index] == value) { + return index; + } + } + return -1; + } + private static ParsedCertificationRequest roundTripRequest(AttributeSet attributes) { byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes)); return FsCodec.decode(FsCodec.PARSED_REQUEST, encoded); diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java index fcebb40..fbff6d1 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java +++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java @@ -59,10 +59,12 @@ import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiId; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.content.DurableContentReference; +import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; import zeroecho.pki.impl.audit.InMemoryAuditSink; @@ -206,6 +208,11 @@ public final class PkiTestRuntime implements AutoCloseable { return readContent(store, credential.content()); } + public Credential caCredential(zeroecho.pki.api.ca.CaRecord ca, int index) { + PkiId credentialId = ca.credentialIds().get(index); + return store.getCredential(credentialId).orElseThrow(); + } + private record UntrustedReference(String storeId, String contentId, Encoding encoding, long length, String sha256, DurableContentReference.Lifecycle lifecycle) implements DurableContentReference { }