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
This commit is contained in:
@@ -152,6 +152,12 @@ public interface CaService {
|
||||
/**
|
||||
* Retrieves a CA record.
|
||||
*
|
||||
* <p>
|
||||
* The returned record contains ordered credential identifiers. Credential
|
||||
* content and metadata remain authoritative only through the configured
|
||||
* credential store.
|
||||
* </p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>
|
||||
* Returned records contain ordered credential identifiers rather than embedded
|
||||
* credential values.
|
||||
* </p>
|
||||
*
|
||||
* @param query query constraints
|
||||
* @return list of CA records
|
||||
* @throws IllegalArgumentException if {@code query} is invalid
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>
|
||||
* 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<Credential> caCredentials) {
|
||||
List<PkiId> 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<PkiId> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Credential> updated = new ArrayList<>(subject.caCredentials());
|
||||
updated.add(cred);
|
||||
List<PkiId> 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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Credential> credentials = ca.caCredentials();
|
||||
for (int index = credentials.size() - 1; index >= 0; index--) {
|
||||
Credential credential = credentials.get(index);
|
||||
List<PkiId> 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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
* <p>
|
||||
* 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<String> snapshotNonCredentialContentIds() {
|
||||
requireStoreUsable();
|
||||
Set<String> 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<CaRecord> 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();
|
||||
|
||||
@@ -448,7 +448,7 @@ final class FsCodec {
|
||||
|
||||
private static final ValueSchema<Credential> CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD,
|
||||
FsCodec::writeCredential, FsCodec::readCredential);
|
||||
private static final ValueSchema<List<Credential>> CREDENTIALS = listOf(CREDENTIAL_VALUE);
|
||||
private static final ValueSchema<List<PkiId>> PKI_IDS = listOf(PKI_ID);
|
||||
|
||||
/* package */ static final Schema<CaRecord> 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 {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*/
|
||||
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<SnapshotProfileArtifact> profiles = preflightImportedProfiles(sourceRoot.resolve("profiles"), at);
|
||||
FsOperations.ensureDir(targetRoot);
|
||||
FsPaths dst = new FsPaths(targetRoot);
|
||||
SnapshotAuthority authority = new AuthorityPlanner(source, options).plan(at);
|
||||
Set<String> 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<Path> selectMutableRecords(Path srcTree, Instant at, FsHistoryPolicy policy, boolean strict)
|
||||
throws IOException {
|
||||
if (!Files.exists(srcTree)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Path> selected = new ArrayList<>();
|
||||
try (java.util.stream.Stream<Path> 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<CaRecord> cas = selectCas(at, inventory.credentials());
|
||||
return new SnapshotAuthority(cas, inventory.credentials(), inventory.contentIds());
|
||||
}
|
||||
|
||||
private CredentialInventory inventoryCredentials() throws IOException {
|
||||
Map<PkiId, Credential> credentials = new LinkedHashMap<>();
|
||||
Set<String> 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<Path> 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<PkiId, Credential> credentials, Set<String> 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<CaRecord> selectCas(Instant at, Map<PkiId, Credential> credentials) throws IOException {
|
||||
List<Path> selectedRecords = selectMutableRecords(source.snapshotRoot().resolve("cas"), at,
|
||||
options.caHistoryPolicy(), options.strictSnapshotExport());
|
||||
List<CaRecord> 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<PkiId> transferred = new HashSet<>();
|
||||
try (FilesystemPkiStore target = new FilesystemPkiStore(targetRoot, options)) {
|
||||
for (Map.Entry<PkiId, Credential> 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<Path> paths = Files.walk(root)) {
|
||||
List<Path> 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<String> credentialContentIds,
|
||||
Set<String> nonCredentialContentIds) throws IOException {
|
||||
if (!Files.exists(src)) {
|
||||
return;
|
||||
}
|
||||
try (java.util.stream.Stream<Path> 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<String> credentialContentIds,
|
||||
Set<String> 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<PkiId, Credential> credentials, Set<String> contentIds) {
|
||||
private CredentialInventory {
|
||||
credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials));
|
||||
contentIds = Set.copyOf(contentIds);
|
||||
}
|
||||
}
|
||||
|
||||
private record SnapshotAuthority(List<CaRecord> cas, Map<PkiId, Credential> credentials,
|
||||
Set<String> sourceCredentialContentIds) {
|
||||
private SnapshotAuthority {
|
||||
cas = List.copyOf(cas);
|
||||
credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials));
|
||||
sourceCredentialContentIds = Set.copyOf(sourceCredentialContentIds);
|
||||
}
|
||||
}
|
||||
|
||||
private record SnapshotPlan(List<SnapshotProfileArtifact> profiles, SnapshotAuthority authority,
|
||||
Set<String> 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;
|
||||
|
||||
@@ -88,12 +88,15 @@ public interface PkiStore extends SignWorkflowStore {
|
||||
*
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
*
|
||||
* @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<CaRecord> 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<CaRecord> listCas();
|
||||
|
||||
|
||||
72
pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java
Normal file
72
pki/src/test/java/zeroecho/pki/api/ca/CaRecordTest.java
Normal file
@@ -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<PkiId> 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<PkiId> credentialIds) {
|
||||
return new CaRecord(new PkiId("ca-test"), CaKind.ROOT, CaState.ACTIVE, new KeyRef("key-test"),
|
||||
new SubjectRef("CN=Test"), credentialIds);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PkiId> 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"));
|
||||
}
|
||||
|
||||
@@ -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<Credential> rawCredential = new AtomicReference<>();
|
||||
|
||||
@@ -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<RevocationReason> reasons = activeReasons();
|
||||
List<BigInteger> 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());
|
||||
|
||||
|
||||
@@ -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<CaRecord> 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<Path> 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<Path> 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<Path> 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<Credential> 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user