From 4b70001c7439a8b470de0bdea7c7aa503e4fc7dd Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 00:15:40 +0900
Subject: [PATCH 01/18] test(security): define credential registry contract
---
.../CredentialRegistryContractTest.java | 95 +++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
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..d87322fdd
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
@@ -0,0 +1,95 @@
+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.resolve(reference));
+ assertEquals("credential-registry-v1", CredentialRegistry.CONTRACT_VERSION);
+ }
+
+ @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]
+ ));
+ }
+}
From fecd1412ac8f3a1f264636ee21c7c36d3d97484e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 00:20:05 +0900
Subject: [PATCH 02/18] feat(security): add credential purpose separation
---
.../viewer/credential/CredentialPurpose.java | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/credential/CredentialPurpose.java
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
+}
From 6af1d089e3e8306a10ead4681a115f38a5b9a4a9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 00:20:48 +0900
Subject: [PATCH 03/18] feat(security): add purpose-scoped credential reference
---
.../credential/CredentialReference.java | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/credential/CredentialReference.java
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");
+ }
+}
From 26dfadbfd0ecaa48e657374e3b30515ed1032e8b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 00:21:47 +0900
Subject: [PATCH 04/18] feat(security): add redacted credential snapshot
---
.../viewer/credential/CredentialSnapshot.java | 113 ++++++++++++++++++
1 file changed, 113 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/credential/CredentialSnapshot.java
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();
+ }
+}
From f7335f979eca75b125199760b4f7dc538733b8b0 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 11 Aug 2026 00:22:21 +0900
Subject: [PATCH 05/18] feat(security): add provider-neutral credential
registry port
---
.../viewer/credential/CredentialRegistry.java | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
create mode 100644 src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
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..e01c721d0
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
@@ -0,0 +1,26 @@
+package com.clearfolio.viewer.credential;
+
+/**
+ * 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.
+ */
+@FunctionalInterface
+public interface CredentialRegistry {
+
+ /**
+ * Version of the provider-neutral credential resolution contract.
+ */
+ String CONTRACT_VERSION = "credential-registry-v1";
+
+ /**
+ * Resolves one purpose-scoped credential reference.
+ *
+ * @param reference server-owned logical credential reference
+ * @return versioned credential snapshot whose purpose matches the reference
+ */
+ CredentialSnapshot resolve(CredentialReference reference);
+}
From 2b8893959b188903334a3e4f81a5a52cb31c8049 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 05:33:27 -0700
Subject: [PATCH 06/18] test(security): reject credential purpose mismatch
---
.../CredentialRegistryContractTest.java | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
index d87322fdd..f942dcca0 100644
--- a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
@@ -29,9 +29,28 @@ void registryResolvesPurposeScopedVersionedMaterial() {
assertEquals("tenant-claims-hmac", reference.credentialName());
assertEquals(expected, registry.resolve(reference));
+ assertEquals(expected, registry.resolveScoped(reference));
assertEquals("credential-registry-v1", CredentialRegistry.CONTRACT_VERSION);
}
+ @Test
+ void scopedResolutionRejectsPurposeMismatchWithoutChangingRawAdapterContract() {
+ CredentialReference reference = new CredentialReference(
+ "tenant-claims-hmac",
+ CredentialPurpose.TENANT_CLAIMS_SIGNING
+ );
+ CredentialSnapshot mismatched = new CredentialSnapshot(
+ "artifact-token-hmac",
+ "v2",
+ CredentialPurpose.ARTIFACT_TOKEN_SIGNING,
+ new byte[] {5, 4, 3, 2}
+ );
+ CredentialRegistry registry = ignored -> mismatched;
+
+ assertEquals(mismatched, registry.resolve(reference));
+ assertThrows(IllegalStateException.class, () -> registry.resolveScoped(reference));
+ }
+
@Test
void credentialReferenceFailsClosedForInvalidAuthority() {
assertThrows(IllegalArgumentException.class, () -> new CredentialReference(
From fa8cc34af6346abcbb65262c011e6050bbbae4ab Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 05:34:42 -0700
Subject: [PATCH 07/18] fix(security): enforce credential purpose separation
---
.../viewer/credential/CredentialRegistry.java | 36 +++++++++++++++++--
1 file changed, 34 insertions(+), 2 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
index e01c721d0..d2cfac3a8 100644
--- a/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
+++ b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
@@ -1,5 +1,7 @@
package com.clearfolio.viewer.credential;
+import java.util.Objects;
+
/**
* Provider-neutral boundary for resolving versioned credential material.
*
@@ -7,6 +9,11 @@
* 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 should call {@link #resolveScoped(CredentialReference)}.
+ * The single abstract {@link #resolve(CredentialReference)} method remains the
+ * adapter hook so registry implementations stay lambda-friendly while the
+ * contract boundary independently verifies purpose separation.
*/
@FunctionalInterface
public interface CredentialRegistry {
@@ -17,10 +24,35 @@ public interface CredentialRegistry {
String CONTRACT_VERSION = "credential-registry-v1";
/**
- * Resolves one purpose-scoped credential reference.
+ * Resolves one credential reference through the provider adapter.
+ *
+ * This is the implementation hook. Runtime consumers should use
+ * {@link #resolveScoped(CredentialReference)} so an adapter cannot silently
+ * return material authorized for a different cryptographic purpose.
*
* @param reference server-owned logical credential reference
- * @return versioned credential snapshot whose purpose matches the reference
+ * @return versioned credential snapshot supplied by the adapter
*/
CredentialSnapshot resolve(CredentialReference reference);
+
+ /**
+ * Resolves credential material and fails closed when adapter authority drifts.
+ *
+ * @param reference server-owned logical credential reference
+ * @return versioned credential snapshot whose purpose matches the reference
+ * @throws NullPointerException when the reference is absent
+ * @throws IllegalStateException when the adapter returns no material or returns
+ * material authorized for a different purpose
+ */
+ default CredentialSnapshot resolveScoped(CredentialReference reference) {
+ CredentialReference requiredReference = Objects.requireNonNull(reference, "reference");
+ CredentialSnapshot snapshot = resolve(requiredReference);
+ if (snapshot == null) {
+ throw new IllegalStateException("credential registry returned no material");
+ }
+ if (snapshot.purpose() != requiredReference.purpose()) {
+ throw new IllegalStateException("credential registry returned material for a different purpose");
+ }
+ return snapshot;
+ }
}
From 1fab7df738557d791ac917e9e346a59eb5ddde2f Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Tue, 18 Aug 2026 05:35:18 -0700
Subject: [PATCH 08/18] test(security): cover missing credential material
---
.../credential/CredentialRegistryContractTest.java | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
index f942dcca0..1185c85de 100644
--- a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
@@ -51,6 +51,17 @@ void scopedResolutionRejectsPurposeMismatchWithoutChangingRawAdapterContract() {
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(
From 7c1cf45826eefea70a8db8921fb0e1c41359465e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:01:31 -0700
Subject: [PATCH 09/18] test(security): hide raw credential resolve boundary
---
.../viewer/credential/CredentialRegistryContractTest.java | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
index 1185c85de..0085737a5 100644
--- a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
@@ -33,6 +33,14 @@ void registryResolvesPurposeScopedVersionedMaterial() {
assertEquals("credential-registry-v1", CredentialRegistry.CONTRACT_VERSION);
}
+ @Test
+ void registryDoesNotExposeRawResolveAsConsumerFacingMethod() {
+ assertThrows(
+ NoSuchMethodException.class,
+ () -> CredentialRegistry.class.getDeclaredMethod("resolve", CredentialReference.class)
+ );
+ }
+
@Test
void scopedResolutionRejectsPurposeMismatchWithoutChangingRawAdapterContract() {
CredentialReference reference = new CredentialReference(
From 0f7e7b2556b04ffe148b2e5d3f8c6df661f87aa9 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:01:46 -0700
Subject: [PATCH 10/18] fix(security): separate credential adapter hook
---
.../viewer/credential/CredentialRegistry.java | 21 ++++++++++---------
1 file changed, 11 insertions(+), 10 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
index d2cfac3a8..32d4e7dfb 100644
--- a/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
+++ b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
@@ -10,10 +10,10 @@
* consumers depend only on this contract so environment variables can remain
* bootstrap transport rather than the credential source of truth.
*
- * Runtime consumers should call {@link #resolveScoped(CredentialReference)}.
- * The single abstract {@link #resolve(CredentialReference)} method remains the
- * adapter hook so registry implementations stay lambda-friendly while the
- * contract boundary independently verifies purpose separation.
+ * 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 purpose-enforcing consumer boundary.
*/
@FunctionalInterface
public interface CredentialRegistry {
@@ -24,16 +24,17 @@ public interface CredentialRegistry {
String CONTRACT_VERSION = "credential-registry-v1";
/**
- * Resolves one credential reference through the provider adapter.
+ * Obtains one credential snapshot from a provider adapter.
*
- * This is the implementation hook. Runtime consumers should use
- * {@link #resolveScoped(CredentialReference)} so an adapter cannot silently
- * return material authorized for a different cryptographic purpose.
+ * This method is an adapter implementation hook only. Runtime consumers
+ * must use {@link #resolveScoped(CredentialReference)}, which independently
+ * verifies that returned material is present and authorized for the requested
+ * cryptographic purpose.
*
* @param reference server-owned logical credential reference
* @return versioned credential snapshot supplied by the adapter
*/
- CredentialSnapshot resolve(CredentialReference reference);
+ CredentialSnapshot resolveAdapterMaterial(CredentialReference reference);
/**
* Resolves credential material and fails closed when adapter authority drifts.
@@ -46,7 +47,7 @@ public interface CredentialRegistry {
*/
default CredentialSnapshot resolveScoped(CredentialReference reference) {
CredentialReference requiredReference = Objects.requireNonNull(reference, "reference");
- CredentialSnapshot snapshot = resolve(requiredReference);
+ CredentialSnapshot snapshot = resolveAdapterMaterial(requiredReference);
if (snapshot == null) {
throw new IllegalStateException("credential registry returned no material");
}
From 4747a69bc97612e88acf3dcd7f15cc07939f6856 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:02:24 -0700
Subject: [PATCH 11/18] test(security): exercise explicit credential adapter
hook
---
.../viewer/credential/CredentialRegistryContractTest.java | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
index 0085737a5..a49487fe2 100644
--- a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
@@ -28,7 +28,7 @@ void registryResolvesPurposeScopedVersionedMaterial() {
};
assertEquals("tenant-claims-hmac", reference.credentialName());
- assertEquals(expected, registry.resolve(reference));
+ assertEquals(expected, registry.resolveAdapterMaterial(reference));
assertEquals(expected, registry.resolveScoped(reference));
assertEquals("credential-registry-v1", CredentialRegistry.CONTRACT_VERSION);
}
@@ -42,7 +42,7 @@ void registryDoesNotExposeRawResolveAsConsumerFacingMethod() {
}
@Test
- void scopedResolutionRejectsPurposeMismatchWithoutChangingRawAdapterContract() {
+ void scopedResolutionRejectsPurposeMismatchWhileKeepingAdapterHookExplicit() {
CredentialReference reference = new CredentialReference(
"tenant-claims-hmac",
CredentialPurpose.TENANT_CLAIMS_SIGNING
@@ -55,7 +55,7 @@ void scopedResolutionRejectsPurposeMismatchWithoutChangingRawAdapterContract() {
);
CredentialRegistry registry = ignored -> mismatched;
- assertEquals(mismatched, registry.resolve(reference));
+ assertEquals(mismatched, registry.resolveAdapterMaterial(reference));
assertThrows(IllegalStateException.class, () -> registry.resolveScoped(reference));
}
From 760bc7e7fd32886bc200fcf2c9f4cc6e42e78065 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:05:56 -0700
Subject: [PATCH 12/18] test(security): reject credential identity substitution
---
.../CredentialRegistryContractTest.java | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
index a49487fe2..5fb1098da 100644
--- a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
@@ -59,6 +59,24 @@ void scopedResolutionRejectsPurposeMismatchWhileKeepingAdapterHookExplicit() {
assertThrows(IllegalStateException.class, () -> registry.resolveScoped(reference));
}
+ @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(
From 2f7e9db9b0ffff25511e7e75f932ebf04313ce7d Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:06:23 -0700
Subject: [PATCH 13/18] fix(security): bind credential identity at registry
boundary
---
.../viewer/credential/CredentialRegistry.java | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
index 32d4e7dfb..dd977be68 100644
--- a/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
+++ b/src/main/java/com/clearfolio/viewer/credential/CredentialRegistry.java
@@ -13,7 +13,7 @@
* 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 purpose-enforcing consumer boundary.
+ * surface cannot be mistaken for the authority-enforcing consumer boundary.
*/
@FunctionalInterface
public interface CredentialRegistry {
@@ -28,8 +28,8 @@ public interface CredentialRegistry {
*
* This method is an adapter implementation hook only. Runtime consumers
* must use {@link #resolveScoped(CredentialReference)}, which independently
- * verifies that returned material is present and authorized for the requested
- * cryptographic purpose.
+ * 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
@@ -40,10 +40,10 @@ public interface CredentialRegistry {
* Resolves credential material and fails closed when adapter authority drifts.
*
* @param reference server-owned logical credential reference
- * @return versioned credential snapshot whose purpose matches the 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 authorized for a different purpose
+ * material for a different logical identity or purpose
*/
default CredentialSnapshot resolveScoped(CredentialReference reference) {
CredentialReference requiredReference = Objects.requireNonNull(reference, "reference");
@@ -51,6 +51,9 @@ default CredentialSnapshot resolveScoped(CredentialReference reference) {
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");
}
From fbadf96f154dc58417096874f1f5a9c47cc95e5e Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:08:11 -0700
Subject: [PATCH 14/18] docs(security): ground credential registry in NIST key
management
---
...dential-registry-key-management-grounding.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
create mode 100644 docs/research/2026-08-29-credential-registry-key-management-grounding.md
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..7114a9b55
--- /dev/null
+++ b/docs/research/2026-08-29-credential-registry-key-management-grounding.md
@@ -0,0 +1,17 @@
+# 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.
+
+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.
+
+## 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. https://doi.org/10.6028/NIST.SP.800-57pt1r5
From 0aa56e278a3d978c25f4ac1c313c88fbd514c5dc Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:08:52 -0700
Subject: [PATCH 15/18] docs(architecture): record credential registry boundary
---
ARCHITECTURE.md | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
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
From fc4af3c72894f9e96d86e04aac8db413ded7d9bd Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Fri, 28 Aug 2026 20:09:36 -0700
Subject: [PATCH 16/18] docs(changelog): record fail-closed credential registry
contract
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
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 같은 대체 인코딩으로 위험 선언을 바이트 검사에서 숨기는 우회와 외부 엔터티 읽기·엔터티 확장형 서비스 거부를 회귀 테스트로 차단했습니다.
From 3fa8fe1462ec7dc8b18bbff4bf1ce8752b0bb18b Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sun, 30 Aug 2026 10:10:21 -0700
Subject: [PATCH 17/18] test(security): exercise credential purpose mismatch
guard
---
.../viewer/credential/CredentialRegistryContractTest.java | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
index 5fb1098da..d04c9620d 100644
--- a/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
+++ b/src/test/java/com/clearfolio/viewer/credential/CredentialRegistryContractTest.java
@@ -48,7 +48,7 @@ void scopedResolutionRejectsPurposeMismatchWhileKeepingAdapterHookExplicit() {
CredentialPurpose.TENANT_CLAIMS_SIGNING
);
CredentialSnapshot mismatched = new CredentialSnapshot(
- "artifact-token-hmac",
+ "tenant-claims-hmac",
"v2",
CredentialPurpose.ARTIFACT_TOKEN_SIGNING,
new byte[] {5, 4, 3, 2}
@@ -56,7 +56,11 @@ void scopedResolutionRejectsPurposeMismatchWhileKeepingAdapterHookExplicit() {
CredentialRegistry registry = ignored -> mismatched;
assertEquals(mismatched, registry.resolveAdapterMaterial(reference));
- assertThrows(IllegalStateException.class, () -> registry.resolveScoped(reference));
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> registry.resolveScoped(reference)
+ );
+ assertEquals("credential registry returned material for a different purpose", failure.getMessage());
}
@Test
From 1e354965b03863a492a2055f2643ffcf8fb85b57 Mon Sep 17 00:00:00 2001
From: Seongho Bae
Date: Sat, 5 Sep 2026 17:03:43 +0900
Subject: [PATCH 18/18] docs(research): pin NIST key-management artifact
provenance
---
...-credential-registry-key-management-grounding.md | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
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
index 7114a9b55..b73811203 100644
--- a/docs/research/2026-08-29-credential-registry-key-management-grounding.md
+++ b/docs/research/2026-08-29-credential-registry-key-management-grounding.md
@@ -10,8 +10,19 @@ This keeps provider translation inside the adapter boundary: if a backing KMS, H
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. https://doi.org/10.6028/NIST.SP.800-57pt1r5
+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.