From 2e1dc916a0b4f5392439da147abf190a18cf3e3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:13:34 +0900 Subject: [PATCH 1/2] fix(security): rebuild production HMAC readiness on protected main --- .../config/ProductionAuthReadinessConfig.java | 76 +++++++++++++- .../ProductionAuthReadinessConfigTest.java | 99 ++++++++++++++++++- 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java b/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java index dfeb00a5..af989ab0 100644 --- a/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java +++ b/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java @@ -1,28 +1,96 @@ package com.clearfolio.viewer.config; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.util.StringUtils; /** - * Fails closed when production profile is started without signed tenant claims. + * Fails closed when production starts without stable, adequately sized, purpose-separated HMAC + * signing material. */ @Configuration @Profile("production") public class ProductionAuthReadinessConfig { + private static final int MINIMUM_HMAC_KEY_BYTES = 16; + /** - * Verifies that production cannot start with unsigned tenant headers. + * Verifies that production cannot start with unsigned tenant headers, undersized HMAC keys, + * an ephemeral artifact-token signing key, ambiguous tenant-key normalization, or one key reused + * across the two signing purposes. + * + *

The tenant-claims runtime removes NUL characters and strips surrounding Unicode whitespace + * before using the configured HMAC secret. Readiness measures that effective key so padding + * cannot inflate the minimum length or hide purpose reuse. Production additionally rejects a + * configured tenant secret that would be changed by that runtime normalization. This prevents an + * operator or gateway from believing the literal configured secret is the signing key while the + * verifier silently uses different bytes. + * + *

The 16-byte floor provides the 128-bit input-length minimum specified for HMAC + * message-authentication keys in NIST SP 800-224's current initial public draft. Key length alone + * does not prove entropy or approved key generation; production operators remain responsible for + * generating and protecting both secrets with an approved secret-management boundary. + * + *

NIST SP 800-57 Part 1 Revision 5 states that, in general, one key is used for one purpose. + * Clearfolio therefore rejects byte-identical tenant-claim and artifact-token signing keys so a + * compromise in one authority does not automatically expose the other authority's signing key. + * Distinct values are still not evidence of independent generation, custody, rotation, or KMS + * provenance; those remain operational controls. + * + *

{@code ArtifactLinkService} intentionally generates a process-local random key when no + * artifact-token secret is configured so development can run without external secret plumbing. + * Production must instead provide stable artifact-token signing material so issued links remain + * verifiable across process restart and replica changes. * * @param tenantClaimsSecret shared gateway signing secret + * @param artifactTokenSecret stable artifact-token signing secret */ public ProductionAuthReadinessConfig( - @Value("${clearfolio.tenant-claims.hmac-secret:}") String tenantClaimsSecret) { - if (!StringUtils.hasText(tenantClaimsSecret)) { + @Value("${clearfolio.tenant-claims.hmac-secret:}") String tenantClaimsSecret, + @Value("${clearfolio.artifact-token.secret:}") String artifactTokenSecret) { + String effectiveTenantClaimsSecret = effectiveTenantClaimsSecret(tenantClaimsSecret); + if (!StringUtils.hasText(effectiveTenantClaimsSecret)) { throw new IllegalStateException( "production profile requires clearfolio.tenant-claims.hmac-secret" ); } + byte[] tenantClaimsKey = effectiveTenantClaimsSecret.getBytes(StandardCharsets.UTF_8); + if (tenantClaimsKey.length < MINIMUM_HMAC_KEY_BYTES) { + throw new IllegalStateException( + "production profile requires clearfolio.tenant-claims.hmac-secret with at least 16 UTF-8 bytes" + ); + } + if (!StringUtils.hasText(artifactTokenSecret)) { + throw new IllegalStateException( + "production profile requires clearfolio.artifact-token.secret" + ); + } + byte[] artifactTokenKey = artifactTokenSecret.getBytes(StandardCharsets.UTF_8); + if (artifactTokenKey.length < MINIMUM_HMAC_KEY_BYTES) { + throw new IllegalStateException( + "production profile requires clearfolio.artifact-token.secret with at least 16 UTF-8 bytes" + ); + } + if (MessageDigest.isEqual(tenantClaimsKey, artifactTokenKey)) { + throw new IllegalStateException( + "production profile requires distinct tenant-claims and artifact-token HMAC secrets" + ); + } + if (!tenantClaimsSecret.equals(effectiveTenantClaimsSecret)) { + throw new IllegalStateException( + "production profile requires clearfolio.tenant-claims.hmac-secret without NUL or surrounding whitespace" + ); + } + } + + private static String effectiveTenantClaimsSecret(String configuredSecret) { + if (configuredSecret == null) { + return null; + } + return configuredSecret.replace("\u0000", "").strip(); } } diff --git a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java index f00ba4c6..76868f8b 100644 --- a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java +++ b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java @@ -1,22 +1,119 @@ package com.clearfolio.viewer.config; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; class ProductionAuthReadinessConfigTest { + private static final String STABLE_ARTIFACT_TOKEN_SECRET = + "clearfolio.artifact-token.secret=stable-artifact-token-secret"; + @Test void productionProfileFailsWithoutSignedTenantClaimsSecret() { productionRunner().run(context -> assertThat(context.getStartupFailure()) .hasRootCauseMessage("production profile requires clearfolio.tenant-claims.hmac-secret")); } + @Test + void productionProfileFailsWithShortSignedTenantClaimsSecret() { + productionRunner() + .withPropertyValues( + "clearfolio.tenant-claims.hmac-secret=short-hmac-key!", + STABLE_ARTIFACT_TOKEN_SECRET) + .run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage( + "production profile requires clearfolio.tenant-claims.hmac-secret with at least 16 UTF-8 bytes" + )); + } + + @Test + void productionReadinessMeasuresEffectiveTenantClaimsKeyAfterSanitization() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " a", + "stable-artifact-token-secret")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret with at least 16 UTF-8 bytes"); + } + + @Test + void productionReadinessRejectsTenantSecretChangedByRuntimeSanitization() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " strong-tenant-signing-key ", + "stable-artifact-token-secret")) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "production profile requires clearfolio.tenant-claims.hmac-secret without NUL or surrounding whitespace" + ); + } + + @Test + void productionProfileFailsWithoutStableArtifactTokenSecret() { + productionRunner() + .withPropertyValues("clearfolio.tenant-claims.hmac-secret=0123456789abcdef") + .run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage("production profile requires clearfolio.artifact-token.secret")); + } + + @Test + void productionProfileFailsWithShortArtifactTokenSecret() { + productionRunner() + .withPropertyValues( + "clearfolio.tenant-claims.hmac-secret=0123456789abcdef", + "clearfolio.artifact-token.secret=short-art-key!") + .run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage( + "production profile requires clearfolio.artifact-token.secret with at least 16 UTF-8 bytes" + )); + } + + @Test + void productionProfileFailsWhenTenantAndArtifactSigningKeysAreReused() { + productionRunner() + .withPropertyValues( + "clearfolio.tenant-claims.hmac-secret=shared-signing-secret", + "clearfolio.artifact-token.secret=shared-signing-secret") + .run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage( + "production profile requires distinct tenant-claims and artifact-token HMAC secrets" + )); + } + + @Test + void productionReadinessRejectsPurposeReuseAfterTenantNormalization() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " shared-signing-secret ", + "shared-signing-secret")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires distinct tenant-claims and artifact-token HMAC secrets"); + } + + @Test + void productionProfileStartsAtMinimumSignedTenantClaimsSecretLength() { + productionRunner() + .withPropertyValues( + "clearfolio.tenant-claims.hmac-secret=0123456789abcdef", + STABLE_ARTIFACT_TOKEN_SECRET) + .run(context -> assertThat(context.getStartupFailure()).isNull()); + } + + @Test + void productionProfileStartsAtMinimumArtifactTokenSecretLength() { + productionRunner() + .withPropertyValues( + "clearfolio.tenant-claims.hmac-secret=production-secret", + "clearfolio.artifact-token.secret=0123456789abcdef") + .run(context -> assertThat(context.getStartupFailure()).isNull()); + } + @Test void productionProfileStartsWithSignedTenantClaimsSecret() { productionRunner() - .withPropertyValues("clearfolio.tenant-claims.hmac-secret=production-secret") + .withPropertyValues( + "clearfolio.tenant-claims.hmac-secret=production-secret", + STABLE_ARTIFACT_TOKEN_SECRET) .run(context -> assertThat(context.getStartupFailure()).isNull()); } From 610f081cc56cfcaa372d674a56f4b21c91123c8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:18:27 +0900 Subject: [PATCH 2/2] test(security): cover null tenant secret normalization --- .../viewer/config/ProductionAuthReadinessConfigTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java index 76868f8b..e5cf0052 100644 --- a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java +++ b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java @@ -17,6 +17,15 @@ void productionProfileFailsWithoutSignedTenantClaimsSecret() { .hasRootCauseMessage("production profile requires clearfolio.tenant-claims.hmac-secret")); } + @Test + void productionReadinessFailsWhenTenantSecretIsExplicitlyNull() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + null, + "stable-artifact-token-secret")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret"); + } + @Test void productionProfileFailsWithShortSignedTenantClaimsSecret() { productionRunner()