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; + } +} 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..a0ca89c60 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/credential/DirectoryCredentialRegistryTest.java @@ -0,0 +1,194 @@ +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) + )); + } + + @Test + 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(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( + purposePath, + 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); + } +}