From f52132c8c55e37bbf2e2e4c9c4d92fbb5cf9ab84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:08:45 +0900 Subject: [PATCH 1/4] test(security): require standalone credential registry adapter --- .../DirectoryCredentialRegistryTest.java | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java diff --git a/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java b/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java new file mode 100644 index 000000000..8dd07d422 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java @@ -0,0 +1,191 @@ +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.assertThrows; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DirectoryCredentialRegistryTest { + + private static final CredentialPurpose TENANT_PURPOSE = CredentialPurpose.TENANT_CLAIMS_SIGNING; + + @TempDir + Path tempDirectory; + + @Test + void resolvesActivePurposeScopedCredentialWithoutExposingSourceBytes() throws IOException { + byte[] storedSecret = new byte[] {7, 3, 1, 9}; + writeCredential(tempDirectory, "tenant-claims", TENANT_PURPOSE, "v7", storedSecret); + + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + CredentialSnapshot snapshot = registry.resolve(new CredentialReference("tenant-claims", TENANT_PURPOSE)); + + assertEquals("tenant-claims", snapshot.credentialId()); + assertEquals("v7", snapshot.version()); + assertEquals(TENANT_PURPOSE, snapshot.purpose()); + assertArrayEquals(storedSecret, snapshot.secretBytes()); + + storedSecret[0] = 0; + assertArrayEquals(new byte[] {7, 3, 1, 9}, snapshot.secretBytes()); + } + + @Test + void failsClosedForMissingRegistryOrReference() throws IOException { + Path regularFile = tempDirectory.resolve("not-a-directory"); + Files.writeString(regularFile, "x", StandardCharsets.UTF_8); + + assertThrows(NullPointerException.class, () -> new DirectoryCredentialRegistry(null)); + assertThrows(IllegalArgumentException.class, () -> new DirectoryCredentialRegistry(regularFile)); + + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + assertThrows(NullPointerException.class, () -> registry.resolve(null)); + } + + @Test + void rejectsUnsafeCredentialNamesBeforePathResolution() { + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + + assertThrows(IllegalArgumentException.class, () -> registry.resolve( + new CredentialReference("../escape", TENANT_PURPOSE) + )); + assertThrows(IllegalArgumentException.class, () -> registry.resolve( + new CredentialReference("tenant/claims", TENANT_PURPOSE) + )); + assertThrows(IllegalArgumentException.class, () -> registry.resolve( + new CredentialReference("tenant\\claims", TENANT_PURPOSE) + )); + assertThrows(IllegalArgumentException.class, () -> registry.resolve( + new CredentialReference(" tenant-claims ", TENANT_PURPOSE) + )); + } + + @Test + void rejectsMissingMalformedOrMismatchedPurposeMetadata() throws IOException { + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + Path credentialDirectory = Files.createDirectories(tempDirectory.resolve("tenant-claims")); + + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Files.writeString(credentialDirectory.resolve("purpose"), "NOT_A_PURPOSE", StandardCharsets.UTF_8); + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Files.writeString( + credentialDirectory.resolve("purpose"), + CredentialPurpose.ARTIFACT_TOKEN_SIGNING.name(), + StandardCharsets.UTF_8 + ); + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + } + + @Test + void rejectsMissingUnsafeOrNonCanonicalActiveVersion() throws IOException { + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + Path credentialDirectory = Files.createDirectories(tempDirectory.resolve("tenant-claims")); + Files.writeString(credentialDirectory.resolve("purpose"), TENANT_PURPOSE.name(), StandardCharsets.UTF_8); + + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Files.writeString(credentialDirectory.resolve("active-version"), "../v1", StandardCharsets.UTF_8); + assertThrows(IllegalArgumentException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Files.writeString(credentialDirectory.resolve("active-version"), " v1 ", StandardCharsets.UTF_8); + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + } + + @Test + void rejectsMissingEmptyOrOversizedSecretMaterial() throws IOException { + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + Path credentialDirectory = Files.createDirectories(tempDirectory.resolve("tenant-claims")); + Files.writeString(credentialDirectory.resolve("purpose"), TENANT_PURPOSE.name(), StandardCharsets.UTF_8); + Files.writeString(credentialDirectory.resolve("active-version"), "v1", StandardCharsets.UTF_8); + Path versionsDirectory = Files.createDirectories(credentialDirectory.resolve("versions")); + + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Path secretPath = versionsDirectory.resolve("v1.key"); + Files.write(secretPath, new byte[0]); + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Files.write(secretPath, new byte[(64 * 1024) + 1]); + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + } + + @Test + void rejectsOversizedMetadataAndSymlinkedCredentialDirectory() throws IOException { + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + Path credentialDirectory = Files.createDirectories(tempDirectory.resolve("tenant-claims")); + Files.writeString( + credentialDirectory.resolve("purpose"), + "X".repeat(257), + StandardCharsets.UTF_8 + ); + + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Files.delete(credentialDirectory.resolve("purpose")); + Files.delete(credentialDirectory); + Path outside = Files.createDirectories(tempDirectory.resolve("outside")); + Files.createSymbolicLink(credentialDirectory, outside); + + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + } + + @Test + void rejectsSymlinkedSecretMaterial() throws IOException { + Path credentialDirectory = Files.createDirectories(tempDirectory.resolve("tenant-claims")); + Files.writeString(credentialDirectory.resolve("purpose"), TENANT_PURPOSE.name(), StandardCharsets.UTF_8); + Files.writeString(credentialDirectory.resolve("active-version"), "v1", StandardCharsets.UTF_8); + Path versionsDirectory = Files.createDirectories(credentialDirectory.resolve("versions")); + Path outsideSecret = tempDirectory.resolve("outside-secret"); + Files.write(outsideSecret, new byte[] {1, 2, 3}); + Files.createSymbolicLink(versionsDirectory.resolve("v1.key"), outsideSecret); + + DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + } + + private static void writeCredential( + Path root, + String credentialName, + CredentialPurpose purpose, + String version, + byte[] secret + ) throws IOException { + Path credentialDirectory = Files.createDirectories(root.resolve(credentialName)); + Files.writeString(credentialDirectory.resolve("purpose"), purpose.name(), StandardCharsets.UTF_8); + Files.writeString(credentialDirectory.resolve("active-version"), version, StandardCharsets.UTF_8); + Path versionsDirectory = Files.createDirectories(credentialDirectory.resolve("versions")); + Files.write(versionsDirectory.resolve(version + ".key"), secret); + } +} From b418acfc37b6d921df74420cc8c34092124cc4ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:13:22 +0900 Subject: [PATCH 2/4] test(security): align credential-name normalization contract --- .../viewer/credential/DirectoryCredentialRegistryTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java b/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java index 8dd07d422..cfcec1f7a 100644 --- a/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java +++ b/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java @@ -61,9 +61,6 @@ void rejectsUnsafeCredentialNamesBeforePathResolution() { assertThrows(IllegalArgumentException.class, () -> registry.resolve( new CredentialReference("tenant\\claims", TENANT_PURPOSE) )); - assertThrows(IllegalArgumentException.class, () -> registry.resolve( - new CredentialReference(" tenant-claims ", TENANT_PURPOSE) - )); } @Test From 4a46161a2bab0a090c5af8bd4fec23ee1273e765 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:14:39 +0900 Subject: [PATCH 3/4] feat(security): add standalone directory credential registry --- .../DirectoryCredentialRegistry.java | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/main/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistry.java diff --git a/src/main/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistry.java b/src/main/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistry.java new file mode 100644 index 000000000..1c6a0d35a --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistry.java @@ -0,0 +1,142 @@ +package com.clearfolio.viewer.credential; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Read-only standalone credential registry backed by a bounded directory layout. + * + *

Each credential is stored under a safe logical name with an exact purpose, + * an exact active-version marker, and versioned opaque key bytes. Symbolic links + * are rejected at credential and key-file boundaries so a local registry cannot + * silently escape its configured root. The adapter never logs or renders secret + * bytes.

+ */ +public final class DirectoryCredentialRegistry implements CredentialRegistry { + + private static final int MAX_METADATA_BYTES = 256; + private static final int MAX_SECRET_BYTES = 64 * 1024; + private static final Pattern SAFE_IDENTIFIER = + Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + + private final Path rootDirectory; + + /** + * Creates a local read-only credential registry rooted at one real directory. + * + * @param rootDirectory registry root that must already exist and must not be a symbolic link + * @throws NullPointerException when the root path is absent + * @throws IllegalArgumentException when the root is not a real directory + */ + public DirectoryCredentialRegistry(Path rootDirectory) { + Path normalizedRoot = Objects.requireNonNull(rootDirectory, "rootDirectory") + .toAbsolutePath() + .normalize(); + if (!Files.isDirectory(normalizedRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalArgumentException("credential registry root must be a directory"); + } + this.rootDirectory = normalizedRoot; + } + + /** + * Resolves the currently active version of one purpose-scoped credential. + * + * @param reference server-owned credential reference + * @return immutable snapshot containing a defensive copy of bounded secret bytes + * @throws NullPointerException when the reference is absent + * @throws IllegalArgumentException when a credential or version identifier is unsafe + * @throws IllegalStateException when registry state is missing, malformed, mismatched, or unsafe + */ + @Override + public CredentialSnapshot resolve(CredentialReference reference) { + CredentialReference required = Objects.requireNonNull(reference, "reference"); + String credentialName = requireSafeIdentifier(required.credentialName(), "credentialName"); + Path credentialDirectory = requireDirectory( + rootDirectory.resolve(credentialName).normalize(), + "credential directory unavailable" + ); + + String purposeText = readMetadata( + credentialDirectory.resolve("purpose"), + "credential purpose unavailable" + ); + CredentialPurpose storedPurpose = parsePurpose(purposeText); + if (storedPurpose != required.purpose()) { + throw new IllegalStateException("credential purpose mismatch"); + } + + String versionText = readMetadata( + credentialDirectory.resolve("active-version"), + "credential version unavailable" + ); + String version = requireSafeIdentifier(versionText, "credentialVersion"); + Path versionsDirectory = requireDirectory( + credentialDirectory.resolve("versions"), + "credential versions unavailable" + ); + byte[] secretBytes = readBounded( + versionsDirectory.resolve(version + ".key"), + MAX_SECRET_BYTES, + "credential material unavailable" + ); + if (secretBytes.length == 0) { + throw new IllegalStateException("credential material unavailable"); + } + + return new CredentialSnapshot( + credentialName, + version, + storedPurpose, + secretBytes + ); + } + + private static CredentialPurpose parsePurpose(String purposeText) { + try { + return CredentialPurpose.valueOf(purposeText); + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("credential purpose invalid", exception); + } + } + + private static String readMetadata(Path path, String errorMessage) { + byte[] bytes = readBounded(path, MAX_METADATA_BYTES, errorMessage); + String value = new String(bytes, StandardCharsets.UTF_8); + if (value.isBlank() || !value.equals(value.strip())) { + throw new IllegalStateException(errorMessage); + } + return value; + } + + private static byte[] readBounded(Path path, int maximumBytes, String errorMessage) { + try (InputStream inputStream = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) { + byte[] bytes = inputStream.readNBytes(maximumBytes + 1); + if (bytes.length > maximumBytes) { + throw new IllegalStateException(errorMessage); + } + return bytes; + } catch (IOException exception) { + throw new IllegalStateException(errorMessage, exception); + } + } + + private static Path requireDirectory(Path path, String errorMessage) { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException(errorMessage); + } + return path; + } + + private static String requireSafeIdentifier(String value, String fieldName) { + if (!SAFE_IDENTIFIER.matcher(value).matches()) { + throw new IllegalArgumentException(fieldName + " is not a safe identifier"); + } + return value; + } +} From 7fe3fb8124bded7f8449ac741fd1339192d0bc5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 04:17:17 +0900 Subject: [PATCH 4/4] test(security): cover blank credential metadata branch --- .../credential/DirectoryCredentialRegistryTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java b/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java index cfcec1f7a..a0ca89c60 100644 --- a/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java +++ b/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java @@ -67,18 +67,24 @@ void rejectsUnsafeCredentialNamesBeforePathResolution() { void rejectsMissingMalformedOrMismatchedPurposeMetadata() throws IOException { DirectoryCredentialRegistry registry = new DirectoryCredentialRegistry(tempDirectory); Path credentialDirectory = Files.createDirectories(tempDirectory.resolve("tenant-claims")); + Path purposePath = credentialDirectory.resolve("purpose"); assertThrows(IllegalStateException.class, () -> registry.resolve( new CredentialReference("tenant-claims", TENANT_PURPOSE) )); - Files.writeString(credentialDirectory.resolve("purpose"), "NOT_A_PURPOSE", StandardCharsets.UTF_8); + Files.writeString(purposePath, " ", StandardCharsets.UTF_8); + assertThrows(IllegalStateException.class, () -> registry.resolve( + new CredentialReference("tenant-claims", TENANT_PURPOSE) + )); + + Files.writeString(purposePath, "NOT_A_PURPOSE", StandardCharsets.UTF_8); assertThrows(IllegalStateException.class, () -> registry.resolve( new CredentialReference("tenant-claims", TENANT_PURPOSE) )); Files.writeString( - credentialDirectory.resolve("purpose"), + purposePath, CredentialPurpose.ARTIFACT_TOKEN_SIGNING.name(), StandardCharsets.UTF_8 );