feat(crypto): bind the table and row identity into the column AAD (S04-A per-format columns plan: 1/11)
Summary
Binds the storage table, the namespace id and the row's own primary key into every credential column's AAD, alongside the logical column name it already carried. This is step 1 of the per-format encrypted credential columns plan, and every later step is built on it.
Until now a column's caller AAD was the logical column name alone, so the same ciphertext opened under any row of any table that used the same name.
Someone with database write access could move encrypted_password from one remote repository's row onto another's, carrying wrapped_dek and the key-identity columns with it, and the composite FK and every CHECK would accept the result.
The caller AAD is now byte-exact, as S04-A specifies it:
tableName || 0x00 || logicalName || 0x00 || namespaceID[16] || rowID[16]Two exported types carry the binding.
crypto.TableName is the storage table's name as bound; crypto.RowRef pairs it with the row's own primary key.
EncryptRow and DecryptRow take a RowRef and reject one missing either field with crypto.ErrInvalidRowRef, before any key or DEK work: a zero RowRef would otherwise round-trip cleanly and store ciphertext bound to no table and no row, which is the failure this binding exists to close.
That ordering is now pinned rather than only documented, because past KeyManager.GetKey a refused write would auto-create the namespace's version-1 key row.
The known-answer vectors were regenerated
internal/crypto/testdata/known_answer_vectors.json exists to make an AAD change fail loudly, so regenerating it is normally the wrong answer.
It is correct here under the fixture's second condition, which this merge request adds to the warning text: no schema anywhere carries an encrypted credential column, and EncryptRow/DecryptRow have no non-test callers, so no stored value was invalidated and no re-encryption migration is owed.
Only the two column vectors moved.
wrapped_key and wrapped_dek keep their d11d0f223 bytes, because neither tier's AAD changed and reproducing them would have discarded the cross-build continuity check the fixture exists for.
_regenerated_because and _columns_regenerated_at_commit record both halves, and internal/crypto/known_answer.md carries the mutation table: nine of ten symmetric mutations fail the package suite with known_answer_test.go removed, re-measured at d1838a1cd through go test -overlay, one mutation at a time.
WireVersion did not move with the binding, so nothing on the wire separates a column sealed under the old caller AAD from one sealed under the new.
That is free only while no schema declares an encrypted_ column: a revert takes the fixture with it and the suite stays green, which is correct while there is no stored value to be made unreadable, and stops being correct the moment such a column ships.
known_answer.md records that window.
Merge order
I intersected this branch's changed-file list against every open merge request's and found no overlap.
The two that touch internal/crypto at all, !2348 (merged) and !2311 (merged), share no file with this diff and both git merge-tree clean against this branch.
Steps 2 to 11 of the plan depend on this one and merge after it; none is open yet. Nothing widens ahead of the code that serves it: the interface gains a required argument rather than accepting a new value, so no gap ships between this merge request and its consumers.
A residual this merge request does not reach
S04-A gives TableName's rationale as the named type making "the bound identity ... checkable when the code compiles", and adds that "the type stops an arbitrary string being passed inline" (docs/specs/S04-a-column-level-encryption.md, its ### Interfaces block).
Both halves are false for a named string type: an untyped constant converts implicitly, so RowRef{Table: "any_wrong_literal"} compiles with no conversion and two slices can still pass the same wrong literal.
Only a runtime string needs crypto.TableName(...).
This merge request corrects the Go doc comment on crypto.TableName to say what actually holds and leaves the spec standing, because a step MR does not amend a spec.
The merged plan repeats the same claim in its ## Research Findings ("the bound name is checked when the code compiles").
Two sites therefore still say the compiler checks the bound table name, which is what the reviewers of Steps 3, 7 and 10 will read when they declare the three constants:
- the S04-A correction rides the spec amendment the plan already gates before Step 4 merges, alongside the five backfill sites listed in the plan's
## Dependencies. - the plan line goes in !2350 (merged), the
docs(plans)merge request that records this step's Status-table row, since a step MR must not edit the plan file.
Governing ADRs
- ADR 007 mandates the all-or-none CHECK across a table's encrypted credential columns.
docs/dev/column-encryption.mdnow describes that unit as the key-wrap trio together with everyencrypted_column wherever the trio is nullable, per S04-A. Widening the unit still enforces everything ADR 007 requires, so this is not a deviation. The guide's attribution is split accordingly: ADR 007 for theencrypted_half, S04-A for the trio-inside-the-unit and single-attribute halves, because ADR 007 scopes its rule to a table with multiple credential columns and names the trio nowhere, so reading it alone makes npm's loneencrypted_auth_tokenlook exempt when S04-A says it is not. - ADR 007 also fixes every API-exposed table's
idas an application-generated UUIDv7 with no server-side default. What makes binding the row's primary key safe is a separate fact, and the guide states it on its own authority rather than borrowing it: no update path writesid, so nothing rewrites one in place. Application generation settles who mints the id, not that noUPDATEever changes it. - ADR 006 keeps migrations self-contained at pod startup rather than adopting the monolith's post-deployment migration model.
It constrains migrations, so it is not what rules a backfill out, and the guide no longer cites it for that.
What holds is narrower and is what the guide now says: no SQL migration can encrypt a plaintext column, because that needs the Go crypto stack and the root key; a Go-side pass is not ruled out, since
re-encryptalready resolves the root key and walks credential tables; and no phase of it encrypts a plaintext column today, with no work item owning one. The guide also carries the condition the plan attaches to the discard, which it previously stated unconditionally.
Checked and not applicable: ADR 023 (cross-format isolation) is untouched, because this change sits below the format layer and no format code is reached. No deviation, so no handbook amendment is owed.
Testing
Added, all in internal/crypto:
TestDefaultRowEncryptor_ColumnAADIsByteExactopens the column under a frozen hex AAD rather than a composed one, and refuses eight near misses: the logical name alone, no separators, the two UUIDs swapped, the UUIDs as canonical text, another table, the storage column name, another row id, another namespace id. A composed positive would pass against a wrong field order, which is why the expected value is a fixed slice.TestDefaultRowEncryptor_RefusesCiphertextMovedBetweenRowsmoves a whole row, ciphertext and wrapped DEK together, between two rows of one table and between two tables sharing an(id, namespace_id)pair and a logical name. Each case asserts the control opens first, so the refusal is the move and not a broken fixture.TestDefaultRowEncryptor_SeparatorsKeepTheBindingUnambiguouspins the0x00after the table name by relabelling tabletcolumnabas tabletacolumnb.- Two subtests pin the
RowRefguard's position, which was documented and unheld:EncryptRowacquires no key and generates no DEK for an unbound ref, andDecryptRowrefuses one before the key lookup. Each assertion was falsified by moving the guard undergo test -overlay, so the worktree stayed clean: pastgenerateDEKreddens the DEK assertion alone, which is what keeps it from being decoration beside the key one; immediately beforeEncryptRow's return reddens both; andDecryptRow's past the key lookup reddens its own, because the row names a key id no fixture holds. cryptotest.RowEncryptorConformanceholds every implementation to the binding contract: round trip under its own reference, refusal under another namespace's, another row's and another table's coordinates, and refusal of an emptyTableand a nilRowon both paths.TestDefaultRowEncryptor_Conformanceruns it against the one implementation that exists. Its cross-namespace cases pin less than they read as pinning, anddocs/dev/column-encryption.mdrecords the residual under### What the conformance suite reaches: three tiers refuse a wrong namespace independently and the first intact one answers, so droppingnamespaceIDfromcolumnCallerAADalone leaves both cases green. What they do catch is an implementation that ignoresnamespaceIDthroughout. The column AAD's own namespace component is pinned per implementation instead, byTestDefaultRowEncryptor_ColumnAADIsByteExact.TestKnownAnswer_RefusesMisboundColumnAADreplaces the single sibling-column case with four, against the frozen bytes.
The column tag-mismatch error now names the table and the row id alongside the column. Before this change the column name was the entire caller AAD; it now carries four values, and without those two a mis-bound reference reads in a log exactly like ciphertext tampering or a cross-namespace read.
No e2e scenario is added or affected, so docs/testing/ is unchanged: EncryptRow and DecryptRow still have no non-test callers, and nothing reachable from a request path changes behaviour.
Format conformance does not apply; this change touches no Maven, npm or OCI protocol code.
Size
913 reviewable LOC across 13 files, past the 500 ceiling in development-model.md. Splitting would not help, because each part is what makes the others reviewable:
| Group | Files | LOC |
|---|---|---|
| Go tests | 6 | 520 |
Production Go (provider.go, row_encryptor.go) |
2 | 106 |
Docs (column-encryption.md, known_answer.md) |
2 | 159 |
cryptotest conformance suite |
2 | 109 |
| Frozen JSON fixture | 1 | 19 |
Production Go is 106 of the 913.
Landing the signature change without the frozen vectors and the cross-row tests would put a format-binding change on main with nothing pinning its bytes, and landing the vectors without the change reddens the suite.
Related to #417 (closed)