From 2e1dc916a0b4f5392439da147abf190a18cf3e3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:13:34 +0900 Subject: [PATCH 1/6] 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 dfeb00a59..af989ab08 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 f00ba4c62..76868f8b5 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/6] 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 76868f8b5..e5cf00526 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() From ff252341f16611366f5d0935152928f4a4e16bef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:21:26 +0900 Subject: [PATCH 3/6] test(security): cover effective HMAC secret boundaries --- .../ProductionAuthReadinessConfigTest.java | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java index e5cf00526..e81e25290 100644 --- a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java +++ b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.nio.charset.StandardCharsets; + import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -10,6 +12,8 @@ class ProductionAuthReadinessConfigTest { private static final String STABLE_ARTIFACT_TOKEN_SECRET = "clearfolio.artifact-token.secret=stable-artifact-token-secret"; + private static final String MINIMUM_UTF8_TENANT_SECRET = "éééééééé"; + private static final String MINIMUM_UTF8_ARTIFACT_SECRET = "界界界界界a"; @Test void productionProfileFailsWithoutSignedTenantClaimsSecret() { @@ -58,6 +62,26 @@ void productionReadinessRejectsTenantSecretChangedByRuntimeSanitization() { ); } + @Test + void productionReadinessRejectsTenantSecretContainingNul() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + "abcdefghijklmnop\u0000q", + "stable-artifact-token-secret")) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "production profile requires clearfolio.tenant-claims.hmac-secret without NUL or surrounding whitespace" + ); + } + + @Test + void productionReadinessRejectsWhitespaceOnlyTenantSecret() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " ", + "stable-artifact-token-secret")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret"); + } + @Test void productionProfileFailsWithoutStableArtifactTokenSecret() { productionRunner() @@ -66,6 +90,15 @@ void productionProfileFailsWithoutStableArtifactTokenSecret() { .hasRootCauseMessage("production profile requires clearfolio.artifact-token.secret")); } + @Test + void productionReadinessRejectsWhitespaceOnlyArtifactTokenSecret() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + "production-secret", + " ")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.artifact-token.secret"); + } + @Test void productionProfileFailsWithShortArtifactTokenSecret() { productionRunner() @@ -101,19 +134,21 @@ void productionReadinessRejectsPurposeReuseAfterTenantNormalization() { @Test void productionProfileStartsAtMinimumSignedTenantClaimsSecretLength() { + assertThat(MINIMUM_UTF8_TENANT_SECRET.getBytes(StandardCharsets.UTF_8)).hasSize(16); productionRunner() .withPropertyValues( - "clearfolio.tenant-claims.hmac-secret=0123456789abcdef", + "clearfolio.tenant-claims.hmac-secret=" + MINIMUM_UTF8_TENANT_SECRET, STABLE_ARTIFACT_TOKEN_SECRET) .run(context -> assertThat(context.getStartupFailure()).isNull()); } @Test void productionProfileStartsAtMinimumArtifactTokenSecretLength() { + assertThat(MINIMUM_UTF8_ARTIFACT_SECRET.getBytes(StandardCharsets.UTF_8)).hasSize(16); productionRunner() .withPropertyValues( "clearfolio.tenant-claims.hmac-secret=production-secret", - "clearfolio.artifact-token.secret=0123456789abcdef") + "clearfolio.artifact-token.secret=" + MINIMUM_UTF8_ARTIFACT_SECRET) .run(context -> assertThat(context.getStartupFailure()).isNull()); } From ab088a5c6af51c5d7e7ae402184b770c5ae422ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:42:54 +0900 Subject: [PATCH 4/6] test(security): require RFC 7518 HMAC-SHA-256 key size --- .../ProductionAuthReadinessConfigTest.java | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java index e81e25290..18ec734ac 100644 --- a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java +++ b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java @@ -10,10 +10,12 @@ class ProductionAuthReadinessConfigTest { + private static final String TENANT_SECRET_32_BYTES = "t".repeat(32); + private static final String ARTIFACT_SECRET_32_BYTES = "a".repeat(32); private static final String STABLE_ARTIFACT_TOKEN_SECRET = - "clearfolio.artifact-token.secret=stable-artifact-token-secret"; - private static final String MINIMUM_UTF8_TENANT_SECRET = "éééééééé"; - private static final String MINIMUM_UTF8_ARTIFACT_SECRET = "界界界界界a"; + "clearfolio.artifact-token.secret=" + ARTIFACT_SECRET_32_BYTES; + private static final String MINIMUM_UTF8_TENANT_SECRET = "é".repeat(16); + private static final String MINIMUM_UTF8_ARTIFACT_SECRET = "界".repeat(10) + "aa"; @Test void productionProfileFailsWithoutSignedTenantClaimsSecret() { @@ -25,37 +27,37 @@ void productionProfileFailsWithoutSignedTenantClaimsSecret() { void productionReadinessFailsWhenTenantSecretIsExplicitlyNull() { assertThatThrownBy(() -> new ProductionAuthReadinessConfig( null, - "stable-artifact-token-secret")) + ARTIFACT_SECRET_32_BYTES)) .isInstanceOf(IllegalStateException.class) .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret"); } @Test - void productionProfileFailsWithShortSignedTenantClaimsSecret() { + void productionProfileRejectsPreviouslyAcceptedSixteenByteTenantSecret() { productionRunner() .withPropertyValues( - "clearfolio.tenant-claims.hmac-secret=short-hmac-key!", + "clearfolio.tenant-claims.hmac-secret=0123456789abcdef", 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" + "production profile requires clearfolio.tenant-claims.hmac-secret with at least 32 UTF-8 bytes" )); } @Test void productionReadinessMeasuresEffectiveTenantClaimsKeyAfterSanitization() { assertThatThrownBy(() -> new ProductionAuthReadinessConfig( - " a", - "stable-artifact-token-secret")) + " a", + ARTIFACT_SECRET_32_BYTES)) .isInstanceOf(IllegalStateException.class) - .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret with at least 16 UTF-8 bytes"); + .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret with at least 32 UTF-8 bytes"); } @Test void productionReadinessRejectsTenantSecretChangedByRuntimeSanitization() { assertThatThrownBy(() -> new ProductionAuthReadinessConfig( - " strong-tenant-signing-key ", - "stable-artifact-token-secret")) + " " + TENANT_SECRET_32_BYTES + " ", + ARTIFACT_SECRET_32_BYTES)) .isInstanceOf(IllegalStateException.class) .hasMessage( "production profile requires clearfolio.tenant-claims.hmac-secret without NUL or surrounding whitespace" @@ -65,8 +67,8 @@ void productionReadinessRejectsTenantSecretChangedByRuntimeSanitization() { @Test void productionReadinessRejectsTenantSecretContainingNul() { assertThatThrownBy(() -> new ProductionAuthReadinessConfig( - "abcdefghijklmnop\u0000q", - "stable-artifact-token-secret")) + TENANT_SECRET_32_BYTES + "\u0000q", + ARTIFACT_SECRET_32_BYTES)) .isInstanceOf(IllegalStateException.class) .hasMessage( "production profile requires clearfolio.tenant-claims.hmac-secret without NUL or surrounding whitespace" @@ -76,8 +78,8 @@ void productionReadinessRejectsTenantSecretContainingNul() { @Test void productionReadinessRejectsWhitespaceOnlyTenantSecret() { assertThatThrownBy(() -> new ProductionAuthReadinessConfig( - " ", - "stable-artifact-token-secret")) + " ", + ARTIFACT_SECRET_32_BYTES)) .isInstanceOf(IllegalStateException.class) .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret"); } @@ -85,7 +87,7 @@ void productionReadinessRejectsWhitespaceOnlyTenantSecret() { @Test void productionProfileFailsWithoutStableArtifactTokenSecret() { productionRunner() - .withPropertyValues("clearfolio.tenant-claims.hmac-secret=0123456789abcdef") + .withPropertyValues("clearfolio.tenant-claims.hmac-secret=" + TENANT_SECRET_32_BYTES) .run(context -> assertThat(context.getStartupFailure()) .hasRootCauseMessage("production profile requires clearfolio.artifact-token.secret")); } @@ -93,30 +95,31 @@ void productionProfileFailsWithoutStableArtifactTokenSecret() { @Test void productionReadinessRejectsWhitespaceOnlyArtifactTokenSecret() { assertThatThrownBy(() -> new ProductionAuthReadinessConfig( - "production-secret", - " ")) + TENANT_SECRET_32_BYTES, + " ")) .isInstanceOf(IllegalStateException.class) .hasMessage("production profile requires clearfolio.artifact-token.secret"); } @Test - void productionProfileFailsWithShortArtifactTokenSecret() { + void productionProfileRejectsPreviouslyAcceptedSixteenByteArtifactSecret() { productionRunner() .withPropertyValues( - "clearfolio.tenant-claims.hmac-secret=0123456789abcdef", - "clearfolio.artifact-token.secret=short-art-key!") + "clearfolio.tenant-claims.hmac-secret=" + TENANT_SECRET_32_BYTES, + "clearfolio.artifact-token.secret=0123456789abcdef") .run(context -> assertThat(context.getStartupFailure()) .hasRootCauseMessage( - "production profile requires clearfolio.artifact-token.secret with at least 16 UTF-8 bytes" + "production profile requires clearfolio.artifact-token.secret with at least 32 UTF-8 bytes" )); } @Test void productionProfileFailsWhenTenantAndArtifactSigningKeysAreReused() { + String sharedSecret = "s".repeat(32); productionRunner() .withPropertyValues( - "clearfolio.tenant-claims.hmac-secret=shared-signing-secret", - "clearfolio.artifact-token.secret=shared-signing-secret") + "clearfolio.tenant-claims.hmac-secret=" + sharedSecret, + "clearfolio.artifact-token.secret=" + sharedSecret) .run(context -> assertThat(context.getStartupFailure()) .hasRootCauseMessage( "production profile requires distinct tenant-claims and artifact-token HMAC secrets" @@ -125,16 +128,17 @@ void productionProfileFailsWhenTenantAndArtifactSigningKeysAreReused() { @Test void productionReadinessRejectsPurposeReuseAfterTenantNormalization() { + String sharedSecret = "s".repeat(32); assertThatThrownBy(() -> new ProductionAuthReadinessConfig( - " shared-signing-secret ", - "shared-signing-secret")) + " " + sharedSecret + " ", + sharedSecret)) .isInstanceOf(IllegalStateException.class) .hasMessage("production profile requires distinct tenant-claims and artifact-token HMAC secrets"); } @Test void productionProfileStartsAtMinimumSignedTenantClaimsSecretLength() { - assertThat(MINIMUM_UTF8_TENANT_SECRET.getBytes(StandardCharsets.UTF_8)).hasSize(16); + assertThat(MINIMUM_UTF8_TENANT_SECRET.getBytes(StandardCharsets.UTF_8)).hasSize(32); productionRunner() .withPropertyValues( "clearfolio.tenant-claims.hmac-secret=" + MINIMUM_UTF8_TENANT_SECRET, @@ -144,19 +148,19 @@ void productionProfileStartsAtMinimumSignedTenantClaimsSecretLength() { @Test void productionProfileStartsAtMinimumArtifactTokenSecretLength() { - assertThat(MINIMUM_UTF8_ARTIFACT_SECRET.getBytes(StandardCharsets.UTF_8)).hasSize(16); + assertThat(MINIMUM_UTF8_ARTIFACT_SECRET.getBytes(StandardCharsets.UTF_8)).hasSize(32); productionRunner() .withPropertyValues( - "clearfolio.tenant-claims.hmac-secret=production-secret", + "clearfolio.tenant-claims.hmac-secret=" + TENANT_SECRET_32_BYTES, "clearfolio.artifact-token.secret=" + MINIMUM_UTF8_ARTIFACT_SECRET) .run(context -> assertThat(context.getStartupFailure()).isNull()); } @Test - void productionProfileStartsWithSignedTenantClaimsSecret() { + void productionProfileStartsWithDistinctThirtyTwoByteSigningSecrets() { productionRunner() .withPropertyValues( - "clearfolio.tenant-claims.hmac-secret=production-secret", + "clearfolio.tenant-claims.hmac-secret=" + TENANT_SECRET_32_BYTES, STABLE_ARTIFACT_TOKEN_SECRET) .run(context -> assertThat(context.getStartupFailure()).isNull()); } From 93d07e430d3aaf10a6ee97635154259072f94d59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:44:27 +0900 Subject: [PATCH 5/6] fix(security): require 256-bit HMAC-SHA-256 keys --- .../config/ProductionAuthReadinessConfig.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java b/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java index af989ab08..675e1b043 100644 --- a/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java +++ b/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java @@ -16,7 +16,7 @@ @Profile("production") public class ProductionAuthReadinessConfig { - private static final int MINIMUM_HMAC_KEY_BYTES = 16; + private static final int MINIMUM_HMAC_KEY_BYTES = 32; /** * Verifies that production cannot start with unsigned tenant headers, undersized HMAC keys, @@ -30,10 +30,11 @@ public class ProductionAuthReadinessConfig { * 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. + *

RFC 7518 section 3.2 requires a key of the same size as the HMAC-SHA-256 output or larger; + * Clearfolio therefore requires at least 32 effective UTF-8 bytes for both signing purposes. Key + * length alone does not prove entropy, independent generation, approved custody, or rotation; + * production operators remain responsible for generating and protecting both secrets through a + * reviewed credential-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 @@ -61,7 +62,7 @@ public ProductionAuthReadinessConfig( 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" + "production profile requires clearfolio.tenant-claims.hmac-secret with at least 32 UTF-8 bytes" ); } if (!StringUtils.hasText(artifactTokenSecret)) { @@ -72,7 +73,7 @@ public ProductionAuthReadinessConfig( 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" + "production profile requires clearfolio.artifact-token.secret with at least 32 UTF-8 bytes" ); } if (MessageDigest.isEqual(tenantClaimsKey, artifactTokenKey)) { From b422e054a791ed7bb8235cc8a3878c80e8001e6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:45:06 +0900 Subject: [PATCH 6/6] docs(doctoring): record HMAC-SHA-256 key-size authority --- .../production-hmac-key-readiness.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/doctoring/production-hmac-key-readiness.md diff --git a/docs/doctoring/production-hmac-key-readiness.md b/docs/doctoring/production-hmac-key-readiness.md new file mode 100644 index 000000000..d215ded3a --- /dev/null +++ b/docs/doctoring/production-hmac-key-readiness.md @@ -0,0 +1,39 @@ +# Production HMAC-SHA-256 key readiness + +## Decision + +Clearfolio production startup requires each effective tenant-claims and artifact-token HMAC-SHA-256 signing key to contain at least **32 UTF-8 bytes**. The two purposes must use byte-distinct keys. + +The readiness check evaluates the same effective tenant-claims key that the verifier consumes after removing NUL characters and stripping surrounding Unicode whitespace. Production rejects a configured value that would change under that normalization, so operators cannot mistake the literal configured bytes for a different runtime key. + +## Standards basis + +RFC 7518 section 3.2 specifies that an HMAC key used with SHA-256 must be at least the size of the hash output: 256 bits. Because Clearfolio uses `HmacSHA256` for tenant claims and artifact-token signatures, a 16-byte minimum is insufficient for the protocol contract even though it may provide a nominal 128-bit input length. + +NIST SP 800-57 Part 1 Revision 5 provides the general key-management principle that a key should ordinarily be used for one purpose. Clearfolio therefore rejects purpose reuse between tenant-claim and artifact-token signing. Distinct configured values do not by themselves prove independent generation, entropy, KMS custody, rotation, or compromise recovery; those remain part of issue #319's credential-registry architecture. + +NIST SP 800-224 remains an Initial Public Draft at this decision point and is not treated as the sole production authority. FIPS 198-1 remains relevant historical HMAC guidance while NIST progresses its announced migration of HMAC specifications. + +## Executable acceptance + +The exact-head regression requires: + +- former 16-byte tenant and artifact secrets to fail closed; +- effective UTF-8 byte length, rather than character count, to determine acceptance; +- exact 32-byte multibyte and ASCII keys to be accepted; +- null, blank, undersized, NUL-modified, or surrounding-whitespace-modified tenant authority to fail; +- missing, blank, or undersized artifact-token authority to fail; +- byte-identical purpose reuse to fail; +- development fallback behavior to remain outside the production readiness contract. + +## Scope boundary + +This decision is a startup-strength and purpose-separation guard. It does not replace direct configuration injection with a provider-neutral credential registry, implement active/previous rotation windows, prove key entropy, or establish KMS-backed custody. Those controls remain owned by issue #319 and the ordered credential-registry PR stack. + +## References + +Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 + +Jones, M. (2015). *JSON Web Algorithms (JWA)* (RFC 7518). RFC Editor. https://doi.org/10.17487/RFC7518 + +National Institute of Standards and Technology. (2008). *The Keyed-Hash Message Authentication Code (HMAC)* (FIPS PUB 198-1). https://doi.org/10.6028/NIST.FIPS.198-1