diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 13dbeda7e..544be985c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture Map -Last updated: 2026-02-23 +Last updated: 2026-08-29 ## System Purpose @@ -45,6 +45,13 @@ Current state: viewer/state API is implemented in this repository; downstream S2 - Domain lifecycle and retry metadata (`attemptCount`, `maxAttempts`, `retryAt`, `deadLettered`) plus manual dead-letter retry transition. - `ViewerBootstrapResponse` (`src/main/java/com/clearfolio/viewer/api/ViewerBootstrapResponse.java`) - Includes deterministic `sourceExtension` and `rendererAdapter` metadata for viewer adapter bootstrap. +- `CredentialRegistry` (`src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java`) + - Provider-neutral credential-resolution boundary for the issue #319 secret-source migration. + - Runtime consumers use `resolveScoped(...)`; provider adapters implement only `resolveAdapterMaterial(...)`. + - The scoped boundary fails closed when material is absent or when the returned logical credential identity or `CredentialPurpose` differs from the server-owned `CredentialReference`. + - `CredentialSnapshot` carries provider-returned version metadata, defensively copies secret bytes, and redacts secret material from string rendering. + - Provider-specific aliases remain inside adapters; runtime code accepts only the normalized server-owned logical credential identity. Persistence, KMS/HSM binding, rotation windows, audit writes, Spring bootstrap, and migration of existing HMAC consumers remain issue #319 follow-up scope. + - Standards traceability is recorded in `docs/research/2026-08-29-credential-registry-key-management-grounding.md`. ## State Model diff --git a/CHANGELOG.md b/CHANGELOG.md index 1187deb2a..22bd51afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Security +- issue #319의 런타임 HMAC 비밀 이동을 위한 provider-neutral `CredentialRegistry` 계약을 추가했습니다. 런타임은 `resolveScoped(...)`만 사용하고 provider adapter는 `resolveAdapterMaterial(...)`을 구현하며, 반환 material이 없거나 server-owned logical credential identity 또는 `CredentialPurpose`가 다르면 fail closed 처리합니다. `CredentialSnapshot`은 secret bytes를 방어적으로 복사하고 문자열 출력에서 비밀을 숨깁니다. NIST SP 800-57 Part 1 Rev. 5 기반 key identity/purpose 분리 근거와 후속 KMS/HSM·rotation·audit·consumer migration 경계를 문서화했습니다. - `GET /api/v1/convert/jobs/{jobId}/download`가 리소스 조회 전에 전용 `artifact:read` 권한을 검증하고, PDF 저장소 접근 전에 작업의 tenant 소유권을 확인하도록 강화했습니다. `job:read`만으로는 문서 바이트를 읽을 수 없으며, 인증 누락·권한 누락·교차 tenant UUID 접근은 각각 fail closed 처리되고 교차 tenant 요청은 리소스 존재를 숨기는 `404`를 반환합니다. - Maven XML 테스트 보고서 검증기는 각 `testsuite`의 `tests`, `skipped`, `failures`, `errors` 속성을 모두 필수 증거로 요구합니다. 누락된 결과 수를 암묵적으로 0으로 간주하지 않고 fail closed 처리하며, 각 속성 누락 회귀 테스트를 추가했습니다. - Maven XML 테스트 보고서 검증기는 UTF-8만 허용하고 UTF-8 BOM은 수용하며, NUL 바이트·DTD·엔터티 선언을 파싱 전에 거부합니다. UTF-16 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다. diff --git a/docs/research/2026-08-29-credential-registry-key-management-grounding.md b/docs/research/2026-08-29-credential-registry-key-management-grounding.md new file mode 100644 index 000000000..b73811203 --- /dev/null +++ b/docs/research/2026-08-29-credential-registry-key-management-grounding.md @@ -0,0 +1,28 @@ +# Credential registry key-management grounding + +## Decision + +The provider-neutral credential registry boundary binds each server-owned logical credential name to the returned credential identifier and `CredentialPurpose`. Resolution fails closed when material is missing, the logical identity differs, or the cryptographic purpose differs. The provider-returned version remains metadata for later rotation and audit integration. + +This keeps provider translation inside the adapter boundary: if a backing KMS, HSM, database, or managed-secret service uses a provider-specific alias, the adapter must map that alias back to the server-owned logical credential identifier before returning a `CredentialSnapshot`. Runtime consumers do not accept ambiguous or substituted identities. + +## Standards traceability + +NIST SP 800-57 Part 1 Rev. 5 treats key-control metadata such as key identifiers and intended usage as information that must be protected so cryptographic keying material is used correctly. It also recommends that a key generally be used for only one purpose. These controls support Clearfolio's strict logical-identity binding and distinct `CredentialPurpose` values for tenant-claims signing and artifact-token signing. + +Revision 5 remains the current final Part 1 publication. NIST published Revision 6 only as an Initial Public Draft on December 5, 2025; its public-comment period closed on February 5, 2026. The final-revision contract therefore continues to cite Revision 5 rather than silently adopting draft guidance. + +This PR defines only the provider-neutral contract. Persistence, KMS/HSM integration, rotation/compatibility windows, audit-record writes, Spring bootstrap, and runtime migration of `TenantAccessService` and `ArtifactLinkService` remain owned by issue #319. + +## Source artifact provenance + +- Official publication page: +- Official PDF: +- DOI: +- Redistribution status: the publication itself states that it is not subject to copyright in the United States and that attribution is appreciated by NIST. NIST's Research Library likewise states that NIST Federal-government publications are generally public-domain works in the United States and may be reprinted or copied with source credit. + +`AGENTS.md` asks substantive PRs to commit paper PDFs when redistribution is permissible. The repository does not currently contain this NIST PDF under `docs/papers/`, so this note deliberately does not invent a repository-relative attachment. Promotion remains blocked on the open review requirement until the actual binary artifact is committed and linked here, or the repository rule is changed through its canonical governance path. + +## APA 7 reference + +Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Rev. 5). National Institute of Standards and Technology. diff --git a/src/main/java/com/clearfolio/viewer/credential/CredentialPurpose.java b/src/main/java/com/clearfolio/viewer/credential/CredentialPurpose.java new file mode 100644 index 000000000..0d9d1cd68 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/credential/CredentialPurpose.java @@ -0,0 +1,21 @@ +package com.clearfolio.viewer.credential; + +/** + * Cryptographic purpose that constrains how resolved credential material may be used. + * + *

Purpose separation prevents one signing key from silently becoming authority + * for a different security boundary. Registry adapters must return material whose + * stored purpose matches the requested reference.

+ */ +public enum CredentialPurpose { + + /** + * HMAC material used only for tenant-claim signing and verification. + */ + TENANT_CLAIMS_SIGNING, + + /** + * HMAC material used only for artifact-link signing and verification. + */ + ARTIFACT_TOKEN_SIGNING +} diff --git a/src/main/java/com/clearfolio/viewer/credential/CredentialReference.java b/src/main/java/com/clearfolio/viewer/credential/CredentialReference.java new file mode 100644 index 000000000..98be20269 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/credential/CredentialReference.java @@ -0,0 +1,26 @@ +package com.clearfolio.viewer.credential; + +import java.util.Objects; + +/** + * Immutable server-owned reference to one purpose-scoped credential. + * + * @param credentialName stable logical credential name, never secret material + * @param purpose cryptographic purpose that the resolved material may serve + */ +public record CredentialReference(String credentialName, CredentialPurpose purpose) { + + /** + * Validates and normalizes the credential authority reference. + * + * @throws IllegalArgumentException when the credential name is absent or blank + * @throws NullPointerException when the credential purpose is absent + */ + public CredentialReference { + if (credentialName == null || credentialName.isBlank()) { + throw new IllegalArgumentException("credentialName must not be blank"); + } + credentialName = credentialName.strip(); + Objects.requireNonNull(purpose, "purpose"); + } +} diff --git a/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java new file mode 100644 index 000000000..dd977be68 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java @@ -0,0 +1,62 @@ +package com.clearfolio.viewer.credential; + +import java.util.Objects; + +/** + * Provider-neutral boundary for resolving versioned credential material. + * + *

Implementations may use an encrypted database registry, managed secret + * service, HSM/KMS-backed adapter, or deterministic test fixture. Runtime + * consumers depend only on this contract so environment variables can remain + * bootstrap transport rather than the credential source of truth.

+ * + *

Runtime consumers call {@link #resolveScoped(CredentialReference)}. The + * single abstract {@link #resolveAdapterMaterial(CredentialReference)} method is + * deliberately named as an adapter hook so the lambda-friendly implementation + * surface cannot be mistaken for the authority-enforcing consumer boundary.

+ */ +@FunctionalInterface +public interface CredentialRegistry { + + /** + * Version of the provider-neutral credential resolution contract. + */ + String CONTRACT_VERSION = "credential-registry-v1"; + + /** + * Obtains one credential snapshot from a provider adapter. + * + *

This method is an adapter implementation hook only. Runtime consumers + * must use {@link #resolveScoped(CredentialReference)}, which independently + * verifies that returned material is present and bound to the requested + * logical credential identity and cryptographic purpose.

+ * + * @param reference server-owned logical credential reference + * @return versioned credential snapshot supplied by the adapter + */ + CredentialSnapshot resolveAdapterMaterial(CredentialReference reference); + + /** + * Resolves credential material and fails closed when adapter authority drifts. + * + * @param reference server-owned logical credential reference + * @return versioned credential snapshot whose identity and purpose match the reference + * @throws NullPointerException when the reference is absent + * @throws IllegalStateException when the adapter returns no material or returns + * material for a different logical identity or purpose + */ + default CredentialSnapshot resolveScoped(CredentialReference reference) { + CredentialReference requiredReference = Objects.requireNonNull(reference, "reference"); + CredentialSnapshot snapshot = resolveAdapterMaterial(requiredReference); + if (snapshot == null) { + throw new IllegalStateException("credential registry returned no material"); + } + if (!snapshot.credentialId().equals(requiredReference.credentialName())) { + throw new IllegalStateException("credential registry returned material for a different identity"); + } + if (snapshot.purpose() != requiredReference.purpose()) { + throw new IllegalStateException("credential registry returned material for a different purpose"); + } + return snapshot; + } +} diff --git a/src/main/java/com/clearfolio/viewer/credential/CredentialSnapshot.java b/src/main/java/com/clearfolio/viewer/credential/CredentialSnapshot.java new file mode 100644 index 000000000..1e9b9ad64 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/credential/CredentialSnapshot.java @@ -0,0 +1,113 @@ +package com.clearfolio.viewer.credential; + +import java.util.Objects; + +/** + * Immutable versioned snapshot of secret material returned by a credential registry. + * + *

Secret bytes are defensively copied on construction and access. The string + * representation is deliberately redacted so routine logging cannot reveal key + * material.

+ */ +public final class CredentialSnapshot { + + /** Stable logical identifier for the resolved credential. */ + private final String credentialId; + + /** Version identifier of the resolved credential material. */ + private final String version; + + /** Cryptographic purpose authorized for the resolved material. */ + private final CredentialPurpose purpose; + + /** Private defensive copy of the resolved secret bytes. */ + private final byte[] secretBytes; + + /** + * Creates one immutable credential snapshot. + * + * @param credentialId stable logical credential identifier + * @param version exact resolved credential version + * @param purpose cryptographic purpose authorized for this material + * @param secretBytes non-empty secret material copied by this instance + * @throws IllegalArgumentException when an identifier, version, or secret is absent + * @throws NullPointerException when the credential purpose is absent + */ + public CredentialSnapshot( + String credentialId, + String version, + CredentialPurpose purpose, + byte[] secretBytes + ) { + this.credentialId = requireText(credentialId, "credentialId"); + this.version = requireText(version, "version"); + this.purpose = Objects.requireNonNull(purpose, "purpose"); + if (secretBytes == null || secretBytes.length == 0) { + throw new IllegalArgumentException("secretBytes must not be empty"); + } + this.secretBytes = secretBytes.clone(); + } + + /** + * Returns the stable logical credential identifier. + * + * @return credential identifier + */ + public String credentialId() { + return credentialId; + } + + /** + * Returns the exact resolved credential version. + * + * @return credential version + */ + public String version() { + return version; + } + + /** + * Returns the cryptographic purpose authorized for this material. + * + * @return credential purpose + */ + public CredentialPurpose purpose() { + return purpose; + } + + /** + * Returns a defensive copy of the secret material. + * + * @return newly copied secret bytes + */ + public byte[] secretBytes() { + return secretBytes.clone(); + } + + /** + * Returns metadata while redacting the secret payload. + * + * @return redacted snapshot description + */ + @Override + public String toString() { + return "CredentialSnapshot[credentialId=" + credentialId + + ", version=" + version + + ", purpose=" + purpose + + ", secret=]"; + } + + /** + * Validates and strips a public metadata field. + * + * @param value metadata value + * @param fieldName field name used only in the controlled error message + * @return stripped non-blank value + */ + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + return value.strip(); + } +} diff --git a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java new file mode 100644 index 000000000..d04c9620d --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java @@ -0,0 +1,155 @@ +package com.clearfolio.viewer.credential; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class CredentialRegistryContractTest { + + @Test + void registryResolvesPurposeScopedVersionedMaterial() { + CredentialReference reference = new CredentialReference( + " tenant-claims-hmac ", + CredentialPurpose.TENANT_CLAIMS_SIGNING + ); + CredentialSnapshot expected = new CredentialSnapshot( + "tenant-claims-hmac", + "v7", + CredentialPurpose.TENANT_CLAIMS_SIGNING, + new byte[] {1, 2, 3, 4} + ); + CredentialRegistry registry = actual -> { + assertEquals(reference, actual); + return expected; + }; + + assertEquals("tenant-claims-hmac", reference.credentialName()); + assertEquals(expected, registry.resolveAdapterMaterial(reference)); + assertEquals(expected, registry.resolveScoped(reference)); + assertEquals("credential-registry-v1", CredentialRegistry.CONTRACT_VERSION); + } + + @Test + void registryDoesNotExposeRawResolveAsConsumerFacingMethod() { + assertThrows( + NoSuchMethodException.class, + () -> CredentialRegistry.class.getDeclaredMethod("resolve", CredentialReference.class) + ); + } + + @Test + void scopedResolutionRejectsPurposeMismatchWhileKeepingAdapterHookExplicit() { + CredentialReference reference = new CredentialReference( + "tenant-claims-hmac", + CredentialPurpose.TENANT_CLAIMS_SIGNING + ); + CredentialSnapshot mismatched = new CredentialSnapshot( + "tenant-claims-hmac", + "v2", + CredentialPurpose.ARTIFACT_TOKEN_SIGNING, + new byte[] {5, 4, 3, 2} + ); + CredentialRegistry registry = ignored -> mismatched; + + assertEquals(mismatched, registry.resolveAdapterMaterial(reference)); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> registry.resolveScoped(reference) + ); + assertEquals("credential registry returned material for a different purpose", failure.getMessage()); + } + + @Test + void scopedResolutionRejectsCredentialIdentitySubstitution() { + CredentialReference reference = new CredentialReference( + "tenant-claims-hmac", + CredentialPurpose.TENANT_CLAIMS_SIGNING + ); + CredentialSnapshot substituted = new CredentialSnapshot( + "tenant-claims-secondary", + "v9", + CredentialPurpose.TENANT_CLAIMS_SIGNING, + new byte[] {8, 6, 4, 2} + ); + CredentialRegistry registry = ignored -> substituted; + + assertEquals(substituted, registry.resolveAdapterMaterial(reference)); + assertThrows(IllegalStateException.class, () -> registry.resolveScoped(reference)); + } + + @Test + void scopedResolutionRejectsMissingAdapterMaterial() { + CredentialReference reference = new CredentialReference( + "tenant-claims-hmac", + CredentialPurpose.TENANT_CLAIMS_SIGNING + ); + CredentialRegistry registry = ignored -> null; + + assertThrows(IllegalStateException.class, () -> registry.resolveScoped(reference)); + } + + @Test + void credentialReferenceFailsClosedForInvalidAuthority() { + assertThrows(IllegalArgumentException.class, () -> new CredentialReference( + null, + CredentialPurpose.TENANT_CLAIMS_SIGNING + )); + assertThrows(IllegalArgumentException.class, () -> new CredentialReference( + " ", + CredentialPurpose.TENANT_CLAIMS_SIGNING + )); + assertThrows(NullPointerException.class, () -> new CredentialReference("tenant-key", null)); + } + + @Test + void credentialSnapshotDefensivelyCopiesAndRedactsSecretMaterial() { + byte[] source = new byte[] {9, 8, 7, 6}; + CredentialSnapshot snapshot = new CredentialSnapshot( + " artifact-token-hmac ", + " v2 ", + CredentialPurpose.ARTIFACT_TOKEN_SIGNING, + source + ); + source[0] = 0; + + byte[] first = snapshot.secretBytes(); + byte[] second = snapshot.secretBytes(); + + assertArrayEquals(new byte[] {9, 8, 7, 6}, first); + assertArrayEquals(first, second); + assertNotSame(first, second); + first[1] = 0; + assertArrayEquals(new byte[] {9, 8, 7, 6}, snapshot.secretBytes()); + assertEquals("artifact-token-hmac", snapshot.credentialId()); + assertEquals("v2", snapshot.version()); + assertEquals(CredentialPurpose.ARTIFACT_TOKEN_SIGNING, snapshot.purpose()); + assertFalse(snapshot.toString().contains("9")); + assertEquals( + "CredentialSnapshot[credentialId=artifact-token-hmac, version=v2, purpose=ARTIFACT_TOKEN_SIGNING, secret=]", + snapshot.toString() + ); + } + + @Test + void credentialSnapshotFailsClosedForInvalidMetadataOrMaterial() { + assertThrows(IllegalArgumentException.class, () -> new CredentialSnapshot( + null, "v1", CredentialPurpose.TENANT_CLAIMS_SIGNING, new byte[] {1} + )); + assertThrows(IllegalArgumentException.class, () -> new CredentialSnapshot( + "key", " ", CredentialPurpose.TENANT_CLAIMS_SIGNING, new byte[] {1} + )); + assertThrows(NullPointerException.class, () -> new CredentialSnapshot( + "key", "v1", null, new byte[] {1} + )); + assertThrows(IllegalArgumentException.class, () -> new CredentialSnapshot( + "key", "v1", CredentialPurpose.TENANT_CLAIMS_SIGNING, null + )); + assertThrows(IllegalArgumentException.class, () -> new CredentialSnapshot( + "key", "v1", CredentialPurpose.TENANT_CLAIMS_SIGNING, new byte[0] + )); + } +}