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 diff --git a/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java b/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java index dfeb00a59..675e1b043 100644 --- a/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java +++ b/src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java @@ -1,28 +1,97 @@ 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 = 32; + /** - * 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. + * + *
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 + * 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 32 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 32 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..18ec734ac 100644 --- a/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java +++ b/src/test/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfigTest.java @@ -1,12 +1,22 @@ package com.clearfolio.viewer.config; 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; 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=" + 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() { productionRunner().run(context -> assertThat(context.getStartupFailure()) @@ -14,9 +24,144 @@ void productionProfileFailsWithoutSignedTenantClaimsSecret() { } @Test - void productionProfileStartsWithSignedTenantClaimsSecret() { + void productionReadinessFailsWhenTenantSecretIsExplicitlyNull() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + null, + ARTIFACT_SECRET_32_BYTES)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret"); + } + + @Test + void productionProfileRejectsPreviouslyAcceptedSixteenByteTenantSecret() { + productionRunner() + .withPropertyValues( + "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 32 UTF-8 bytes" + )); + } + + @Test + void productionReadinessMeasuresEffectiveTenantClaimsKeyAfterSanitization() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " a", + ARTIFACT_SECRET_32_BYTES)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret with at least 32 UTF-8 bytes"); + } + + @Test + void productionReadinessRejectsTenantSecretChangedByRuntimeSanitization() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " " + 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" + ); + } + + @Test + void productionReadinessRejectsTenantSecretContainingNul() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + 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" + ); + } + + @Test + void productionReadinessRejectsWhitespaceOnlyTenantSecret() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " ", + ARTIFACT_SECRET_32_BYTES)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.tenant-claims.hmac-secret"); + } + + @Test + void productionProfileFailsWithoutStableArtifactTokenSecret() { + productionRunner() + .withPropertyValues("clearfolio.tenant-claims.hmac-secret=" + TENANT_SECRET_32_BYTES) + .run(context -> assertThat(context.getStartupFailure()) + .hasRootCauseMessage("production profile requires clearfolio.artifact-token.secret")); + } + + @Test + void productionReadinessRejectsWhitespaceOnlyArtifactTokenSecret() { + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + TENANT_SECRET_32_BYTES, + " ")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("production profile requires clearfolio.artifact-token.secret"); + } + + @Test + void productionProfileRejectsPreviouslyAcceptedSixteenByteArtifactSecret() { + productionRunner() + .withPropertyValues( + "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 32 UTF-8 bytes" + )); + } + + @Test + void productionProfileFailsWhenTenantAndArtifactSigningKeysAreReused() { + String sharedSecret = "s".repeat(32); + productionRunner() + .withPropertyValues( + "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" + )); + } + + @Test + void productionReadinessRejectsPurposeReuseAfterTenantNormalization() { + String sharedSecret = "s".repeat(32); + assertThatThrownBy(() -> new ProductionAuthReadinessConfig( + " " + 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(32); + productionRunner() + .withPropertyValues( + "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(32); + productionRunner() + .withPropertyValues( + "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 productionProfileStartsWithDistinctThirtyTwoByteSigningSecrets() { productionRunner() - .withPropertyValues("clearfolio.tenant-claims.hmac-secret=production-secret") + .withPropertyValues( + "clearfolio.tenant-claims.hmac-secret=" + TENANT_SECRET_32_BYTES, + STABLE_ARTIFACT_TOKEN_SECRET) .run(context -> assertThat(context.getStartupFailure()).isNull()); }