Skip to content
39 changes: 39 additions & 0 deletions docs/doctoring/production-hmac-key-readiness.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>{@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) {
Comment on lines 53 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

비밀 조회를 Spring 환경 placeholder에서 분리하세요.

Line 53과 Line 54는 HMAC 비밀을 @Value Spring 환경 placeholder에서 읽습니다. 이는 production 비밀의 허용된 소스가 아닙니다. ProductionAuthReadinessConfig, TenantAccessService, ArtifactLinkService가 동일한 KV 또는 credential registry에서 해석한 비밀을 사용하도록 마이그레이션하세요. 환경 변수는 해당 저장소의 bootstrap에만 사용하세요.

As per coding guidelines, 새 비밀과 기존 artifact-token 및 tenant-claims 비밀은 Spring 환경 placeholder 대신 KV 또는 credential registry에서 읽어야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/clearfolio/viewer/config/ProductionAuthReadinessConfig.java`
around lines 52 - 54, Update ProductionAuthReadinessConfig, TenantAccessService,
and ArtifactLinkService to resolve tenant-claims and artifact-token secrets
through the same KV or credential registry used by the application, removing
their Spring `@Value` environment-placeholder injection. Keep environment
variables limited to bootstrapping that shared secret store, and ensure all
three components consume the resolved values consistently.

Source: Coding guidelines

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();
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,167 @@
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())
.hasRootCauseMessage("production profile requires clearfolio.tenant-claims.hmac-secret"));
}

@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"
));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@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());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@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());
}

Expand Down
Loading