Loading
feat(crypto): add DefaultRowEncryptor with key-identity stamping
Implements plan Step 5 of S04-A column-level encryption: the DefaultRowEncryptor and the key-identity stamping it introduces.
What this MR does
internal/crypto/row_encryptor.go(new):DefaultRowEncryptor.EncryptRowgenerates one fresh DEK per row, seals every column under it with the logical column name as AAD, wraps the DEK under the namespace's active key (nil caller AAD), and stampsNamespaceKeyIDandNamespaceKeyVersionfrom that same acquired key.DecryptRowis the symmetric read path, resolving the wrapping key by the stamped key row id viaGetKeyByID, so rows wrapped by deactivated key rows still decrypt. The namespace key is acquired insideEncryptRow, immediately before the DEK wrap; no method takes a caller-suppliedNamespaceKey.internal/crypto/provider.go:EncryptedRowgainsNamespaceKeyID, the key row's never-reused identity, so the stamped identity reaches the credential INSERT alongside the denormalized version.internal/crypto/rotation.go:ReEncryptRowre-stamps both key-identity fields together, keys its idempotency check off the key row id rather than the version, resolves the row's old wrapping key by id viaGetKeyByID, and heals a drifted version stamp on an id match (metadata only, no crypto work) so a drifted row leaves the rotation sweep predicate after one pass. The version-orderedErrStaleActiveKeyguard stays for id mismatches. This ships the Step 6 contract amendment the plan assigns to Step 5, with the field.- Tests:
row_encryptor_test.go(new) and theReEncryptRowsuites inrotation_test.go, including mutation-hardened pins: the wrapped DEK is verified to be that write's generated DEK at the point of use, fake key managers panic onGetKeyByVersion(no runtime path resolves keys by the denormalized version), and every DEK and served-key buffer is asserted zeroized on success and error paths.
Spec coverage
Spec: docs/specs/S04-a-column-level-encryption.md
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-10 | EncryptRow/DecryptRow round-trip (multi-column, one DEK) |
TestDefaultRowEncryptor_RoundTrip |
| AC-11 | Cross-namespace isolation | TestDefaultRowEncryptor_DecryptRow/cannot_decrypt_another_namespace's_row (the (id, namespace_id) lookup miss and the defense-in-depth wrong-KEK unwrap failure) |
| AC-25 | EncryptRow stamps ns_key_id + ns_key_version from the acquired key; DecryptRow and ReEncryptRow resolve by ns_key_id, never by version |
TestDefaultRowEncryptor_RoundTrip (stamping), TestDefaultRowEncryptor_DecryptRow/resolves_the_wrapping_key_by_id,_not_by_the_active_key, TestNamespaceKeyReEncryptor_ReEncryptRow (id re-stamp, id-keyed idempotency, version heal), TestDefaultRowEncryptor_ReEncryptRowInterplay |
Error cases
| Condition | Tests |
|---|---|
ns_key_id not found → DecryptRow returns ErrKeyNotFound |
TestDefaultRowEncryptor_DecryptRow/returns_ErrKeyNotFound_for_a_purged_key_row_id |
Row version newer than served active key → ErrStaleActiveKey |
TestNamespaceKeyReEncryptor_ReEncryptRow_Errors/fails_closed_when_the_row_is_newer_than_the_served_active_key |
| Tampered ciphertext / wrong AAD (column swap) / short wrapped DEK | TestDefaultRowEncryptor_DecryptRow tamper and swap subtests; TestNamespaceKeyReEncryptor_ReEncryptRow_Errors/errors_on_an_unwrappable_DEK (ErrCiphertextShort) |
Security considerations
| Property | Tests |
|---|---|
| Per-row DEKs zeroized after use, all paths | spyDEKs/assertDEKsZeroized across TestDefaultRowEncryptor_*; TestNamespaceKeyReEncryptor_ReEncryptRow_ZeroizesDEK; the wrap-error-path DEK assert |
| Served KEK copies destroyed by the caller (copy-on-serve) | assertRowServedZeroized/assertServedZeroized on success and error subtests |
| No caller-supplied key; operation-scoped acquisition | Interface signatures; getKeyCalls == 0 pins on the pre-acquisition error paths |
| Fresh DEK per write, verified at point of use | TestDefaultRowEncryptor_EncryptRow/generates_a_fresh_DEK_per_write (unwraps each WrappedDEK and compares to the captured generated DEK) |
Guardrails
- Tests: unit coverage at 100% of statements for
row_encryptor.goand the amendedrotation.gofunctions; suite verified against 36 behavior mutations across three review cycles plus a post-rebase gate. - e2e catalog (
docs/testing/): not affected — internal crypto plumbing with no user-facing surface; encrypted-credential e2e scenarios ride with the S13 per-format credential wiring. - Configuration reference: no config schema/loader change in this MR.
Related to https://gitlab.com/gitlab-org/ops/artifact-registry/-/issues/384