Per-format credential-column encryption wiring

Closed Beta blocker: Security (customer secrets at rest)

Credentials that customers give us for their upstream registries are stored unencrypted in our database. For the platform: any database exposure hands over customer secrets in the clear, before we ever have a real customer secret to lose.

Delivery item for the per-format credential-column wiring that the S04-A plan work item #262 explicitly defers.

#262 is scoped to S04-A Phase 1, framework-only, and states:

Per-format credential-column wiring (encrypted_* columns, backfill from tmp_plaintext_*, drop-plaintext, RowEncryptor boundary), composition-root wiring, and the re-encrypt CLI are a follow-up gated on S13's remote-repository tables.

That follow-up had no work item. This is it. The re-encrypt CLI is tracked separately under epic &6; framework delivery rides #384; composition-root wiring was owned by #426 (S04-C), which is merged and closed.

Scope

Migrate the interim plaintext credential columns to the ADR-007 encrypted_* bytea shape, backfill from the plaintext values, drop the plaintext columns, and move reads and writes behind the S04-A RowEncryptor boundary. One slice per table, each with its own migration sequence:

Table Plaintext columns today Credential read/write
maven_remote_repositories tmp_plaintext_username, tmp_plaintext_password internal/datastore/maven_remote_credentials.go
npm_remote_repositories tmp_plaintext_auth_token internal/datastore/npm_remote_repositories.go
container_remote_repositories tmp_plaintext_username, tmp_plaintext_password internal/datastore/container_remote_credentials.go

What each slice owes

The contract is written up in docs/dev/column-encryption.md; the DDL shape itself is specified by S04-A.

  • The four key-wrap columns: wrapped_dek BYTEA, ns_key_id UUID, ns_key_version INT, plus one encrypted_<attribute> BYTEA per credential attribute.
  • An all-or-none CHECK across the credential columns that form one unit, replacing the tmp_plaintext_* one where a pair exists.
  • The composite key-reference FK (ns_key_id, namespace_id) REFERENCES namespace_encryption_keys (id, namespace_id) ON DELETE RESTRICT, served by a (namespace_id, ns_key_id) index. This is what makes key-version retirement safe.
  • No constraint on ciphertext internals. The 2,048-character plaintext cap stays in the Go validation layer.
  • Backfill from the plaintext values, then drop the plaintext columns.
  • Reads and writes through crypto.RowEncryptor.EncryptRow / DecryptRow, with the logical attribute name as AAD. The logical name is permanent once a row is written: renaming it, or reusing a column for another attribute, orphans every stored ciphertext.
  • A datastore.CredentialTable implementation, registered where the operator's re-encrypt command is composed. Without it the table does not join the rotation sweep.
  • Integration tests for the two sweep queries (ordering, the limit bound, and the guard's atomicity) and EXPLAIN coverage for both call shapes the sweep issues.

Gate status

Measured on main at fff85cb3a (2026-09-02):

  • The remote-repository tables this was gated on all exist in internal/datastore/migrations/structure.sql.
  • Composition-root wiring (#426, S04-C) is merged and closed.
  • Credentials are still stored in plaintext: the three tables carry tmp_plaintext_* columns, and structure.sql has no wrapped_dek, encrypted_username, encrypted_password, or encrypted_auth_token column anywhere.
  • credentialTables in cmd/artifact-registry/operator.go is composed empty in production, so a namespace-key rotation today re-wraps no credential row.

Nothing gates the start of this work. Re-check the four claims above with git grep before planning; they move as the tree does.

Closed-beta-blocking

Plaintext credential storage is an accepted, time-boxed risk, and this work item is what closes it. The registry does not enter closed beta, and does not handle any customer's upstream credentials, while these columns hold plaintext.

The specs record the opposite posture

They say plaintext is accepted through the closed beta and that only GA is blocked. S04-C states it most directly and cites this work item as its source, so moving the gate here without amending the specs leaves them contradicting their own citation. Amend all five in the same pass. Lines at fff85cb3a:

File Line What it records
docs/specs/S04-c-column-level-encryption-wiring.md 199 "Encryption compliance is GA-blocking, not beta-blocking, matching the posture recorded on #417"
docs/specs/S13-virtual-remote-foundation.md 100 Plaintext accepted for the closed beta, GA-blocking
docs/specs/S14-maven-remote.md 580 GA-blocking, and still cites #68
docs/specs/S15-npm-remote.md 1077, 1134 GA-blocking, and still cites #68
docs/specs/S16-container-remote.md 1258 Plaintext accepted for the closed beta, GA-blocking

The S14 and S15 lines carry both defects at once, so they and the #68 fixes in Spec citations to correct are one edit, not two.

Re-derive before editing, and filter the hits: S32 records two GA-blocking gates of its own that have nothing to do with credential encryption.

git grep -nE 'GA-blocking|reach GA' -- docs/specs

docs/roadmap/closed-beta.md needs a look in the same pass. It does not mention this work item anywhere, and its S04 row reads Completed, which is true of the framework tiers and not of the per-format wiring the closed beta now waits on.

Security review

A Data Security review of the shipped S04-A/B/C stack was completed on 2026-09-01, in the confidential consult gl-security#500. It names this work item as the tracker for the per-format wiring. Three things follow from it here:

  • The review records prerequisites that must be resolved before the encryption configuration block is enabled in any environment. This work is what makes that block worth enabling, so those prerequisites are effectively upstream of it.
  • Several findings are latent only while nothing is encrypted, and this work is what arms them. The review asks that they be settled before this lands rather than after, because afterwards each becomes a migration over live encrypted rows instead of a schema or AAD decision.
  • A further review is required once this lands. It brings the first code that exercises the framework end to end: the CredentialTable implementations, the first callers of EncryptRow / DecryptRow, and the columns with the composite FK.

The findings themselves are confidential and are not restated here, because this work item is public. Read them in the consult, and reply there.

The AppSec review gates are tracked separately and are still open: #513 (S04-A/C) and #514 (S04-B).

Delivery shape

This is feature work, so the plan-MR guardrail in AGENTS.md applies: a plan file under docs/plans/ lands in its own MR before the first implementation MR opens. The DDL shape and the framework contract are already specified, so a new spec may not be needed; settle that when the plan is written.

The three slices are independent. Whether they land as one plan with three steps or three plans is a planning decision, not a constraint from the schema.

Spec citations to correct

Some specs still point a reader at #68 as the tracker for this work. #68 is closed and was scoped to documenting encryption guidance, so it cannot carry it. Occurrences at fff85cb3a:

File Occurrences
docs/specs/S14-maven-remote.md 4
docs/specs/S15-npm-remote.md 4
docs/specs/S17-rest-management-api.md 1

Re-derive before fixing, because the count moves:

git grep -nE '#68([^0-9]|$)|(work_items|issues)/68([^0-9]|$)' -- docs/specs

Three hits that search returns are deliberate and must stay: S16-container-remote.md explains why neither #68 nor #262 can carry this work and points here; S04-a-column-level-encryption.md cites the #68 thread as the one that established the requirements; S04-b-encryption-key-rotation-tooling.md cites it as the origin of the --scope=master naming it overrode.

docs/specs/S13-virtual-remote-foundation.md cited #68 when this work item was filed and no longer does; it points here throughout. Merged files under docs/plans/ are historical records and are not rewritten.

Epic &2 (S04a: column-level encryption) · #262 (plan) · #384 (framework delivery) · #426 (S04-C composition-root wiring, closed) · #250 (row-identity AAD binding for credential columns) · #358 (root key provisioning) · #513 and #514 (AppSec review gates)

Edited by João Pereira