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. EncryptRow generates 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 stamps NamespaceKeyID and NamespaceKeyVersion from that same acquired key. DecryptRow is the symmetric read path, resolving the wrapping key by the stamped key row id via GetKeyByID, so rows wrapped by deactivated key rows still decrypt. The namespace key is acquired inside EncryptRow, immediately before the DEK wrap; no method takes a caller-supplied NamespaceKey.
  • internal/crypto/provider.go: EncryptedRow gains NamespaceKeyID, the key row's never-reused identity, so the stamped identity reaches the credential INSERT alongside the denormalized version.
  • internal/crypto/rotation.go: ReEncryptRow re-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 via GetKeyByID, 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-ordered ErrStaleActiveKey guard 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 the ReEncryptRow suites in rotation_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 on GetKeyByVersion (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.go and the amended rotation.go functions; 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

Merge request reports

Loading
Loading