ADR-0024: Datasource credential encryption at rest (AES-256-GCM)
Status
Accepted — 2026-08-05
Context
ADR-0008 moved JDBC connection secrets (url/username/password/driver) from projectJSON.profile.dbs to single source of truth table data_sources, solving "share/version snapshot carries plaintext password" — but data_sources.username/password still stored plaintext in MySQL: DBA/backup/slow query log/anyone with data volume can read user downstream DB passwords directly. Repo already has JwtConfig (session key), OidcRsaKeySupport (OIDC RSA private key) two "local weak default + prod fail-fast + reject repo default" key management conventions, but no symmetric encryption for data at rest; jasypt-spring-boot-starter dependency declared but never wired.
Decision
- Encrypt only true secret fields:
username,password;host/port/url/databaseName/driverClassNamestay plaintext (non-secret, and some scenarios need direct display in list/form — not worth decryption-failure availability risk) - Algorithm: AES-256-GCM (
javax.cryptonative, zero new deps), key =SHA-256(ERD_DB_CONFIG_SECRET); ciphertext formatenc:v1:<base64(iv[12B]||ciphertext||tag)>, prefix carries dual "version + encrypted?" identifier - Location: encrypt/decrypt centralized in
DataSourceCredentialCipher(single Spring bean), called byDataSourcesServiceImpl(coverssave/updateById/update/getById/list/page) andDataSourceAcl(only path bypassing Service direct mapper); no MyBatisTypeHandlerscattered to mapper layer — keep explicit call chain "who reads/writes DB encrypts/decrypts" for audit and test - Backward compat / gradual migration: no one-shot batch rewrite.
decrypt()passes through legacy plaintext withoutenc:v1:prefix; user next edit-save (form username/password required)encrypt()auto-upgrades. Fresh encrypted rows and legacy plaintext rows can coexist long-term, no migration window downtime - Key management: reuse
JwtConfig/OidcRsaKeySupportconvention —erd.datasource-secret.key: ${ERD_DB_CONFIG_SECRET:<repo weak default>}; local/dev allows weak default (dev-ensurezero-config);application-prod.ymlno default (missing placeholder parse fail, fail-fast);DataSourceCredentialCipherconstructor additionally validates prod key non-blank and not repo default - No Jasypt/external KMS: repo scale (monolith self-host, user-managed DB) native AES-GCM sufficient;
jasypt-spring-boot-starternot wired this round (future KMS/HSM can be separate ADR)
Consequences
- Positive: MySQL at-rest/backup/slow query no longer plaintext downstream passwords; transparent to Controller,
ConnectorCredentialResolver, frontend forms (API still plaintext, UX zero change) - Positive: gradual migration zero downtime, no batch migration script or maintenance window
- Negative: key rotation costly — changing
ERD_DB_CONFIG_SECRETmakes all old ciphertext undecryptable (decrypt()throws); before rotation must re-save all connections with old key, or export and rebuild with new key (recorded in deployment) - Negative:
username/passwordcolumns longer after ciphertext (AES-GCM ≈ plaintext + 28 bytes +enc:v1:7-byte prefix, then base64 ~33% expansion); FlywayV18widenedusernameto 255,passwordto 500, covers most cases - Follow-up (not this slice): multi-key/rotation without losing history needs key id prefix (e.g.
enc:v2:<kid>:...) and dual-key decrypt fallback — not this ADR
Verification
DataSourceCredentialCipherTest: encrypt/decrypt roundtrip, IV randomness, idempotent encrypt, legacy plaintext passthrough, tamper/wrong key throws, prod empty/repo default fail-fastcurlmanual: afterPOST /ncnb/dataSourcesdirect MySQL querydata_sources.passwordisenc:v1:...;GET /ncnb/dataSources/{id}and list still return plaintext password; manual insert plaintext row readable onGET, afterPATCHre-save MySQL becomes ciphertextmvn testfull pass (except unrelated existing failureOracleReverseDialectCommentTest, verified same failure on main withgit stash)