From 5261356ac34e6545bce947ba0bcf2b1ce9f9be67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 12:59:56 +0900 Subject: [PATCH 01/27] security: rebuild audit pseudonymization on current main --- CHANGELOG.md | 6 +- docs/diagrams/submit-flow.md | 4 +- docs/diagrams/submit-policy-adapter-flow.md | 4 +- ...lio-viewer-unified-document-preview-prd.md | 6 +- .../2026-08-04-audit-pseudonymization.md | 108 +++++++++++ .../viewer/config/ConversionProperties.java | 40 ++++ .../security/AuditKeySeparationGuard.java | 49 +++++ .../viewer/security/AuditPseudonymizer.java | 140 ++++++++++++++ .../DefaultDocumentValidationService.java | 13 +- .../viewer/service/PolicyOverrideRequest.java | 12 +- src/main/resources/application.yml | 9 +- .../security/AuditKeySeparationGuardTest.java | 40 ++++ .../AuditPseudonymizerKeyStrengthTest.java | 41 ++++ .../security/AuditPseudonymizerTest.java | 182 ++++++++++++++++++ ...ultDocumentValidationServiceAuditTest.java | 170 ++++++++++++++++ .../service/PolicyOverrideRequestTest.java | 29 ++- 16 files changed, 831 insertions(+), 22 deletions(-) create mode 100644 docs/security/2026-08-04-audit-pseudonymization.md create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java create mode 100644 src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java create mode 100644 src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java create mode 100644 src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 373a661a..cc5ba27b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Changed - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. +### Security +- 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. +- 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. + # Changelog ## [Unreleased] @@ -50,4 +54,4 @@ - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. ### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 \ No newline at end of file diff --git a/docs/diagrams/submit-flow.md b/docs/diagrams/submit-flow.md index 7b51e3cd..bccde0f3 100644 --- a/docs/diagrams/submit-flow.md +++ b/docs/diagrams/submit-flow.md @@ -64,7 +64,7 @@ sequenceDiagram V->>P: getBlockedExtensions() alt Override headers valid V-->>V: validate override=true + token + approver - V-->>V: emit audit-safe log(extension, approver, tokenFingerprint) + V-->>V: emit audit-safe log(extension, approverFingerprint, tokenFingerprint) V-->>Svc: validation ok else Override missing/invalid V-->>Svc: UnsupportedDocumentFormatException or IllegalArgumentException @@ -94,6 +94,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym. The raw approver identifier is accepted only as validation input and is never emitted by the audit-safe log. + ## Exception paths covered - Missing or empty file diff --git a/docs/diagrams/submit-policy-adapter-flow.md b/docs/diagrams/submit-policy-adapter-flow.md index bc65dfe7..18cca561 100644 --- a/docs/diagrams/submit-policy-adapter-flow.md +++ b/docs/diagrams/submit-policy-adapter-flow.md @@ -29,7 +29,7 @@ sequenceDiagram EH-->>C: 400 UNSUPPORTED_FORMAT else extension blocked and override=true alt token/approver valid - Val-->>Val: audit-safe log(extension, approverId, tokenFingerprint) + Val-->>Val: audit-safe log(extension, approverFingerprint, tokenFingerprint) Val-->>Svc: validation ok Svc->>Repo: findOrStoreByContentHash(job) Svc->>W: enqueue(jobId) when created @@ -60,6 +60,8 @@ sequenceDiagram end ``` +`approverFingerprint` is the versioned, domain-separated keyed audit pseudonym; the raw approver identifier is never written to the audit-safe log. + ## Deterministic adapter baseline - `pdf -> PDF_JS` diff --git a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md index d9fe8f4d..b4b8bc76 100644 --- a/docs/prd/clearfolio-viewer-unified-document-preview-prd.md +++ b/docs/prd/clearfolio-viewer-unified-document-preview-prd.md @@ -1,7 +1,7 @@ # PRD: Clearfolio Viewer Unified Document Preview (Internal) Date: 2026-02-23 -Last updated: 2026-02-23 +Last updated: 2026-08-05 Owner: Product Manager Sources: `docs/architecture.md`, `docs/trd-integrated-document-viewer-platform.md`, `docs/prd-integrated-document-viewer-platform.md`, `docs/engineering/acceptance-criteria.md`, `docs/workflow/one-day-delivery-plan.md`, `docs/diagrams/*`, `AGENTS.md` @@ -196,7 +196,7 @@ Minimum claims/scopes (MVP intent): - preview session creation - viewer access (success/fail) - blocked-format attempts - - exception lane approvals (including approver id, token fingerprint, and rationale id if available) + - exception lane approvals (including `approverFingerprint`, token fingerprint, and rationale id if available); the raw approver identifier is never logged - operator-triggered retries ### 10.4 Browser security headers / CSP @@ -306,4 +306,4 @@ Minimum one-day deliverables: - Risk: Office formats (`docx`/`pptx`/`xlsx`) preview quality depends on converter availability; failures could impact perceived “unified” promise if not clearly messaged. - Risk: Gateway-induced header/proxy limitations can constrain token propagation; mitigation is short-lived viewer session tokens and minimized header set. - Risk: Strict no-warnings/no-deprecations gates can slow dependency upgrades; mitigate with explicit upgrade windows and pre-merge checks. -- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only fingerprint + approver id. +- Risk: Exception lane governance (who can approve, how approvals are issued) can expand scope; mitigate by treating policy token issuance as external and logging only the token fingerprint and `approverFingerprint`; the raw approver identifier remains validation input only and is never logged. diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md new file mode 100644 index 00000000..92b06e93 --- /dev/null +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -0,0 +1,108 @@ +# Audit identifier pseudonymization + +## Decision + +Clearfolio must not write raw approver identifiers or approval tokens to application logs. Policy-override audit events use a domain-separated keyed HMAC for the approver identifier and a non-reversible token fingerprint for the already high-entropy approval signature. Authentication-token handling is outside this policy-override logging contract and remains governed by the repository-wide logging and authorization controls. + +The approver field is named `approverFingerprint`, not `approverId`, so downstream log consumers cannot mistake pseudonymous data for the source identifier. Pseudonymized values remain personal data when they can be related back to a person using separately held information; they are not treated as anonymized data. + +## Cryptographic contract + +### Approver identifier + +The approver fingerprint is calculated as follows: + +```text +HMAC-SHA-256( + dedicated_audit_key, + UTF-8("clearfolio:audit-approver:v1\n" + exact_approver_identifier) +) +``` + +The first 128 bits are encoded as lowercase hexadecimal and prefixed by the non-sensitive key version: + +```text +:<32 lowercase hexadecimal characters> +``` + +The implementation preserves the exact Java string bytes supplied after the policy override has passed its existing identity validation. It does not lowercase, Unicode-normalize, or trim inside the pseudonymizer because those transformations would silently alter identity semantics. Null input produces `absent:`. An empty Java string is not absent: it is processed as a zero-length identifier through the same domain-separated HMAC and produces a normal versioned fingerprint. A missing dedicated key produces `unavailable:` and never falls back to plaintext, the policy-signing secret, or an unkeyed identifier hash. + +A configured audit pseudonym secret must contain at least 32 UTF-8 bytes and must be generated from a cryptographically secure random source. The byte-length gate prevents accidentally deploying a short human-memorable secret whose effective strength would bound the HMAC protection. Blank or absent configuration retains the explicit non-correlatable `unavailable` behavior; a nonblank weak key fails application startup. FIPS 198-1 remains the current final NIST HMAC standard while NIST SP 800-224 remains an initial public draft; NIST expects the final SP to be published concurrently with withdrawal of FIPS 198-1 (National Institute of Standards and Technology, 2008, 2025; Turan & Brandão, 2024). + +Only an absent key-version property defaults to `v1`. Explicit blank, oversized, or unsafe key-version values fail application startup so one version label can never identify multiple key generations accidentally. The accepted format is one to 32 Java UTF-16 code units matching the implementation-equivalent expression `^[\p{L}\p{Nd}._-]{1,32}$`: each character must satisfy Java `Character.isLetterOrDigit` or be `.`, `_`, or `-`. The value is retained as a Java Unicode string and written by the configured log encoding; deployments use UTF-8 log output. Control characters, separators, whitespace, slashes, and other punctuation are rejected. + +### Approval token + +The approval token is a policy-override HMAC signature and is therefore already a high-entropy authentication value. The audit-only token fingerprint is calculated independently as follows: + +```text +SHA-256(UTF-8(exact_approval_token)) +``` + +The first eight digest bytes are encoded as 16 lowercase hexadecimal characters and written as `tokenFingerprint`. The fingerprint is unkeyed and has no domain prefix because it is used only as a short diagnostic correlation value for an already high-entropy signature; it must never be accepted as an authentication credential or used to validate a policy override. Null, empty, and blank approval tokens are rejected by request validation before fingerprinting, so the audit fingerprint function has no absent or empty sentinel contract. + +## Runtime secret loading + +Runtime key material is supplied through Spring Boot's config-tree property source rather than direct secret-bearing environment variables. The default mount is `/run/secrets/clearfolio/`; `CLEARFOLIO_SECRET_CONFIG_DIR` may select another bootstrap directory but must not contain a secret value. + +The secret store or orchestrator mounts files with these exact names: + +```text +conversion.policy-override-secret +conversion.audit-pseudonym-secret +conversion.audit-pseudonym-key-version +``` + +Spring reads each file's contents as the corresponding property. The deployment must restrict file ownership and mode, prevent inclusion in container images and support bundles, and avoid logging the imported values. If the optional config tree is absent, the application retains safe disabled defaults. Production policy must require the needed values before enabling policy override operations. + +## Key ownership and rotation + +- `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. +- The configured value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. +- The application startup guard rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. +- `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. +- Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. +- Retired keys must not remain in application configuration. Any escrow or incident-response copy must be access-controlled, time-bounded, and audited. + +## Retention and access + +Audit log retention must be limited to the shortest period required by the documented security, contractual, and regulatory purpose. Read access is restricted by least privilege. Export, search, re-identification, and deletion workflows must be auditable. Logs and pseudonym keys must never be stored in the same access domain. + +## Incident response + +If the audit pseudonym key is suspected to be exposed: + +1. Rotate the key and version immediately. +2. Preserve affected log ranges under incident hold without broadening access. +3. Determine whether dictionary attacks against likely identifiers were feasible. +4. Treat exposed pseudonymized records as potentially exposed personal data. +5. Follow the applicable breach-assessment and notification process. +6. Verify that no raw identifiers, approval tokens, or key material were written to logs. + +## Verification requirements + +Automated tests must prove: + +- determinism within one key version and domain; +- separation across keys, versions, and domains; +- rejection of configured keys shorter than 32 UTF-8 bytes; +- rejection of invalid explicit key versions; +- startup rejection when policy and audit purposes reuse the same nonblank key; +- distinct absent, empty, and unavailable approver behavior; +- rejection of null, empty, or blank approval tokens before token fingerprinting; +- safe handling of Unicode and control characters; +- no raw approver identifier or approval token in captured policy-override audit output; +- stable failure behavior if the HMAC provider is unavailable; +- 100% JaCoCo line and branch coverage for the `com.clearfolio.viewer.*` production package. + +## References + +European Parliament and Council of the European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. *Official Journal of the European Union, L 119*, 1–88. + +National Institute of Standards and Technology. (2008). *The keyed-hash message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of Commerce. https://doi.org/10.6028/NIST.FIPS.198-1 + +National Institute of Standards and Technology. (2025, June 23). *Proposed withdrawal of FIPS 198-1, HMAC*. Computer Security Resource Center. https://csrc.nist.gov/News/2025/proposed-withdrawal-of-fips-198-1-hmac + +OWASP Foundation. (n.d.). *Logging cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +Turan, M. S., & Brandão, L. T. A. N. (2024). *Keyed-hash message authentication code (HMAC): Specification of HMAC and recommendations for message authentication* (NIST SP 800-224 Initial Public Draft). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-224.ipd diff --git a/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java index a86fc982..feb50622 100644 --- a/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java +++ b/src/main/java/com/clearfolio/viewer/config/ConversionProperties.java @@ -21,6 +21,8 @@ public class ConversionProperties { private double retryBackoffMultiplier = 2.0; private long maxUploadSizeBytes = 5 * 1024 * 1024L; private String policyOverrideSecret = ""; + private String auditPseudonymSecret = ""; + private String auditPseudonymKeyVersion = "v1"; private long processingLeaseTimeoutMs = 60_000L; /** @@ -200,6 +202,44 @@ public void setPolicyOverrideSecret(String policyOverrideSecret) { this.policyOverrideSecret = policyOverrideSecret == null ? "" : policyOverrideSecret; } + /** + * Returns the dedicated secret used to pseudonymize audit identifiers. + * + * @return audit pseudonym secret + */ + public String getAuditPseudonymSecret() { + return auditPseudonymSecret; + } + + /** + * Sets the dedicated secret used to pseudonymize audit identifiers. + * + * @param auditPseudonymSecret audit pseudonym secret + */ + public void setAuditPseudonymSecret(String auditPseudonymSecret) { + this.auditPseudonymSecret = auditPseudonymSecret == null ? "" : auditPseudonymSecret; + } + + /** + * Returns the non-sensitive key version included in audit fingerprints. + * + * @return audit pseudonym key version + */ + public String getAuditPseudonymKeyVersion() { + return auditPseudonymKeyVersion; + } + + /** + * Sets the non-sensitive key version included in audit fingerprints. + * + * @param auditPseudonymKeyVersion audit pseudonym key version + */ + public void setAuditPseudonymKeyVersion(String auditPseudonymKeyVersion) { + this.auditPseudonymKeyVersion = auditPseudonymKeyVersion == null + ? "v1" + : auditPseudonymKeyVersion; + } + /** * Returns the processing lease timeout used by restart recovery. * diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java new file mode 100644 index 00000000..240fae06 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -0,0 +1,49 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +import org.springframework.stereotype.Component; + +import com.clearfolio.viewer.config.ConversionProperties; + +/** + * Fails application startup when policy signing and audit pseudonymization use + * the same configured key material. + * + *

The two HMAC purposes form separate security domains. Reusing one value + * would allow a holder of the audit key to create policy-override signatures, + * so nonblank configured values must remain distinct.

+ */ +@Component +public final class AuditKeySeparationGuard { + + /** + * Validates the bound conversion security configuration during bean startup. + * + * @param properties bound conversion configuration + */ + public AuditKeySeparationGuard(ConversionProperties properties) { + requireDistinct( + properties.getPolicyOverrideSecret(), + properties.getAuditPseudonymSecret() + ); + } + + static void requireDistinct(String policySecret, String auditSecret) { + if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { + return; + } + if (MessageDigest.isEqual( + policySecret.getBytes(StandardCharsets.UTF_8), + auditSecret.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalStateException( + "policy override and audit pseudonym keys must be different" + ); + } + } + + private static boolean isConfigured(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java new file mode 100644 index 00000000..ed3bd31d --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/security/AuditPseudonymizer.java @@ -0,0 +1,140 @@ +package com.clearfolio.viewer.security; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.HexFormat; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Produces privacy-safe, domain-separated audit fingerprints for identifiers. + * + *

The pseudonymizer deliberately uses a dedicated keyed HMAC rather than an + * unkeyed digest. This prevents practical dictionary attacks against common + * low-entropy identifiers such as usernames, employee numbers, and email + * addresses. Fingerprints are stable only within the configured key version + * and domain.

+ */ +public final class AuditPseudonymizer { + + private static final String HMAC_SHA_256 = "HmacSHA256"; + private static final String DEFAULT_KEY_VERSION = "v1"; + private static final String APPROVER_DOMAIN = "clearfolio:audit-approver:v1"; + private static final int FINGERPRINT_BYTES = 16; + private static final int MIN_SECRET_BYTES = 32; + private static final int MAX_KEY_VERSION_LENGTH = 32; + private static final HexFormat HEX_FORMAT = HexFormat.of(); + + private final byte[] secretBytes; + private final String keyVersion; + private final String domain; + + /** + * Creates an approver audit pseudonymizer. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + */ + public AuditPseudonymizer(String secret, String keyVersion) { + this(secret, keyVersion, APPROVER_DOMAIN); + } + + /** + * Creates a pseudonymizer with an explicit domain for isolated internal use + * and domain-separation verification. + * + * @param secret dedicated audit pseudonym secret; when configured, it must + * contain at least 32 UTF-8 bytes; blank disables correlation + * @param keyVersion non-sensitive key-rotation identifier + * @param domain stable protocol-specific domain separator + */ + AuditPseudonymizer(String secret, String keyVersion, String domain) { + this.secretBytes = configuredSecretBytes(secret); + this.keyVersion = normalizeKeyVersion(keyVersion); + this.domain = requireDomain(domain); + } + + /** + * Returns a stable keyed fingerprint without exposing the supplied identifier. + * + *

Null input is represented by a fixed absent marker. If no dedicated key + * is configured, the method emits a fixed non-correlatable unavailable marker + * instead of falling back to plaintext or an unkeyed hash. Empty input remains + * distinct from absent input and is HMACed exactly as supplied.

+ * + * @param identifier exact identifier bytes represented as a Java string + * @return versioned fingerprint or a fixed safe marker + */ + public String fingerprint(String identifier) { + if (identifier == null) { + return "absent:" + keyVersion; + } + if (secretBytes == null) { + return "unavailable:" + keyVersion; + } + + String payload = domain + "\n" + identifier; + try { + Mac mac = Mac.getInstance(HMAC_SHA_256); + mac.init(new SecretKeySpec(secretBytes, HMAC_SHA_256)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return keyVersion + ":" + HEX_FORMAT.formatHex(digest, 0, FINGERPRINT_BYTES); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("audit pseudonym HMAC unavailable", ex); + } + } + + private static byte[] configuredSecretBytes(String secret) { + String configuredSecret = cleanSecret(secret); + if (configuredSecret == null) { + return null; + } + + byte[] bytes = configuredSecret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < MIN_SECRET_BYTES) { + throw new IllegalArgumentException( + "audit pseudonym secret must contain at least 32 UTF-8 bytes" + ); + } + return bytes; + } + + private static String cleanSecret(String secret) { + if (secret == null || secret.isBlank()) { + return null; + } + return secret; + } + + private static String normalizeKeyVersion(String keyVersion) { + if (keyVersion == null) { + return DEFAULT_KEY_VERSION; + } + if (keyVersion.isEmpty()) { + throw new IllegalArgumentException("audit pseudonym key version must not be blank"); + } + if (keyVersion.length() > MAX_KEY_VERSION_LENGTH) { + throw new IllegalArgumentException("audit pseudonym key version is too long"); + } + for (int index = 0; index < keyVersion.length(); index++) { + char character = keyVersion.charAt(index); + boolean safe = Character.isLetterOrDigit(character) + || character == '.' + || character == '_' + || character == '-'; + if (!safe) { + throw new IllegalArgumentException("audit pseudonym key version contains unsafe characters"); + } + } + return keyVersion; + } + + private static String requireDomain(String domain) { + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("audit pseudonym domain is required"); + } + return domain; + } +} diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 95e67228..43884656 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -18,6 +18,7 @@ import com.clearfolio.viewer.config.ConversionProperties; import com.clearfolio.viewer.exception.UnsupportedDocumentFormatException; +import com.clearfolio.viewer.security.AuditPseudonymizer; /** * Default document validator that enforces extension and size constraints. @@ -32,6 +33,7 @@ public class DefaultDocumentValidationService implements DocumentValidationServi private final Set blockedExtensions; private final long maxUploadSizeBytes; private final String policyOverrideSecret; + private final AuditPseudonymizer auditPseudonymizer; /** * Creates the validation service from conversion configuration values. @@ -42,6 +44,10 @@ public DefaultDocumentValidationService(ConversionProperties conversionPropertie this.blockedExtensions = conversionProperties.getBlockedExtensions(); this.maxUploadSizeBytes = conversionProperties.getMaxUploadSizeBytes(); this.policyOverrideSecret = conversionProperties.getPolicyOverrideSecret(); + this.auditPseudonymizer = new AuditPseudonymizer( + conversionProperties.getAuditPseudonymSecret(), + conversionProperties.getAuditPseudonymKeyVersion() + ); } /** @@ -113,9 +119,9 @@ public void validateOrThrow(MultipartFile file, PolicyOverrideRequest overrideRe if (blockedExtension) { LOGGER.info( - "Blocked-format override accepted extension={} approverId={} tokenFingerprint={}", + "Blocked-format override accepted extension={} approverFingerprint={} tokenFingerprint={}", sanitizeForLog(extension), - sanitizeForLog(overrideApproverIdForAudit), + auditPseudonymizer.fingerprint(overrideApproverIdForAudit), tokenFingerprint(overrideTokenForAudit) ); } @@ -202,7 +208,6 @@ private String tokenFingerprint(String approvalToken) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(approvalToken.getBytes(StandardCharsets.UTF_8)); - // Reused HexFormat for performance return HEX_FORMAT.formatHex(hashed, 0, FINGERPRINT_TRUNCATE_BYTES); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("SHA-256 digest unavailable", ex); @@ -213,8 +218,6 @@ private String sanitizeForLog(final String value) { if (value == null) { return ""; } - // ⚡ Bolt: Single-pass string sanitization - // Avoids multiple allocations from chained replace() calls. StringBuilder sb = null; for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); diff --git a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java index 76790b55..0d77ef62 100644 --- a/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java +++ b/src/main/java/com/clearfolio/viewer/service/PolicyOverrideRequest.java @@ -101,12 +101,22 @@ private static String normalizeHeader(final String value) { return sb == null ? value : sb.toString(); } + /** + * Returns a log-safe diagnostic representation. + * + *

The approval token and approver identifier are always redacted, even + * when absent, so callers cannot accidentally disclose either secret value + * or infer whether an approver identifier was supplied from this string.

+ * + * @return diagnostic text containing only the sanitized override flag and + * fixed redaction markers for sensitive headers + */ @Override public String toString() { return "PolicyOverrideRequest{" + "policyOverride='" + normalizeHeader(policyOverride) + '\'' + ", approvalToken='[redacted]'" - + ", approverId='" + normalizeHeader(approverId) + '\'' + + ", approverId='[redacted]'" + '}'; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index df4f72aa..87a025b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,14 @@ spring: application: name: clearfolio-viewer + config: + # Runtime key material is mounted as a Spring Boot config tree rather than + # carried in process environment variables. The environment variable below + # selects only the bootstrap directory. Mount files named + # `conversion.policy-override-secret`, + # `conversion.audit-pseudonym-secret`, and + # `conversion.audit-pseudonym-key-version` in this directory. + import: "optional:configtree:${CLEARFOLIO_SECRET_CONFIG_DIR:/run/secrets/clearfolio/}" codec: max-in-memory-size: ${conversion.max-upload-size-bytes} @@ -9,7 +17,6 @@ conversion: - hwp - hwpx worker-threads: 4 - policy-override-secret: "${CONVERSION_POLICY_OVERRIDE_SECRET:}" queue-capacity: 200 max-retry-attempts: 3 retry-initial-delay-ms: 500 diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java new file mode 100644 index 00000000..9e1c4a6f --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -0,0 +1,40 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.clearfolio.viewer.config.ConversionProperties; + +class AuditKeySeparationGuardTest { + + @Test + void rejectsIdenticalConfiguredKeysDuringStartup() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("shared-key-material"); + properties.setAuditPseudonymSecret("shared-key-material"); + + assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + } + + @Test + void acceptsDistinctConfiguredKeys() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("policy-signing-key"); + properties.setAuditPseudonymSecret("audit-pseudonym-key"); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", null)); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(" ", "audit-key")); + assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct("policy-key", " ")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java new file mode 100644 index 00000000..5200269a --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerKeyStrengthTest.java @@ -0,0 +1,41 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Verifies the minimum configured key-strength contract for audit pseudonyms. + */ +class AuditPseudonymizerKeyStrengthTest { + + @Test + void rejectsConfiguredSecretShorterThanThirtyTwoUtf8Bytes() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcde", "v1") + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + + @Test + void acceptsConfiguredSecretWithThirtyTwoUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("0123456789abcdef0123456789abcdef", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } + + @Test + void measuresConfiguredSecretLengthAsUtf8Bytes() { + AuditPseudonymizer pseudonymizer = assertDoesNotThrow( + () -> new AuditPseudonymizer("가나다라마바사아자차카타파하가나", "v1") + ); + + assertTrue(pseudonymizer.fingerprint("approver").startsWith("v1:")); + } +} diff --git a/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java new file mode 100644 index 00000000..ce9b01b7 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/security/AuditPseudonymizerTest.java @@ -0,0 +1,182 @@ +package com.clearfolio.viewer.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.Provider; +import java.security.Security; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AuditPseudonymizerTest { + + private static final Object SECURITY_PROVIDERS_LOCK = new Object(); + private static final String AUDIT_KEY_ONE = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY_TWO = "fedcba9876543210fedcba9876543210"; + + @Test + void producesDeterministicVersionedFingerprintForExactIdentifierBytes() { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "2026-08"); + + String first = pseudonymizer.fingerprint("Employee-007@example.com"); + String second = pseudonymizer.fingerprint("Employee-007@example.com"); + + assertEquals(first, second); + assertTrue(first.matches("2026-08:[0-9a-f]{32}")); + assertFalse(first.contains("Employee-007")); + } + + @Test + void separatesKeysVersionsAndDomains() { + String identifier = "approver-123"; + String baseline = new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier); + + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_TWO, + "v1", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v2", + "clearfolio:audit-approver:v1" + ).fingerprint(identifier) + ); + assertNotEquals( + baseline, + new AuditPseudonymizer( + AUDIT_KEY_ONE, + "v1", + "clearfolio:audit-subject:v1" + ).fingerprint(identifier) + ); + } + + @Test + void distinguishesAbsentEmptyAndUnavailableValues() { + AuditPseudonymizer configured = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + AuditPseudonymizer unavailableWhitespace = new AuditPseudonymizer(" ", "v1"); + AuditPseudonymizer unavailableNull = new AuditPseudonymizer(null, "v1"); + + assertEquals("absent:v1", configured.fingerprint(null)); + assertTrue(configured.fingerprint("").matches("v1:[0-9a-f]{32}")); + assertNotEquals(configured.fingerprint(null), configured.fingerprint("")); + assertEquals("unavailable:v1", unavailableWhitespace.fingerprint("approver")); + assertEquals("unavailable:v1", unavailableNull.fingerprint("approver")); + assertEquals("absent:v1", unavailableWhitespace.fingerprint(null)); + } + + @Test + void preservesUnicodeAndControlCharactersOnlyInsideTheHmacInput() { + String identifier = "승인자\n\u202E@example.com"; + String fingerprint = new AuditPseudonymizer(AUDIT_KEY_ONE, "unicode-v1") + .fingerprint(identifier); + + assertTrue(fingerprint.matches("unicode-v1:[0-9a-f]{32}")); + assertFalse(fingerprint.contains("승인자")); + assertFalse(fingerprint.contains("example.com")); + assertFalse(fingerprint.contains("\n")); + assertFalse(fingerprint.contains("\u202E")); + } + + @Test + void defaultsOnlyMissingKeyVersionAndRejectsInvalidExplicitValues() { + assertTrue(new AuditPseudonymizer(AUDIT_KEY_ONE, null).fingerprint("id").startsWith("v1:")); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, " v1") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1 ") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "bad/version") + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "x".repeat(33)) + ); + assertTrue( + new AuditPseudonymizer(AUDIT_KEY_ONE, "valid._-9") + .fingerprint("id") + .startsWith("valid._-9:") + ); + } + + @Test + void rejectsMissingDomain() { + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", null) + ); + assertThrows( + IllegalArgumentException.class, + () -> new AuditPseudonymizer(AUDIT_KEY_ONE, "v1", " ") + ); + } + + @Test + void wrapsMissingHmacProviderAsStableInternalFailure() { + synchronized (SECURITY_PROVIDERS_LOCK) { + List removedProviders = hmacProviderPositions(); + for (ProviderPosition providerPosition : removedProviders) { + Security.removeProvider(providerPosition.provider().getName()); + } + try { + AuditPseudonymizer pseudonymizer = new AuditPseudonymizer(AUDIT_KEY_ONE, "v1"); + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> pseudonymizer.fingerprint("approver") + ); + assertEquals("audit pseudonym HMAC unavailable", exception.getMessage()); + } finally { + removedProviders.stream() + .sorted(Comparator.comparingInt(ProviderPosition::position)) + .forEach(providerPosition -> Security.insertProviderAt( + providerPosition.provider(), + providerPosition.position() + )); + } + } + } + + private static List hmacProviderPositions() { + Provider[] providers = Security.getProviders(); + List positions = new ArrayList<>(); + for (int index = 0; index < providers.length; index++) { + Provider provider = providers[index]; + if (provider.getService("Mac", "HmacSHA256") != null) { + positions.add(new ProviderPosition(provider, index + 1)); + } + } + return positions; + } + + private record ProviderPosition(Provider provider, int position) { + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java new file mode 100644 index 00000000..7cafcd86 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceAuditTest.java @@ -0,0 +1,170 @@ +package com.clearfolio.viewer.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import com.clearfolio.viewer.config.ConversionProperties; + +class DefaultDocumentValidationServiceAuditTest { + + private static final String AUDIT_PSEUDONYM_SECRET = + "0123456789abcdef0123456789abcdef"; + + @Test + void acceptedOverrideLogsOnlyPrivacySafeFingerprints() { + String approverId = "employee-007@example.com"; + String policySecret = "policy-signing-secret"; + String approvalToken = generateSignature(approverId, "hwp", policySecret); + ConversionProperties properties = configuredProperties( + policySecret, + AUDIT_PSEUDONYM_SECRET, + "rotation-7" + ); + DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); + CapturingAppender appender = attachAppender(); + + try { + service.validateOrThrow( + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), + PolicyOverrideRequest.of("true", approvalToken, approverId) + ); + } finally { + appender.closeAndDetach(); + } + + String auditLine = appender.singleMessage(); + assertTrue(auditLine.contains("approverFingerprint=rotation-7:")); + assertTrue(auditLine.contains("tokenFingerprint=")); + assertFalse(auditLine.contains(approverId)); + assertFalse(auditLine.contains(approvalToken)); + assertFalse(auditLine.contains("approverId=")); + } + + @Test + void missingDedicatedAuditKeyUsesNonCorrelatableSentinel() { + String approverId = "employee-008"; + String policySecret = "policy-signing-secret"; + String approvalToken = generateSignature(approverId, "hwp", policySecret); + ConversionProperties properties = configuredProperties(policySecret, "", "v9"); + DefaultDocumentValidationService service = new DefaultDocumentValidationService(properties); + CapturingAppender appender = attachAppender(); + + try { + service.validateOrThrow( + new MockMultipartFile( + "file", + "contract.hwp", + "application/octet-stream", + new byte[] {1} + ), + PolicyOverrideRequest.of("true", approvalToken, approverId) + ); + } finally { + appender.closeAndDetach(); + } + + String auditLine = appender.singleMessage(); + assertTrue(auditLine.contains("approverFingerprint=unavailable:v9")); + assertFalse(auditLine.contains(approverId)); + assertFalse(auditLine.contains(approvalToken)); + } + + @Test + void auditConfigurationNullsUseDocumentedSafeDefaults() { + ConversionProperties properties = new ConversionProperties(); + + properties.setAuditPseudonymSecret(null); + properties.setAuditPseudonymKeyVersion(null); + + assertEquals("", properties.getAuditPseudonymSecret()); + assertEquals("v1", properties.getAuditPseudonymKeyVersion()); + } + + private static ConversionProperties configuredProperties( + String policySecret, + String auditSecret, + String keyVersion) { + ConversionProperties properties = new ConversionProperties(); + properties.setBlockedExtensions(Set.of("hwp", "hwpx")); + properties.setPolicyOverrideSecret(policySecret); + properties.setAuditPseudonymSecret(auditSecret); + properties.setAuditPseudonymKeyVersion(keyVersion); + return properties; + } + + private static String generateSignature(String approverId, String extension, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String payload = approverId.length() + ":" + approverId + extension; + return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("test signature generation failed", ex); + } + } + + private static CapturingAppender attachAppender() { + Logger logger = (Logger) LogManager.getLogger(DefaultDocumentValidationService.class); + CapturingAppender appender = new CapturingAppender(logger); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.INFO); + return appender; + } + + private static final class CapturingAppender extends AbstractAppender { + + private final Logger logger; + private final List messages = new ArrayList<>(); + + private CapturingAppender(Logger logger) { + super( + "audit-test-appender", + null, + PatternLayout.newBuilder().withPattern("%m").build(), + false, + null + ); + this.logger = logger; + } + + @Override + public void append(LogEvent event) { + messages.add(event.getMessage().getFormattedMessage()); + } + + private String singleMessage() { + assertEquals(1, messages.size()); + return messages.getFirst(); + } + + private void closeAndDetach() { + logger.removeAppender(this); + stop(); + } + } +} diff --git a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java index 00a34a26..e8e7966b 100644 --- a/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java +++ b/src/test/java/com/clearfolio/viewer/service/PolicyOverrideRequestTest.java @@ -4,8 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -53,30 +53,41 @@ void ofCreatesDistinctInstanceWhenOnlyApproverHeaderIsPresent() { } @Test - void toStringRedactsApprovalToken() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true", "secret-token", "approver-1"); + void toStringRedactsApprovalTokenAndApproverIdentifier() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "true", + "secret-token", + "private-approver@example.com" + ); String rendered = request.toString(); assertTrue(rendered.contains("approvalToken='[redacted]'")); + assertTrue(rendered.contains("approverId='[redacted]'")); assertFalse(rendered.contains("secret-token")); + assertFalse(rendered.contains("private-approver@example.com")); } @Test - void toStringNormalizesControlCharactersInPrintableHeaders() { - PolicyOverrideRequest request = PolicyOverrideRequest.of("true\n", "secret-token", "approver\r\n1\t"); + void toStringNormalizesControlCharactersInPrintableOverrideFlag() { + PolicyOverrideRequest request = PolicyOverrideRequest.of( + "tr\nue\t", + "secret-token", + "sensitive-user\r\n1\t" + ); String rendered = request.toString(); - assertTrue(rendered.contains("policyOverride='true_'")); - assertTrue(rendered.contains("approverId='approver__1_'")); + assertTrue(rendered.contains("policyOverride='tr_ue_'")); + assertTrue(rendered.contains("approverId='[redacted]'")); + assertFalse(rendered.contains("sensitive-user")); } @Test - void toStringHandlesNullPrintableHeaders() { + void toStringHandlesNullPrintableHeaderWithoutRevealingIdentityState() { String rendered = PolicyOverrideRequest.none().toString(); assertTrue(rendered.contains("policyOverride='null'")); - assertTrue(rendered.contains("approverId='null'")); + assertTrue(rendered.contains("approverId='[redacted]'")); } } From 28966e640eeae2119ac6fcee04419219ef33b00c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:05:39 +0900 Subject: [PATCH 02/27] test(security): require strong policy override keys --- .../security/AuditKeySeparationGuardTest.java | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index 9e1c4a6f..ce613765 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -9,11 +10,14 @@ class AuditKeySeparationGuardTest { + private static final String POLICY_KEY = "0123456789abcdef0123456789abcdef"; + private static final String AUDIT_KEY = "fedcba9876543210fedcba9876543210"; + @Test void rejectsIdenticalConfiguredKeysDuringStartup() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("shared-key-material"); - properties.setAuditPseudonymSecret("shared-key-material"); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(POLICY_KEY); assertThrows( IllegalStateException.class, @@ -21,11 +25,32 @@ void rejectsIdenticalConfiguredKeysDuringStartup() { ); } + @Test + void rejectsConfiguredPolicyKeyShorterThanThirtyTwoUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("short-policy-key"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> new AuditKeySeparationGuard(properties) + ); + + assertTrue(exception.getMessage().contains("at least 32 UTF-8 bytes")); + } + @Test void acceptsDistinctConfiguredKeys() { ConversionProperties properties = new ConversionProperties(); - properties.setPolicyOverrideSecret("policy-signing-key"); - properties.setAuditPseudonymSecret("audit-pseudonym-key"); + properties.setPolicyOverrideSecret(POLICY_KEY); + properties.setAuditPseudonymSecret(AUDIT_KEY); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + + @Test + void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { + ConversionProperties properties = new ConversionProperties(); + properties.setPolicyOverrideSecret("가나다라마바사아자차카"); assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } From 2e9c4a2f81584229acbf9dcc9ab3c36930a50092 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:06:04 +0900 Subject: [PATCH 03/27] fix(security): reject weak policy override keys --- .../security/AuditKeySeparationGuard.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java index 240fae06..3b45d45f 100644 --- a/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java +++ b/src/main/java/com/clearfolio/viewer/security/AuditKeySeparationGuard.java @@ -8,28 +8,45 @@ import com.clearfolio.viewer.config.ConversionProperties; /** - * Fails application startup when policy signing and audit pseudonymization use - * the same configured key material. + * Fails application startup when configured policy-signing key material is weak + * or when policy signing and audit pseudonymization reuse the same key. * - *

The two HMAC purposes form separate security domains. Reusing one value + *

The policy-override key protects an administrative authorization decision, + * so a configured value must contain at least 32 UTF-8 bytes. The policy and + * audit HMAC purposes also form separate security domains. Reusing one value * would allow a holder of the audit key to create policy-override signatures, * so nonblank configured values must remain distinct.

*/ @Component public final class AuditKeySeparationGuard { + private static final int MINIMUM_POLICY_SECRET_BYTES = 32; + /** * Validates the bound conversion security configuration during bean startup. * * @param properties bound conversion configuration */ public AuditKeySeparationGuard(ConversionProperties properties) { + requireStrongPolicySecret(properties.getPolicyOverrideSecret()); requireDistinct( properties.getPolicyOverrideSecret(), properties.getAuditPseudonymSecret() ); } + static void requireStrongPolicySecret(String policySecret) { + if (!isConfigured(policySecret)) { + return; + } + if (policySecret.getBytes(StandardCharsets.UTF_8).length + < MINIMUM_POLICY_SECRET_BYTES) { + throw new IllegalStateException( + "policy override key must contain at least 32 UTF-8 bytes" + ); + } + } + static void requireDistinct(String policySecret, String auditSecret) { if (!isConfigured(policySecret) || !isConfigured(auditSecret)) { return; From ac70dd738acea7eeb311916eda1b2fa8f3782c82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 17:07:17 +0900 Subject: [PATCH 04/27] docs(security): document policy override key strength --- docs/security/2026-08-04-audit-pseudonymization.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/security/2026-08-04-audit-pseudonymization.md b/docs/security/2026-08-04-audit-pseudonymization.md index 92b06e93..6f39f223 100644 --- a/docs/security/2026-08-04-audit-pseudonymization.md +++ b/docs/security/2026-08-04-audit-pseudonymization.md @@ -8,6 +8,12 @@ The approver field is named `approverFingerprint`, not `approverId`, so downstre ## Cryptographic contract +### Policy override key + +A configured `conversion.policy-override-secret` authorizes blocked-document policy exceptions and must contain at least 32 UTF-8 bytes. Blank or absent configuration keeps policy override disabled. A nonblank value below the minimum fails application startup before any conversion endpoint can accept traffic. The startup gate measures encoded bytes rather than Java character count, does not log the supplied value, and remains independent of the audit-key separation check. + +Deployments must generate this key from a cryptographically secure random source and must not use a password, person or tenant identifier, repository token, or other human-memorable value. The minimum-length gate prevents a weak configured secret from reducing the effective security of the HMAC approval token even when the HMAC algorithm itself is correctly implemented (National Institute of Standards and Technology, 2008; Turan & Brandão, 2024). + ### Approver identifier The approver fingerprint is calculated as follows: @@ -57,8 +63,9 @@ Spring reads each file's contents as the corresponding property. The deployment ## Key ownership and rotation +- `conversion.policy-override-secret` is an authorization key owned by the security function. It must contain at least 32 UTF-8 bytes, be generated from a cryptographically secure random source, and be rotated through the deployment secret manager. - `conversion.audit-pseudonym-secret` is owned by the security or privacy operations function and must be stored in the deployment secret manager. -- The configured value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. +- The configured audit value must contain at least 32 UTF-8 bytes and should be a uniformly random 256-bit-or-stronger value rather than a password or identifier. - The application startup guard rejects identical nonblank values for `conversion.audit-pseudonym-secret` and `conversion.policy-override-secret`. Deployment policy must additionally keep the audit key operationally separate from tenant-claims signing keys, encryption keys, and API credentials; those keys are owned by their respective subsystems and are not all available to this component's startup guard. - `conversion.audit-pseudonym-key-version` is a non-secret identifier such as `2026-08` but is mounted with the same versioned configuration bundle to keep key and label rotation atomic. - Rotation changes both the secret and version. During an investigation that spans a rotation boundary, operators must treat fingerprints from different versions as intentionally unlinkable unless an approved, separately controlled re-identification process exists. @@ -85,7 +92,8 @@ Automated tests must prove: - determinism within one key version and domain; - separation across keys, versions, and domains; -- rejection of configured keys shorter than 32 UTF-8 bytes; +- startup rejection of configured policy-override and audit keys shorter than 32 UTF-8 bytes; +- acceptance of multibyte policy keys based on encoded byte length rather than character count; - rejection of invalid explicit key versions; - startup rejection when policy and audit purposes reuse the same nonblank key; - distinct absent, empty, and unavailable approver behavior; From 27106a53071465fc3b7439b1bfdf305aa58e373e Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:37:26 +0000 Subject: [PATCH 05/27] fix: trigger CI due to strix timeout --- update_commit.sh | 1 + 1 file changed, 1 insertion(+) create mode 100755 update_commit.sh diff --git a/update_commit.sh b/update_commit.sh new file mode 100755 index 00000000..6e48d953 --- /dev/null +++ b/update_commit.sh @@ -0,0 +1 @@ +git commit --allow-empty -m "trigger CI: Retry due to Strix GitHub Models transient outage" From b3ad882680f81b2bbfeddac892ba71bb45af8ade Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 18:59:42 +0900 Subject: [PATCH 06/27] chore: remove stray CI trigger script --- update_commit.sh | 1 - 1 file changed, 1 deletion(-) delete mode 100755 update_commit.sh diff --git a/update_commit.sh b/update_commit.sh deleted file mode 100755 index 6e48d953..00000000 --- a/update_commit.sh +++ /dev/null @@ -1 +0,0 @@ -git commit --allow-empty -m "trigger CI: Retry due to Strix GitHub Models transient outage" From 0e7770be5de992a7d4e24f4626508682bf9d7232 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:05:47 +0900 Subject: [PATCH 07/27] test: cover disabled policy signing startup path --- .../viewer/security/AuditKeySeparationGuardTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java index ce613765..5d44c4d5 100644 --- a/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java +++ b/src/test/java/com/clearfolio/viewer/security/AuditKeySeparationGuardTest.java @@ -55,6 +55,13 @@ void acceptsConfiguredPolicyKeyMeasuredAsThirtyTwoOrMoreUtf8Bytes() { assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); } + @Test + void permitsMissingPolicyKeyWhenPolicySigningIsDisabled() { + ConversionProperties properties = new ConversionProperties(); + + assertDoesNotThrow(() -> new AuditKeySeparationGuard(properties)); + } + @Test void permitsDisabledSecurityPurposesWithoutComparingMissingValues() { assertDoesNotThrow(() -> AuditKeySeparationGuard.requireDistinct(null, "audit-key")); From a86ab46c3214c803a90f51deec3d4d8723699667 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:06:15 +0900 Subject: [PATCH 08/27] docs: consolidate unreleased changelog entries --- CHANGELOG.md | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5ba27b..a1a683cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,31 +1,34 @@ +# Changelog + ## [Unreleased] + ### Added + - **UI UX 개선**: 'Details' 버튼 클릭 시, 작업 상세 정보 로드 중에 사용자가 명시적인 로딩 상태를 확인할 수 있도록 'Loading...' 텍스트와 비활성화 상태를 표시하도록 추가했습니다. +- **관리자용 단건 작업 삭제 및 재시도 API 추가** + - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. + - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. +- **비동기 버튼 로딩 피드백 및 상태 복원 개선** + - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..."이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. + - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. ### Changed + - PDF.js WebJar를 `6.1.200`으로 올리고, Clearfolio가 동일 버전의 `pdf.mjs`와 `pdf.worker.mjs`를 직접 사용해 서명된 same-origin artifact의 첫 페이지를 렌더링하도록 통합했습니다. 패키징·셸 경로·서명된 `artifactToken` 흐름을 회귀 테스트로 고정했습니다. ### Security + - 정책 재정의 승인자의 원문 식별자를 감사 로그에서 제거하고, 전용 회전형 키와 도메인 분리를 사용하는 HMAC 기반 `approverFingerprint`로 대체했습니다. 전용 키가 없으면 원문이나 비키 해시로 폴백하지 않고 비상관 `unavailable` 표식을 기록합니다. - 감사 가명화 키의 소유권, 회전, 보존, 사고 대응 및 GDPR상 가명정보의 개인정보 지위를 문서화하고, 원문 승인자 식별자와 승인 토큰이 로그에 남지 않는 회귀 테스트를 추가했습니다. -# Changelog - -## [Unreleased] - -### 추가된 기능 (Added) -- **관리자용 단건 작업 삭제 및 재시도 API 추가** - - 특정 변환 작업을 삭제할 수 있는 `DELETE /api/v1/admin/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. - - 실패(dead-lettered) 상태인 작업을 관리자가 재시도 큐에 등록할 수 있는 `POST /api/v1/admin/convert/jobs/{jobId}/retry` 엔드포인트를 추가했습니다. - -- **비동기 버튼 로딩 피드백 및 상태 복원 개선** - - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. - - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. +### Fixed +- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정했습니다. ## [0.1.0] - 2026-06-25 ### 추가된 기능 (Added) + - **비동기 버튼 로딩 상태 UX 개선 (Async Button Loading States)** - 문서 제출(`submitDocument`), 데모 데이터 로드(`loadDemoData`), 실패 작업 재시도(`retryActiveJob`) 등 비동기 요청을 수행하는 버튼들에 대해 처리 중 명시적인 로딩 상태(Loading, Submitting, Retrying 등)를 추가했습니다. - 사용자의 중복 클릭을 방지하기 위해 작업 중에는 버튼이 비활성화되도록 수정했습니다. @@ -41,9 +44,11 @@ - 관련 `AdminJobListResponse` DTO 모델과 이를 처리하는 Repository 및 Service 계층의 `findAll`/`getAllJobs` 메서드를 추가했습니다. ### 테스트 커버리지 (Tests) + - 신규 구현된 Repository, Service, Controller 계층에 대한 유닛 테스트(Unit Tests)를 작성하여 JaCoCo 기준 라인 및 브랜치 커버리지 100%를 달성했습니다. ### 보안 (Security) + - **의존성 취약점 일괄 정리 (trivy-fs / osv-scan 대응)**: Spring Boot 부모 POM을 `3.5.0`에서 `3.5.16`으로 올려 Spring Framework, Netty, Reactor Netty, logback 관련 다수의 HIGH/MEDIUM 권고를 해소했습니다. - Jackson 계열을 `jackson-bom` import로 `2.22.1`에 고정하여 jackson-databind case-insensitive deserialization bypass 권고(GHSA-5jmj-h7xm-6q6v / CVE-2026-54515)를 제거했습니다. - Apache Tika 표준 파서를 통해 유입되던 전이 의존성을 `dependencyManagement`로 고정했습니다: junrar `7.6.0`(경로 순회 RCE/파일 쓰기), commons-io `2.20.0`(XmlStreamReader DoS), commons-lang3 `3.18.0`, BouncyCastle `bcprov-jdk18on 1.84` 및 `bcpkix-jdk18on 1.84`(CRITICAL/Medium). 전체 347개 테스트 통과를 확인했습니다. @@ -52,6 +57,3 @@ - 루트 `LICENSE`와 Maven license metadata를 추가해 Scorecard License alert가 표준 Apache-2.0 파일을 확인할 수 있게 했습니다. - logback-core 신규 권고(GHSA-jhq6-gfmj-v8fx) 대응을 위해 Logback 관리 버전을 `1.5.35`로 고정했습니다. - 저장소 보안 정책, Maven/GitHub Actions Dependabot 설정, 기본 CodeQL/중앙 SAST 운영 지침, 다운로드 파일명 정규화 Jazzer fuzz target을 추가해 Scorecard 보안 거버넌스 신호를 보강했습니다. - -### Fixed -- 뷰어 UI의 재시도 버튼 로딩 상태가 내부 DOM을 손상시키지 않고 안전하게 복원되도록 수정 \ No newline at end of file From eb343235981880e994e2e91a841cdf67e428e970 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 19:11:12 +0900 Subject: [PATCH 09/27] build: enforce zero missed production lines and branches --- pom.xml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pom.xml b/pom.xml index 51e22d13..0710a5a0 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ ${java.version} UTF-8 + 0.8.15 3.0.8 6.1.200