Skip to content

sys_secret: rotations that happened BEFORE #8030's fix left decryptable orphans on deployed instances, and nothing reaps them #8103

Description

@huangyiirene

Residual left open, deliberately and with reason, by #8030 → PR #8063. Filed by the domain:services PM seat on the dev's recommendation; ⛔ not graded by me — for triage.

What is left

#8030 made a secret rotation actually rotate, and made it reap the ciphertext it rotated away. The reaping is forward-only: it fires on the write that retires a handle. Rows orphaned by rotations that already happened on a deployed instance are not touched by it, and nothing else ever will be.

On such an instance every rotation performed before the fix left one sys_secret row behind — the filer of #8030 measured the count going 7 → 8 → 9 across three writes on their own instance. Each one is a decryptable copy of a credential an administrator believed they had retired, sitting under the same data key, readable by anyone who can read the table. That is the same exposure #8030 argued was worth fixing; the only difference is that these copies already exist rather than being created going forward.

Compounding it, and specific to the pre-fix population: because the handle was never repointed, an orphan from that era may be the ciphertext that was still in force — so the set is not simply "old values", it is "old values plus, for keys never successfully rotated, the value the admin thought they had replaced".

Why it was not done inside #8030

Correctly, in my read as the reviewing seat: doing this properly means a migration that walks sys_secret against the live value_enc values and deletes what nothing references, and a P0 hotfix on a release-blocking security defect is the wrong vehicle for a destructive sweep over the secrets table. Landing it as a rider would have meant shipping an unreviewed delete-many over credentials on the same PR that was being rushed for target:v17.

What it needs from whoever takes it — as constraints, not a design

  • The sweep is destructive and irreversible over a secrets table. Whether it is a migration, an admin command, or an opt-in pnpm script is exactly the decision this card is for; ⛔ do not assume "migration that runs on boot".
  • Reachability has to be established, not assumed. fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063's reaping argues it from three facts — handle ids are minted per encrypt() call, sys_setting.value_enc is the only column that holds one, and the audit trail records sha256: digests rather than handles. A sweep over existing rows has a wider blast radius than a single point delete, so those three want re-measuring rather than citing, including against instances whose history predates the current shapes.
  • The legacy inline-crypto path stores the ciphertext itself in value_enc rather than a sec_ handle. fix(service-settings): repoint sys_setting.value_enc on a secret rotation, and reap the retired ciphertext (#8030) #8063's reaper self-guards with a sec_ prefix check; any sweep needs the equivalent, in the other direction (do not orphan-collect rows a legacy value implicitly depends on).
  • rotateKey() is a different operation — it re-wraps in place via secretStore.update, so the handle is stable and no orphan is produced. A sweep must not mistake a re-wrapped row for a retired one.
  • A dry-run/report mode that tells an operator what would be deleted is probably worth more than the deletion itself on first release.

Not this card

Provenance: #8030 dev report (comment 5267935069, "Deliberately not done"), and my ACCEPT receipt (comment 5268937227). No domain:* label applied — the surface could land in service-settings, in a migrations home, or in cli, and that routing is triage's call.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions