diff --git a/.env.example b/.env.example index 0bae487e..8485d41b 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,17 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요. # REFRESH_TOKEN_COOKIE_SECURE=true +# 계정 연락처 암호화입니다. 실제 키는 Git이 아닌 배포 Secret으로만 주입합니다. +# PII_ENCRYPTION_ENABLED=true +# PII_ENCRYPTION_KEY_BASE64= +PII_ENCRYPTION_KEY_VERSION=local-pii-v1 +# 키 교체 중에만 이전 키를 version=base64 형식으로 추가합니다. +# PII_DECRYPTION_KEYS=local-pii-v0=... +# 아래 명령은 one-off 유지보수 프로세스 전용입니다. 일반 Server는 항상 none입니다. +# migrate | verify | restore-plaintext +PII_MAINTENANCE_COMMAND=none +PII_MAINTENANCE_BATCH_SIZE=100 + # 비밀번호 재설정 token은 알림 Provider에만 원문을 전달하고 DB에는 SHA-256 hash만 저장합니다. PASSWORD_RESET_TTL=30m PASSWORD_RESET_COOLDOWN=1m diff --git a/compose.demo.yml b/compose.demo.yml index afb2a262..5767e555 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -65,6 +65,12 @@ services: DOCUMENT_OCR_ENABLED: ${DOCUMENT_OCR_ENABLED:-false} OCR_RESULT_ENCRYPTION_KEY_BASE64: ${OCR_RESULT_ENCRYPTION_KEY_BASE64:-} OCR_RESULT_KEY_VERSION: ${OCR_RESULT_KEY_VERSION:-demo-v1} + PII_ENCRYPTION_ENABLED: ${PII_ENCRYPTION_ENABLED:-false} + PII_ENCRYPTION_KEY_BASE64: ${PII_ENCRYPTION_KEY_BASE64:-} + PII_ENCRYPTION_KEY_VERSION: ${PII_ENCRYPTION_KEY_VERSION:-demo-v1} + PII_DECRYPTION_KEYS: ${PII_DECRYPTION_KEYS:-} + PII_MAINTENANCE_COMMAND: ${PII_MAINTENANCE_COMMAND:-none} + PII_MAINTENANCE_BATCH_SIZE: ${PII_MAINTENANCE_BATCH_SIZE:-100} FILE_STORAGE_LOCAL_PATH: /app/data/files DEMO_SEED_ENABLED: ${DEMO_SEED_ENABLED:-false} DEMO_SEED_ADMIN_PASSWORD: ${DEMO_SEED_ADMIN_PASSWORD:-} diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 460781c5..c899afeb 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -39,6 +39,12 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre | DB | `DB_RUNTIME_USERNAME`, `DB_RUNTIME_PASSWORD` | 애플리케이션 실행 계정 | | DB | `DB_MIGRATION_USERNAME`, `DB_MIGRATION_PASSWORD` | Flyway 전용 계정 | | Auth | `JWT_SECRET_BASE64`, `JWT_ISSUER`, `JWT_AUDIENCE` | Access Token 발급·검증 | +| PII | `PII_ENCRYPTION_ENABLED=true` | 계정 연락처 AES-256-GCM 암호화 활성화 | +| PII | `PII_ENCRYPTION_KEY_BASE64` | 현재 32바이트 연락처 암호화 키의 Base64 | +| PII | `PII_ENCRYPTION_KEY_VERSION` | 현재 키 식별 version, 예: `pii-2026-08-v1` | +| PII | `PII_DECRYPTION_KEYS` | 회전 이전 키 목록, `version=base64`를 쉼표로 구분 | +| PII | `PII_MAINTENANCE_COMMAND=none` | 일반 Server의 고정값. 일회성 작업에서만 변경 | +| PII | `PII_MAINTENANCE_BATCH_SIZE=100` | 연락처 전환 작업의 transaction당 처리 건수 | | Web | `CORS_ALLOWED_ORIGINS` | 실제 Client HTTPS origin만 허용 | | Catalog | `WORKFLOW_CATALOG_LOCATION` | 검증된 `RELEASED` projection 위치 | | AI | `AI_RUNTIME_ENABLED=true` | 실제 Runtime 연동 활성화 | @@ -53,6 +59,79 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre | OCR | `OCR_RESULT_ENCRYPTION_KEY_BASE64` | 32바이트 OCR 결과 암호화 키의 Base64 | | OCR | `OCR_RESULT_KEY_VERSION` | 암호화 키 식별 version | +`PII_ENCRYPTION_KEY_BASE64`와 `PII_DECRYPTION_KEYS`는 Git, DB, 이미지, Issue, 로그에 +기록하지 않고 `server-env` Secret으로 주입합니다. 운영에서는 `prod` profile이 PII 암호화를 +기본 활성화하므로 현재 키가 없으면 Server가 기동하지 않습니다. 현재 Infra는 Kubernetes +Secret 주입까지 지원하며 AWS KMS·Secrets Manager 자동 동기화는 별도 고도화 범위입니다. + +로그인·프로필 수정 시 평문 또는 이전 키 암호문을 현재 키로 다시 암호화하는 방어 로직이 +있지만, 계정 접근 빈도에 의존하는 점진 전환을 배포 완료 기준으로 사용하지 않습니다. 초기 +전환과 키 회전은 아래 일회성 유지보수 명령으로 모든 행을 명시적으로 처리합니다. + +| 명령 | 목적 | 완료 조건 | +| --- | --- | --- | +| `migrate` | 평문과 이전 키 암호문을 현재 키로 전환 | 처리 후 오류 없이 종료 | +| `verify` | 전환 완료 여부 검사 | 평문 0건, 이전 키 0건 | +| `restore-plaintext` | 구버전 애플리케이션 롤백 전 평문 복원 | 암호문 0건 | + +유지보수 명령은 PostgreSQL RLS를 우회해야 하므로 일반 Runtime 계정으로 실행되지 않습니다. +`user_account` 소유자, `BYPASSRLS` 또는 Superuser 권한을 가진 **Flyway 전용 계정**을 일회성 +프로세스에만 주입합니다. 정상 Deployment의 `PII_MAINTENANCE_COMMAND`는 항상 `none`입니다. + +### 최초 암호화 전환 + +1. DB 백업과 복구 절차를 확인합니다. +2. 새 컬럼과 암호화 코드를 먼저 배포하고 현재 키를 Secret으로 주입합니다. +3. 쓰기 트래픽을 통제한 뒤 일회성 프로세스에서 `migrate`를 실행합니다. +4. 같은 키로 `verify`를 실행해 평문과 이전 키 잔여 건수가 0인지 확인합니다. +5. 정상 Server의 로그인·프로필 조회 Smoke를 수행합니다. + +```bash +export SPRING_MAIN_WEB_APPLICATION_TYPE=none +export PII_MAINTENANCE_COMMAND=migrate +export PII_MAINTENANCE_BATCH_SIZE=100 +export DB_RUNTIME_USERNAME="$DB_MIGRATION_USERNAME" +export DB_RUNTIME_PASSWORD="$DB_MIGRATION_PASSWORD" +java -jar server.jar + +export PII_MAINTENANCE_COMMAND=verify +java -jar server.jar +``` + +Kubernetes에서는 동일 환경변수를 가진 일회성 Job으로 실행합니다. 일반 Deployment의 Secret을 +`migrate`로 바꾸지 않으며, 로그에는 원문·암호문 대신 처리 건수와 키 version만 남습니다. + +### 키 회전 + +1. 새 키와 새 version을 현재 값으로 설정합니다. +2. 직전 키를 `PII_DECRYPTION_KEYS=old-version=old-base64`에 유지합니다. +3. 새 설정을 배포한 뒤 `migrate`, `verify`를 순서대로 실행합니다. +4. DB와 애플리케이션 Smoke를 확인한 뒤 이전 version 잔여 건수가 0일 때만 이전 키를 제거합니다. + +### 구버전 애플리케이션 롤백 + +암호화 도입 이전 버전은 `phone_ciphertext`를 읽지 못하므로 이미지를 먼저 되돌리면 연락처가 +빈 값으로 보입니다. 반드시 모든 복호화 키를 유지한 상태에서 쓰기 트래픽을 통제하고 +`restore-plaintext`를 먼저 실행합니다. 아래 조회에서 암호문 0건을 확인한 다음에만 구버전 +이미지를 배포합니다. 장애 수정 후에는 다시 `migrate`, `verify`를 수행하는 전진 복구를 +우선합니다. + +```sql +SELECT COUNT(*) AS legacy_plaintext_phone_count +FROM user_account +WHERE phone IS NOT NULL; + +SELECT phone_key_version, COUNT(*) AS encrypted_phone_count +FROM user_account +WHERE phone_ciphertext IS NOT NULL +GROUP BY phone_key_version +ORDER BY phone_key_version; + +SELECT COUNT(*) AS remaining_encrypted_phone_count +FROM user_account +WHERE phone_ciphertext IS NOT NULL; +``` + 비밀번호 재설정 메일을 실제로 발송할 때만 다음 값을 `server-env`에 추가합니다. 기본 `PASSWORD_RESET_NOTIFICATION_PROVIDER=none`에서는 메일을 발송하지 않습니다. @@ -116,6 +195,9 @@ Swagger를 읽기 전용으로 유지합니다. HTTP 주소를 임시로 넣어 ```bash export DEMO_DB_PASSWORD='local-demo-password' export JWT_SECRET_BASE64="$(openssl rand -base64 32)" +export PII_ENCRYPTION_ENABLED=true +export PII_ENCRYPTION_KEY_BASE64="$(openssl rand -base64 32)" +export PII_ENCRYPTION_KEY_VERSION='local-pii-v1' export DEMO_SEED_ENABLED=true export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값' docker compose -f compose.demo.yml up --build diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java new file mode 100644 index 00000000..7bc89092 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java @@ -0,0 +1,27 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.util.UUID; + +public interface AccountPiiCipher { + + boolean isAvailable(); + + String currentKeyVersion(); + + default boolean requiresReEncryption(String keyVersion) { + return isAvailable() && !currentKeyVersion().equals(keyVersion); + } + + EncryptedValue encrypt(String plaintext, UUID companyId, UUID userId, String fieldName); + + String decrypt( + String ciphertext, + String keyVersion, + UUID companyId, + UUID userId, + String fieldName + ); + + record EncryptedValue(String ciphertext, String keyVersion) { + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCryptoConfiguration.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCryptoConfiguration.java new file mode 100644 index 00000000..330b9f6a --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCryptoConfiguration.java @@ -0,0 +1,65 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(AccountPiiProperties.class) +public class AccountPiiCryptoConfiguration { + + @Bean + public AccountPiiCipher accountPiiCipher(AccountPiiProperties properties) { + if (!properties.isEnabled()) { + return new DisabledAccountPiiCipher(); + } + properties.validateEnabledConfiguration(); + Map keys = parsePreviousKeys(properties.getDecryptionKeys()); + String currentVersion = properties.getCurrentKeyVersion().strip(); + byte[] currentKey = decodeKey(properties.getCurrentKeyBase64(), "PII_ENCRYPTION_KEY_BASE64"); + byte[] conflictingKey = keys.put(currentVersion, currentKey); + if (conflictingKey != null && !Arrays.equals(conflictingKey, currentKey)) { + throw new IllegalStateException("current PII key version conflicts with PII_DECRYPTION_KEYS"); + } + return new AesGcmAccountPiiCipher(keys, currentVersion, new SecureRandom()); + } + + private Map parsePreviousKeys(String configuredKeys) { + Map keys = new LinkedHashMap<>(); + if (configuredKeys == null || configuredKeys.isBlank()) { + return keys; + } + for (String entry : configuredKeys.split(",")) { + String[] pair = entry.strip().split("=", 2); + if (pair.length != 2) { + throw new IllegalStateException( + "PII_DECRYPTION_KEYS must use version=base64 entries separated by commas" + ); + } + String version = pair[0].strip(); + AccountPiiProperties.validateKeyVersion(version, "PII_DECRYPTION_KEYS version"); + byte[] previous = keys.put(version, decodeKey(pair[1], "PII_DECRYPTION_KEYS")); + if (previous != null) { + throw new IllegalStateException("PII_DECRYPTION_KEYS contains a duplicate version"); + } + } + return keys; + } + + private byte[] decodeKey(String value, String fieldName) { + try { + byte[] decoded = Base64.getDecoder().decode(value.strip()); + if (decoded.length != 32) { + throw new IllegalStateException(fieldName + " must decode to 32 bytes"); + } + return decoded; + } catch (IllegalArgumentException exception) { + throw new IllegalStateException(fieldName + " is not valid Base64", exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceCommandRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceCommandRunner.java new file mode 100644 index 00000000..778d0fb7 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceCommandRunner.java @@ -0,0 +1,113 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.EncryptionInventory; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.MaintenanceResult; +import java.util.Locale; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +final class AccountPiiMaintenanceCommandRunner implements ApplicationRunner { + + private static final Logger LOGGER = LoggerFactory.getLogger( + AccountPiiMaintenanceCommandRunner.class + ); + + private final AccountPiiMaintenanceService service; + private final ConfigurableApplicationContext applicationContext; + private final String command; + private final int batchSize; + + AccountPiiMaintenanceCommandRunner( + AccountPiiMaintenanceService service, + ConfigurableApplicationContext applicationContext, + @Value("${app.auth.pii.maintenance-command:none}") String command, + @Value("${app.auth.pii.maintenance-batch-size:100}") int batchSize + ) { + this.service = Objects.requireNonNull(service, "service must not be null"); + this.applicationContext = Objects.requireNonNull( + applicationContext, + "applicationContext must not be null" + ); + this.command = Objects.requireNonNull(command, "command must not be null"); + this.batchSize = batchSize; + } + + @Override + public void run(ApplicationArguments arguments) { + Command parsed = Command.parse(command); + if (parsed == Command.NONE) { + return; + } + switch (parsed) { + case MIGRATE -> logResult(parsed, service.migrateToCurrentKey(batchSize)); + case VERIFY -> logInventory(parsed, service.verifyCurrentKey(), 0); + case RESTORE_PLAINTEXT -> logResult(parsed, service.restorePlaintext(batchSize)); + case NONE -> throw new IllegalStateException("unreachable account PII command"); + } + applicationContext.close(); + } + + private void logResult(Command command, MaintenanceResult result) { + logInventory(command, result.inventory(), result.processedCount()); + } + + private void logInventory( + Command command, + EncryptionInventory inventory, + int processedCount + ) { + LOGGER.info( + "account_pii_maintenance command={} processed_count={} account_count={} " + + "plaintext_count={} encrypted_count={} current_key_count={} " + + "stale_key_count={} current_key_version={}", + command.externalName(), + processedCount, + inventory.accountCount(), + inventory.plaintextCount(), + inventory.encryptedCount(), + inventory.currentKeyCount(), + inventory.staleKeyCount(), + inventory.currentKeyVersion() + ); + } + + private enum Command { + NONE("none"), + MIGRATE("migrate"), + VERIFY("verify"), + RESTORE_PLAINTEXT("restore-plaintext"); + + private final String externalName; + + Command(String externalName) { + this.externalName = externalName; + } + + String externalName() { + return externalName; + } + + static Command parse(String value) { + String normalized = value.strip().toLowerCase(Locale.ROOT); + for (Command command : values()) { + if (command.externalName.equals(normalized)) { + return command; + } + } + throw new IllegalStateException( + "app.auth.pii.maintenance-command must be one of " + + "none, migrate, verify, restore-plaintext" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceService.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceService.java new file mode 100644 index 00000000..d2a13c7a --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceService.java @@ -0,0 +1,342 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@Component +final class AccountPiiMaintenanceService { + + private static final String PHONE_FIELD = "phone"; + + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + private final AccountPiiCipher piiCipher; + + AccountPiiMaintenanceService( + JdbcTemplate jdbcTemplate, + PlatformTransactionManager transactionManager, + AccountPiiCipher piiCipher + ) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + this.transactionTemplate = new TransactionTemplate(Objects.requireNonNull( + transactionManager, + "transactionManager must not be null" + )); + this.piiCipher = Objects.requireNonNull(piiCipher, "piiCipher must not be null"); + } + + MaintenanceResult migrateToCurrentKey(int batchSize) { + requireReady(batchSize); + requireMaintenanceDatabaseRole(); + int migrated = processBatches(batchSize, this::migrateBatch); + EncryptionInventory inventory = inspect(); + if (inventory.plaintextCount() != 0 || inventory.staleKeyCount() != 0) { + throw new IllegalStateException( + "account PII migration finished with plaintext or stale-key rows remaining" + ); + } + return new MaintenanceResult(migrated, inventory); + } + + MaintenanceResult restorePlaintext(int batchSize) { + requireReady(batchSize); + requireMaintenanceDatabaseRole(); + int restored = processBatches(batchSize, this::restoreBatch); + EncryptionInventory inventory = inspect(); + if (inventory.encryptedCount() != 0) { + throw new IllegalStateException( + "account PII plaintext restore finished with encrypted rows remaining" + ); + } + return new MaintenanceResult(restored, inventory); + } + + EncryptionInventory verifyCurrentKey() { + requireReady(1); + requireMaintenanceDatabaseRole(); + EncryptionInventory inventory = inspect(); + if (inventory.plaintextCount() != 0 || inventory.staleKeyCount() != 0) { + throw new IllegalStateException( + "account PII verification failed because plaintext or stale-key rows remain" + ); + } + return inventory; + } + + private int processBatches(int batchSize, BatchOperation operation) { + int processed = 0; + while (true) { + Integer batchProcessed = transactionTemplate.execute(status -> operation.run(batchSize)); + int changed = batchProcessed == null ? 0 : batchProcessed; + if (changed == 0) { + return processed; + } + processed += changed; + } + } + + private int migrateBatch(int batchSize) { + List rows = jdbcTemplate.query( + """ + SELECT user_id, company_id, phone, phone_ciphertext, phone_key_version + FROM user_account + WHERE phone IS NOT NULL + OR (phone_ciphertext IS NOT NULL AND phone_key_version <> ?) + ORDER BY user_id + LIMIT ? + """, + (resultSet, rowNumber) -> new PhoneRow( + resultSet.getObject("user_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("phone"), + resultSet.getString("phone_ciphertext"), + resultSet.getString("phone_key_version") + ), + piiCipher.currentKeyVersion(), + batchSize + ); + int updated = 0; + for (PhoneRow row : rows) { + String plaintext = row.phone() != null + ? row.phone() + : piiCipher.decrypt( + row.phoneCiphertext(), + row.phoneKeyVersion(), + row.companyId(), + row.userId(), + PHONE_FIELD + ); + AccountPiiCipher.EncryptedValue encrypted = piiCipher.encrypt( + plaintext, + row.companyId(), + row.userId(), + PHONE_FIELD + ); + updated += row.phone() != null + ? replacePlaintext(row, encrypted) + : replaceStaleCiphertext(row, encrypted); + } + ensureBatchMadeProgress(rows, updated); + return updated; + } + + private int restoreBatch(int batchSize) { + List rows = jdbcTemplate.query( + """ + SELECT user_id, company_id, phone, phone_ciphertext, phone_key_version + FROM user_account + WHERE phone_ciphertext IS NOT NULL + ORDER BY user_id + LIMIT ? + """, + (resultSet, rowNumber) -> new PhoneRow( + resultSet.getObject("user_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("phone"), + resultSet.getString("phone_ciphertext"), + resultSet.getString("phone_key_version") + ), + batchSize + ); + int updated = 0; + for (PhoneRow row : rows) { + String plaintext = piiCipher.decrypt( + row.phoneCiphertext(), + row.phoneKeyVersion(), + row.companyId(), + row.userId(), + PHONE_FIELD + ); + updated += jdbcTemplate.update( + """ + UPDATE user_account + SET phone = ?, + phone_ciphertext = NULL, + phone_key_version = NULL, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE user_id = ? + AND company_id = ? + AND phone IS NULL + AND phone_ciphertext = ? + AND phone_key_version = ? + """, + plaintext, + row.userId(), + row.companyId(), + row.phoneCiphertext(), + row.phoneKeyVersion() + ); + } + ensureBatchMadeProgress(rows, updated); + return updated; + } + + private int replacePlaintext( + PhoneRow row, + AccountPiiCipher.EncryptedValue encrypted + ) { + return jdbcTemplate.update( + """ + UPDATE user_account + SET phone = NULL, + phone_ciphertext = ?, + phone_key_version = ?, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE user_id = ? + AND company_id = ? + AND phone = ? + AND phone_ciphertext IS NULL + AND phone_key_version IS NULL + """, + encrypted.ciphertext(), + encrypted.keyVersion(), + row.userId(), + row.companyId(), + row.phone() + ); + } + + private int replaceStaleCiphertext( + PhoneRow row, + AccountPiiCipher.EncryptedValue encrypted + ) { + return jdbcTemplate.update( + """ + UPDATE user_account + SET phone_ciphertext = ?, + phone_key_version = ?, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE user_id = ? + AND company_id = ? + AND phone IS NULL + AND phone_ciphertext = ? + AND phone_key_version = ? + """, + encrypted.ciphertext(), + encrypted.keyVersion(), + row.userId(), + row.companyId(), + row.phoneCiphertext(), + row.phoneKeyVersion() + ); + } + + private EncryptionInventory inspect() { + return jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) AS account_count, + SUM(CASE WHEN phone IS NOT NULL THEN 1 ELSE 0 END) AS plaintext_count, + SUM(CASE WHEN phone_ciphertext IS NOT NULL THEN 1 ELSE 0 END) AS encrypted_count, + SUM(CASE + WHEN phone_ciphertext IS NOT NULL AND phone_key_version = ? THEN 1 + ELSE 0 + END) AS current_key_count, + SUM(CASE + WHEN phone_ciphertext IS NOT NULL AND phone_key_version <> ? THEN 1 + ELSE 0 + END) AS stale_key_count + FROM user_account + """, + (resultSet, rowNumber) -> new EncryptionInventory( + resultSet.getLong("account_count"), + resultSet.getLong("plaintext_count"), + resultSet.getLong("encrypted_count"), + resultSet.getLong("current_key_count"), + resultSet.getLong("stale_key_count"), + piiCipher.currentKeyVersion() + ), + piiCipher.currentKeyVersion(), + piiCipher.currentKeyVersion() + ); + } + + private void requireReady(int batchSize) { + if (!piiCipher.isAvailable()) { + throw new IllegalStateException( + "account PII encryption must be enabled for maintenance commands" + ); + } + if (batchSize < 1 || batchSize > 1_000) { + throw new IllegalStateException("account PII maintenance batch size must be 1 to 1000"); + } + } + + private void requireMaintenanceDatabaseRole() { + String databaseProduct = jdbcTemplate.execute( + (ConnectionCallback) connection -> databaseProductName(connection) + ); + if (!"PostgreSQL".equalsIgnoreCase(databaseProduct)) { + return; + } + Boolean allowed = jdbcTemplate.queryForObject( + """ + SELECT role.rolsuper + OR role.rolbypassrls + OR account_table.relowner = role.oid + FROM pg_catalog.pg_roles AS role + JOIN pg_catalog.pg_class AS account_table + ON account_table.relname = 'user_account' + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = account_table.relnamespace + AND namespace.nspname = 'public' + WHERE role.rolname = CURRENT_USER + """, + Boolean.class + ); + if (!Boolean.TRUE.equals(allowed)) { + throw new IllegalStateException( + "account PII maintenance requires the migration owner or a BYPASSRLS role" + ); + } + } + + private String databaseProductName(Connection connection) throws SQLException { + return connection.getMetaData().getDatabaseProductName(); + } + + private void ensureBatchMadeProgress(List rows, int updated) { + if (!rows.isEmpty() && updated == 0) { + throw new IllegalStateException( + "account PII maintenance made no progress because rows changed concurrently" + ); + } + } + + record MaintenanceResult(int processedCount, EncryptionInventory inventory) { + } + + record EncryptionInventory( + long accountCount, + long plaintextCount, + long encryptedCount, + long currentKeyCount, + long staleKeyCount, + String currentKeyVersion + ) { + } + + private record PhoneRow( + UUID userId, + UUID companyId, + String phone, + String phoneCiphertext, + String phoneKeyVersion + ) { + } + + @FunctionalInterface + private interface BatchOperation { + int run(int batchSize); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiProperties.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiProperties.java new file mode 100644 index 00000000..bba9a45f --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiProperties.java @@ -0,0 +1,62 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.auth.pii") +public final class AccountPiiProperties { + + private boolean enabled; + private String currentKeyBase64; + private String currentKeyVersion = "local-v1"; + private String decryptionKeys = ""; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCurrentKeyBase64() { + return currentKeyBase64; + } + + public void setCurrentKeyBase64(String currentKeyBase64) { + this.currentKeyBase64 = currentKeyBase64; + } + + public String getCurrentKeyVersion() { + return currentKeyVersion; + } + + public void setCurrentKeyVersion(String currentKeyVersion) { + this.currentKeyVersion = currentKeyVersion; + } + + public String getDecryptionKeys() { + return decryptionKeys; + } + + public void setDecryptionKeys(String decryptionKeys) { + this.decryptionKeys = decryptionKeys; + } + + void validateEnabledConfiguration() { + if (!enabled) { + return; + } + if (currentKeyBase64 == null || currentKeyBase64.isBlank()) { + throw new IllegalStateException( + "PII_ENCRYPTION_KEY_BASE64 must be configured when account PII encryption is enabled" + ); + } + validateKeyVersion(currentKeyVersion, "PII_ENCRYPTION_KEY_VERSION"); + } + + static void validateKeyVersion(String value, String fieldName) { + if (value == null || !value.matches("[A-Za-z0-9._-]{1,60}")) { + throw new IllegalStateException(fieldName + " must match [A-Za-z0-9._-]{1,60}"); + } + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java new file mode 100644 index 00000000..0eefefcb --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java @@ -0,0 +1,135 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +final class AesGcmAccountPiiCipher implements AccountPiiCipher { + + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + + private final Map keys; + private final String currentKeyVersion; + private final SecureRandom secureRandom; + + AesGcmAccountPiiCipher( + Map keyBytesByVersion, + String currentKeyVersion, + SecureRandom secureRandom + ) { + Objects.requireNonNull(keyBytesByVersion, "keyBytesByVersion must not be null"); + this.currentKeyVersion = requireText(currentKeyVersion, "currentKeyVersion"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom must not be null"); + this.keys = keyBytesByVersion.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + entry -> requireText(entry.getKey(), "key version"), + entry -> secretKey(entry.getValue()) + )); + if (!keys.containsKey(this.currentKeyVersion)) { + throw new IllegalStateException("current PII encryption key version is missing from the keyring"); + } + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public String currentKeyVersion() { + return currentKeyVersion; + } + + @Override + public EncryptedValue encrypt( + String plaintext, + UUID companyId, + UUID userId, + String fieldName + ) { + String value = Objects.requireNonNull(plaintext, "plaintext must not be null"); + byte[] iv = new byte[IV_BYTES]; + secureRandom.nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.ENCRYPT_MODE, + keys.get(currentKeyVersion), + new GCMParameterSpec(TAG_BITS, iv) + ); + cipher.updateAAD(aad(companyId, userId, fieldName)); + byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)); + String ciphertext = "v1." + + Base64.getUrlEncoder().withoutPadding().encodeToString(iv) + + "." + + Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted); + return new EncryptedValue(ciphertext, currentKeyVersion); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("account PII encryption failed", exception); + } + } + + @Override + public String decrypt( + String ciphertext, + String keyVersion, + UUID companyId, + UUID userId, + String fieldName + ) { + SecretKeySpec key = keys.get(requireText(keyVersion, "keyVersion")); + if (key == null) { + throw new IllegalStateException("account PII decryption key version is unavailable"); + } + String[] parts = requireText(ciphertext, "ciphertext").split("\\.", -1); + if (parts.length != 3 || !"v1".equals(parts[0])) { + throw new IllegalStateException("account PII ciphertext format is invalid"); + } + try { + byte[] iv = Base64.getUrlDecoder().decode(parts[1]); + byte[] encrypted = Base64.getUrlDecoder().decode(parts[2]); + if (iv.length != IV_BYTES) { + throw new IllegalStateException("account PII ciphertext IV is invalid"); + } + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + cipher.updateAAD(aad(companyId, userId, fieldName)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException exception) { + throw new IllegalStateException("account PII decryption failed", exception); + } + } + + private byte[] aad(UUID companyId, UUID userId, String fieldName) { + return (Objects.requireNonNull(companyId, "companyId must not be null") + + ":" + + Objects.requireNonNull(userId, "userId must not be null") + + ":user_account:" + + requireText(fieldName, "fieldName")) + .getBytes(StandardCharsets.UTF_8); + } + + private SecretKeySpec secretKey(byte[] keyBytes) { + Objects.requireNonNull(keyBytes, "keyBytes must not be null"); + if (keyBytes.length != 32) { + throw new IllegalStateException("account PII encryption keys must decode to 32 bytes"); + } + return new SecretKeySpec(keyBytes.clone(), "AES"); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(fieldName + " must not be blank"); + } + return value.strip(); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java new file mode 100644 index 00000000..bd7e915a --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java @@ -0,0 +1,32 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.util.UUID; + +final class DisabledAccountPiiCipher implements AccountPiiCipher { + + @Override + public boolean isAvailable() { + return false; + } + + @Override + public String currentKeyVersion() { + throw new IllegalStateException("account PII encryption is disabled"); + } + + @Override + public EncryptedValue encrypt(String plaintext, UUID companyId, UUID userId, String fieldName) { + throw new IllegalStateException("account PII encryption is disabled"); + } + + @Override + public String decrypt( + String ciphertext, + String keyVersion, + UUID companyId, + UUID userId, + String fieldName + ) { + throw new IllegalStateException("account PII encryption is disabled"); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java index 63faeba8..44414110 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java @@ -1,6 +1,7 @@ package com.fowoco.server.auth.infrastructure.persistence; import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCipher; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import java.util.Objects; @@ -13,15 +14,17 @@ public class JpaUserAccountRepository implements com.fowoco.server.auth.application.port.UserAccountRepository { private final EntityManager entityManager; + private final AccountPiiCipher piiCipher; - public JpaUserAccountRepository(EntityManager entityManager) { + public JpaUserAccountRepository(EntityManager entityManager, AccountPiiCipher piiCipher) { this.entityManager = entityManager; + this.piiCipher = Objects.requireNonNull(piiCipher, "piiCipher must not be null"); } @Override public void insert(UserAccount userAccount) { Objects.requireNonNull(userAccount, "userAccount must not be null"); - entityManager.persist(UserAccountJpaEntity.fromDomain(userAccount)); + entityManager.persist(UserAccountJpaEntity.fromDomain(userAccount, piiCipher)); entityManager.flush(); } @@ -36,7 +39,7 @@ public void update(UserAccount userAccount) { if (entity == null) { throw new IllegalStateException("user account to update was not found"); } - entity.applyState(userAccount); + entity.applyState(userAccount, piiCipher); entityManager.flush(); } @@ -70,7 +73,7 @@ public Optional findByNormalizedEmail(String normalizedEmail) { .setMaxResults(1) .getResultStream() .findFirst() - .map(UserAccountJpaEntity::toDomain); + .map(entity -> entity.toDomain(piiCipher)); } @Override @@ -89,7 +92,7 @@ public Optional findByNormalizedEmailWithLock(String normalizedEmai .setMaxResults(1) .getResultStream() .findFirst() - .map(UserAccountJpaEntity::toDomain); + .map(entity -> entity.toDomain(piiCipher)); } @Override @@ -124,6 +127,6 @@ private Optional findByUserIdAndCompanyId( return query .getResultStream() .findFirst() - .map(UserAccountJpaEntity::toDomain); + .map(entity -> entity.toDomain(piiCipher)); } } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java index 802163b3..d1e42146 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java @@ -3,6 +3,7 @@ import com.fowoco.server.auth.domain.AccountStatus; import com.fowoco.server.auth.domain.UserAccount; import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCipher; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -31,6 +32,8 @@ ) public class UserAccountJpaEntity { + private static final String PHONE_FIELD = "phone"; + @Id @Column(name = "user_id", nullable = false, updatable = false) private UUID userId; @@ -44,6 +47,12 @@ public class UserAccountJpaEntity { @Column(name = "phone", length = 30) private String phone; + @Column(name = "phone_ciphertext") + private String phoneCiphertext; + + @Column(name = "phone_key_version", length = 60) + private String phoneKeyVersion; + @Column(name = "email", nullable = false, length = 254) private String email; @@ -91,6 +100,8 @@ private UserAccountJpaEntity( UUID companyId, String displayName, String phone, + String phoneCiphertext, + String phoneKeyVersion, String email, String normalizedEmail, String passwordHash, @@ -108,6 +119,8 @@ private UserAccountJpaEntity( this.companyId = companyId; this.displayName = displayName; this.phone = phone; + this.phoneCiphertext = phoneCiphertext; + this.phoneKeyVersion = phoneKeyVersion; this.email = email; this.normalizedEmail = normalizedEmail; this.passwordHash = passwordHash; @@ -122,13 +135,18 @@ private UserAccountJpaEntity( this.version = version; } - public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { + public static UserAccountJpaEntity fromDomain( + UserAccount userAccount, + AccountPiiCipher piiCipher + ) { Objects.requireNonNull(userAccount, "userAccount must not be null"); - return new UserAccountJpaEntity( + UserAccountJpaEntity entity = new UserAccountJpaEntity( userAccount.userId(), userAccount.companyId(), userAccount.displayName(), - userAccount.phone(), + null, + null, + null, userAccount.email(), userAccount.normalizedEmail(), userAccount.passwordHash(), @@ -142,14 +160,16 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { userAccount.lastFailedLoginAt(), userAccount.version() ); + entity.storePhone(userAccount.phone(), piiCipher); + return entity; } - public UserAccount toDomain() { + public UserAccount toDomain(AccountPiiCipher piiCipher) { return new UserAccount( userId, companyId, displayName, - phone, + readPhone(piiCipher), email, normalizedEmail, passwordHash, @@ -165,13 +185,13 @@ public UserAccount toDomain() { ); } - void applyState(UserAccount userAccount) { + void applyState(UserAccount userAccount, AccountPiiCipher piiCipher) { Objects.requireNonNull(userAccount, "userAccount must not be null"); if (!userId.equals(userAccount.userId()) || version + 1 != userAccount.version()) { throw new IllegalArgumentException("user account version transition is invalid"); } displayName = userAccount.displayName(); - phone = userAccount.phone(); + storePhone(userAccount.phone(), piiCipher); passwordHash = userAccount.passwordHash(); updatedAt = userAccount.updatedAt(); passwordChangedAt = userAccount.passwordChangedAt(); @@ -180,4 +200,52 @@ void applyState(UserAccount userAccount) { lastFailedLoginAt = userAccount.lastFailedLoginAt(); } + private String readPhone(AccountPiiCipher piiCipher) { + Objects.requireNonNull(piiCipher, "piiCipher must not be null"); + if (phoneCiphertext != null) { + String decrypted = piiCipher.decrypt( + phoneCiphertext, + phoneKeyVersion, + companyId, + userId, + PHONE_FIELD + ); + if (piiCipher.requiresReEncryption(phoneKeyVersion)) { + storePhone(decrypted, piiCipher); + } + return decrypted; + } + if (phone != null && piiCipher.isAvailable()) { + String legacyPhone = phone; + storePhone(legacyPhone, piiCipher); + return legacyPhone; + } + return phone; + } + + private void storePhone(String value, AccountPiiCipher piiCipher) { + Objects.requireNonNull(piiCipher, "piiCipher must not be null"); + if (value == null) { + phone = null; + phoneCiphertext = null; + phoneKeyVersion = null; + return; + } + if (!piiCipher.isAvailable()) { + phone = value; + phoneCiphertext = null; + phoneKeyVersion = null; + return; + } + AccountPiiCipher.EncryptedValue encrypted = piiCipher.encrypt( + value, + companyId, + userId, + PHONE_FIELD + ); + phone = null; + phoneCiphertext = encrypted.ciphertext(); + phoneKeyVersion = encrypted.keyVersion(); + } + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 27475ef9..42fa4819 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -155,6 +155,13 @@ app: demo-document-data: command: ${DEMO_DOCUMENT_DATA_COMMAND:none} auth: + pii: + enabled: ${PII_ENCRYPTION_ENABLED:false} + current-key-base64: ${PII_ENCRYPTION_KEY_BASE64:} + current-key-version: ${PII_ENCRYPTION_KEY_VERSION:local-v1} + decryption-keys: ${PII_DECRYPTION_KEYS:} + maintenance-command: ${PII_MAINTENANCE_COMMAND:none} + maintenance-batch-size: ${PII_MAINTENANCE_BATCH_SIZE:100} jwt: issuer: ${JWT_ISSUER:fowoco-server} audience: ${JWT_AUDIENCE:fowoco-client} @@ -320,6 +327,8 @@ app: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS} auth: + pii: + enabled: ${PII_ENCRYPTION_ENABLED:true} jwt: issuer: ${JWT_ISSUER} audience: ${JWT_AUDIENCE} diff --git a/src/main/resources/db/migration/V61__encrypt_user_account_phone.sql b/src/main/resources/db/migration/V61__encrypt_user_account_phone.sql new file mode 100644 index 00000000..dc82f8cb --- /dev/null +++ b/src/main/resources/db/migration/V61__encrypt_user_account_phone.sql @@ -0,0 +1,17 @@ +ALTER TABLE user_account + ADD COLUMN phone_ciphertext TEXT; + +ALTER TABLE user_account + ADD COLUMN phone_key_version VARCHAR(60); + +ALTER TABLE user_account + ADD CONSTRAINT ck_user_account_phone_cipher_pair + CHECK ( + (phone_ciphertext IS NULL AND phone_key_version IS NULL) + OR + (phone_ciphertext IS NOT NULL AND phone_key_version IS NOT NULL) + ); + +ALTER TABLE user_account + ADD CONSTRAINT ck_user_account_phone_single_storage + CHECK (phone IS NULL OR phone_ciphertext IS NULL); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 852eb40a..242ab20a 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -176,6 +176,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("user_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) .containsEntry("display_name", new ColumnSpec("varchar", false)) + .containsEntry("phone", new ColumnSpec("varchar", true)) + .containsEntry("phone_ciphertext", new ColumnSpec("text", true)) + .containsEntry("phone_key_version", new ColumnSpec("varchar", true)) .containsEntry("normalized_email", new ColumnSpec("varchar", false)) .containsEntry("password_hash", new ColumnSpec("varchar", false)) .containsEntry("role", new ColumnSpec("varchar", false)) @@ -423,6 +426,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_user_account_company", "uq_user_account_normalized_email", "uq_user_account_user_company", + "ck_user_account_phone_cipher_pair", + "ck_user_account_phone_single_storage", "pk_refresh_token", "uq_refresh_token_hash", "fk_refresh_token_user_company", @@ -965,6 +970,27 @@ INSERT INTO user_account ( 'test-password-hash', 'HR', 'ACTIVE' ) """); + assertSqlState(connection, "23514", """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status, phone, phone_ciphertext, phone_key_version + ) VALUES ( + '45000000-0000-0000-0000-000000000001', '%s', + 'double.phone@example.com', 'double.phone@example.com', + 'test-password-hash', 'HR', 'ACTIVE', + '010-1234-5678', 'v1.example', 'test-v1' + ) + """.formatted(COMPANY_A)); + assertSqlState(connection, "23514", """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status, phone_ciphertext + ) VALUES ( + '46000000-0000-0000-0000-000000000001', '%s', + 'missing.key@example.com', 'missing.key@example.com', + 'test-password-hash', 'HR', 'ACTIVE', 'v1.example' + ) + """.formatted(COMPANY_A)); assertSqlState(connection, "23503", """ INSERT INTO refresh_token ( refresh_token_id, user_id, company_id, diff --git a/src/test/java/com/fowoco/server/auth/AccountPiiEncryptionIntegrationTest.java b/src/test/java/com/fowoco/server/auth/AccountPiiEncryptionIntegrationTest.java new file mode 100644 index 00000000..3ca2504e --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/AccountPiiEncryptionIntegrationTest.java @@ -0,0 +1,170 @@ +package com.fowoco.server.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.datasource.url=jdbc:h2:mem:fowoco-account-pii-test;" + + "MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1", + "app.auth.pii.enabled=true", + "app.auth.pii.current-key-base64=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "app.auth.pii.current-key-version=test-v1" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AccountPiiEncryptionIntegrationTest { + + private static final String PASSWORD = "Signup-password-1!"; + private static final String PHONE = "010-1234-5678"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeEach + void cleanData() { + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM password_reset_token"); + jdbcTemplate.update("DELETE FROM user_agreement_consent"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company_settings"); + jdbcTemplate.update("DELETE FROM company"); + } + + @Test + void profileUpdateStoresPhoneOnlyAsCiphertextAndReturnsDecryptedValue() throws Exception { + HttpResponse signup = postJson("/api/v1/auth/signup", signupBody()); + assertThat(signup.statusCode()).isEqualTo(201); + HttpResponse login = postJson("/api/v1/auth/login", """ + {"email":"owner@example.com","password":"Signup-password-1!"} + """); + String accessToken = JsonPath.read(login.body(), "$.access_token"); + String userId = JsonPath.read(login.body(), "$.user_id"); + + HttpResponse updated = patchJson( + "/api/v1/auth/me/profile", + """ + {"display_name":"담당자","phone":"010-1234-5678"} + """, + accessToken + ); + + assertThat(updated.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(updated.body(), "$.phone")).isEqualTo(PHONE); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone FROM user_account WHERE user_id = ?", + String.class, + userId + )).isNull(); + String ciphertext = jdbcTemplate.queryForObject( + "SELECT phone_ciphertext FROM user_account WHERE user_id = ?", + String.class, + userId + ); + assertThat(ciphertext).startsWith("v1.").doesNotContain(PHONE); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone_key_version FROM user_account WHERE user_id = ?", + String.class, + userId + )).isEqualTo("test-v1"); + + HttpResponse profile = get("/api/v1/auth/me/profile", accessToken); + assertThat(profile.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(profile.body(), "$.phone")).isEqualTo(PHONE); + } + + @Test + void legacyPlaintextPhoneIsMigratedOnAuthenticatedAccountAccess() throws Exception { + HttpResponse signup = postJson("/api/v1/auth/signup", signupBody()); + String userId = JsonPath.read(signup.body(), "$.user_id"); + jdbcTemplate.update( + "UPDATE user_account SET phone = ?, phone_ciphertext = NULL, phone_key_version = NULL WHERE user_id = ?", + PHONE, + userId + ); + + HttpResponse login = postJson("/api/v1/auth/login", """ + {"email":"owner@example.com","password":"Signup-password-1!"} + """); + + assertThat(login.statusCode()).isEqualTo(200); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE user_id = ? AND phone IS NULL " + + "AND phone_ciphertext IS NOT NULL AND phone_key_version = 'test-v1'", + Integer.class, + userId + )).isEqualTo(1); + } + + private String signupBody() { + return """ + { + "company_name":"한빛정밀", + "display_name":"담당자", + "email":"owner@example.com", + "password":"Signup-password-1!", + "agreements":{ + "service_terms":{"agreed":true,"version":"1.0"}, + "privacy_policy":{"agreed":true,"version":"1.0"}, + "marketing":{"agreed":false,"version":"1.0"} + } + } + """; + } + + private HttpResponse postJson(String path, String body) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse patchJson(String path, String body, String accessToken) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse get(String path, String accessToken) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } +} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceServiceIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceServiceIntegrationTest.java new file mode 100644 index 00000000..ae4632de --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceServiceIntegrationTest.java @@ -0,0 +1,184 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.MaintenanceResult; +import java.util.Base64; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.annotation.DirtiesContext; + +@ActiveProfiles("test") +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:fowoco-account-pii-maintenance-test;" + + "MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1", + "app.auth.pii.enabled=true", + "app.auth.pii.current-key-base64=AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "app.auth.pii.current-key-version=pii-v2", + "app.auth.pii.decryption-keys=pii-v1=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AccountPiiMaintenanceServiceIntegrationTest { + + private static final UUID COMPANY_ID = UUID.fromString( + "10000000-0000-0000-0000-000000000001" + ); + private static final UUID PLAINTEXT_USER_ID = UUID.fromString( + "11000000-0000-0000-0000-000000000001" + ); + private static final UUID OLD_KEY_USER_ID = UUID.fromString( + "11000000-0000-0000-0000-000000000002" + ); + private static final String PLAINTEXT_PHONE = "010-1111-2222"; + private static final String OLD_KEY_PHONE = "010-3333-4444"; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private AccountPiiMaintenanceService service; + + @Autowired + private AccountPiiCipher currentCipher; + + @BeforeEach + void prepareRows() { + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + jdbcTemplate.update( + """ + INSERT INTO company ( + company_id, name, status, created_at, updated_at, version + ) VALUES (?, '테스트 사업장', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + COMPANY_ID + ); + insertAccount(PLAINTEXT_USER_ID, "plain@example.com", PLAINTEXT_PHONE, null, null); + + AccountPiiCipher oldCipher = oldCipher(); + AccountPiiCipher.EncryptedValue oldEncrypted = oldCipher.encrypt( + OLD_KEY_PHONE, + COMPANY_ID, + OLD_KEY_USER_ID, + "phone" + ); + insertAccount( + OLD_KEY_USER_ID, + "old-key@example.com", + null, + oldEncrypted.ciphertext(), + oldEncrypted.keyVersion() + ); + } + + @Test + void migratesPlaintextAndOldKeyRowsThenRestoresPlaintextForRollback() { + MaintenanceResult migrated = service.migrateToCurrentKey(1); + + assertThat(migrated.processedCount()).isEqualTo(2); + assertThat(migrated.inventory().plaintextCount()).isZero(); + assertThat(migrated.inventory().currentKeyCount()).isEqualTo(2); + assertThat(migrated.inventory().staleKeyCount()).isZero(); + assertThat(service.migrateToCurrentKey(1).processedCount()).isZero(); + assertStoredPhone(PLAINTEXT_USER_ID, PLAINTEXT_PHONE); + assertStoredPhone(OLD_KEY_USER_ID, OLD_KEY_PHONE); + + MaintenanceResult restored = service.restorePlaintext(1); + + assertThat(restored.processedCount()).isEqualTo(2); + assertThat(restored.inventory().encryptedCount()).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone FROM user_account WHERE user_id = ?", + String.class, + PLAINTEXT_USER_ID + )).isEqualTo(PLAINTEXT_PHONE); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone FROM user_account WHERE user_id = ?", + String.class, + OLD_KEY_USER_ID + )).isEqualTo(OLD_KEY_PHONE); + } + + @Test + void verificationFailsWhilePlaintextOrOldKeyRowsRemain() { + assertThatThrownBy(service::verifyCurrentKey) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("plaintext or stale-key rows remain"); + } + + private void insertAccount( + UUID userId, + String email, + String phone, + String ciphertext, + String keyVersion + ) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, display_name, phone, phone_ciphertext, + phone_key_version, email, normalized_email, password_hash, + role, status, created_at, updated_at, password_changed_at, + failed_login_attempts, version + ) VALUES ( + ?, ?, '담당자', ?, ?, ?, ?, ?, 'password-hash', + 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, 0, 0 + ) + """, + userId, + COMPANY_ID, + phone, + ciphertext, + keyVersion, + email, + email + ); + } + + private void assertStoredPhone(UUID userId, String expected) { + StoredPhone stored = jdbcTemplate.queryForObject( + """ + SELECT phone, phone_ciphertext, phone_key_version + FROM user_account + WHERE user_id = ? + """, + (resultSet, rowNumber) -> new StoredPhone( + resultSet.getString("phone"), + resultSet.getString("phone_ciphertext"), + resultSet.getString("phone_key_version") + ), + userId + ); + assertThat(stored).isNotNull(); + assertThat(stored.plaintext()).isNull(); + assertThat(stored.keyVersion()).isEqualTo("pii-v2"); + assertThat(currentCipher.decrypt( + stored.ciphertext(), + stored.keyVersion(), + COMPANY_ID, + userId, + "phone" + )).isEqualTo(expected); + } + + private AccountPiiCipher oldCipher() { + AccountPiiProperties properties = new AccountPiiProperties(); + properties.setEnabled(true); + properties.setCurrentKeyVersion("pii-v1"); + properties.setCurrentKeyBase64( + Base64.getEncoder().encodeToString(new byte[32]) + ); + return new AccountPiiCryptoConfiguration().accountPiiCipher(properties); + } + + private record StoredPhone(String plaintext, String ciphertext, String keyVersion) { + } +} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipherTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipherTest.java new file mode 100644 index 00000000..9e7f699d --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipherTest.java @@ -0,0 +1,110 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class AesGcmAccountPiiCipherTest { + + private static final UUID COMPANY_ID = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID USER_ID = UUID.fromString("11000000-0000-0000-0000-000000000001"); + + @Test + void encryptsWithTenantUserAndFieldBoundAuthenticatedData() { + AesGcmAccountPiiCipher cipher = cipher(Map.of("test-v1", new byte[32]), "test-v1"); + + AccountPiiCipher.EncryptedValue encrypted = cipher.encrypt( + "010-1234-5678", + COMPANY_ID, + USER_ID, + "phone" + ); + + assertThat(encrypted.ciphertext()).startsWith("v1.").doesNotContain("010-1234-5678"); + assertThat(encrypted.keyVersion()).isEqualTo("test-v1"); + assertThat(cipher.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "phone" + )).isEqualTo("010-1234-5678"); + assertThatThrownBy(() -> cipher.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + UUID.randomUUID(), + USER_ID, + "phone" + )).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> cipher.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "email" + )).isInstanceOf(IllegalStateException.class); + } + + @Test + void decryptsPreviousKeyVersionDuringRotation() { + byte[] previousKey = new byte[32]; + byte[] currentKey = new byte[32]; + currentKey[0] = 1; + AesGcmAccountPiiCipher previous = cipher(Map.of("pii-v1", previousKey), "pii-v1"); + AccountPiiCipher.EncryptedValue encrypted = previous.encrypt( + "010-9999-0000", + COMPANY_ID, + USER_ID, + "phone" + ); + AesGcmAccountPiiCipher rotated = cipher( + Map.of("pii-v1", previousKey, "pii-v2", currentKey), + "pii-v2" + ); + + assertThat(rotated.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "phone" + )).isEqualTo("010-9999-0000"); + assertThat(rotated.encrypt("010-9999-0000", COMPANY_ID, USER_ID, "phone").keyVersion()) + .isEqualTo("pii-v2"); + } + + @Test + void rejectsTamperedCiphertextAndUnknownKeyVersion() { + AesGcmAccountPiiCipher cipher = cipher(Map.of("test-v1", new byte[32]), "test-v1"); + AccountPiiCipher.EncryptedValue encrypted = cipher.encrypt( + "010-1234-5678", + COMPANY_ID, + USER_ID, + "phone" + ); + String tampered = encrypted.ciphertext().substring(0, encrypted.ciphertext().length() - 1) + "A"; + + assertThatThrownBy(() -> cipher.decrypt( + tampered, + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "phone" + )).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> cipher.decrypt( + encrypted.ciphertext(), + "unknown-v1", + COMPANY_ID, + USER_ID, + "phone" + )).isInstanceOf(IllegalStateException.class); + } + + private AesGcmAccountPiiCipher cipher(Map keys, String currentVersion) { + return new AesGcmAccountPiiCipher(keys, currentVersion, new SecureRandom()); + } +} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntityPiiRotationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntityPiiRotationTest.java new file mode 100644 index 00000000..754eecc2 --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntityPiiRotationTest.java @@ -0,0 +1,68 @@ +package com.fowoco.server.auth.infrastructure.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCipher; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCryptoConfiguration; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiProperties; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class UserAccountJpaEntityPiiRotationTest { + + private static final UUID COMPANY_ID = UUID.fromString( + "10000000-0000-0000-0000-000000000001" + ); + private static final UUID USER_ID = UUID.fromString( + "11000000-0000-0000-0000-000000000001" + ); + private static final String PHONE = "010-1234-5678"; + + @Test + void reEncryptsCiphertextWithCurrentKeyWhenAccountIsRead() { + byte[] oldKey = new byte[32]; + byte[] currentKey = new byte[32]; + currentKey[0] = 1; + AccountPiiCipher oldCipher = cipher("pii-v1", oldKey, ""); + UserAccountJpaEntity entity = UserAccountJpaEntity.fromDomain( + UserAccount.create( + USER_ID, + COMPANY_ID, + "담당자", + PHONE, + "owner@example.com", + "password-hash", + UserRole.ADMIN, + Instant.parse("2026-08-19T00:00:00Z") + ), + oldCipher + ); + + AccountPiiCipher rotatingCipher = cipher( + "pii-v2", + currentKey, + "pii-v1=" + Base64.getEncoder().encodeToString(oldKey) + ); + assertThat(entity.toDomain(rotatingCipher).phone()).isEqualTo(PHONE); + + AccountPiiCipher currentOnlyCipher = cipher("pii-v2", currentKey, ""); + assertThat(entity.toDomain(currentOnlyCipher).phone()).isEqualTo(PHONE); + } + + private AccountPiiCipher cipher( + String currentVersion, + byte[] currentKey, + String previousKeys + ) { + AccountPiiProperties properties = new AccountPiiProperties(); + properties.setEnabled(true); + properties.setCurrentKeyVersion(currentVersion); + properties.setCurrentKeyBase64(Base64.getEncoder().encodeToString(currentKey)); + properties.setDecryptionKeys(previousKeys); + return new AccountPiiCryptoConfiguration().accountPiiCipher(properties); + } +}