Skip to content

Rekey unseal keys too, and stop losing the key that unseals vault-unseal - #86

Merged
sethbergman merged 2 commits into
mainfrom
feat/shamir-rekey
Sep 8, 2026
Merged

sethbergman merged 2 commits into
mainfrom
feat/shamir-rekey

Conversation

@sethbergman

Copy link
Copy Markdown
Owner

rotate-keys.sh covered the recovery-key path only, because the local cluster is auto-unsealed and its shares are recovery keys. The unseal-key path had no coverage at all.

--unseal-keys runs the same ceremony against sys/rekey instead of sys/rekey-recovery-key, exercised against vault-unseal — the Shamir-sealed Vault underneath the profile. Which kind of key a Vault has depends only on how it's sealed, and migrate-seal.sh turns each into the other without changing their values, so this is one ceremony against two endpoints rather than two scripts.

Flag Endpoint CLI
--recovery-keys sys/rekey-recovery-key/* vault operator rekey -target=recovery
--unseal-keys sys/rekey/* vault operator rekey

The CLI calls the second one barrier and makes it the default target — so vault operator rekey with no arguments, run against an auto-unsealed cluster, addresses a set of keys that cluster does not use.

The prerequisite turned out to be a bug

Rekeying needs a quorum of the current shares, and vault-unseal had none. bootstrap-dev-cluster.sh held its unseal key and root token in shell variables and never wrote them down.

That made a single docker compose restart vault-unseal unrecoverable — which is what a Docker Desktop restart or a host reboot does. And it doesn't fail gracefully. vault-unseal comes back sealed, and a cluster node restarted afterwards doesn't come back sealed either; it fails to start:

error parsing Seal configuration: ... 503  * Vault is sealed

with no key anywhere to fix it. The only route back was make destroy — losing the cluster because the container providing its seal was restarted.

This is the same shape as the recovery keys the bootstrap used to discard, fixed in v0.15, in the one place where it costs more. docker/dev/.unseal-keys.json is written 0600 and gitignored now, and the suite restarts vault-unseal on every run to prove the kept keys open it and that a cluster node auto-unseals against it afterwards.

One stale share proves nothing

The suite rekeys vault-unseal twice — 1-of-1 to 5-of-3, then again — so a full quorum of the superseded generation exists to test with.

Vault accepts unseal shares and only validates the combination once the threshold is reached, so a single stale share returns success and 1/3 progress. An assertion built on one old share would pass whether or not the rekey did anything — and would leave that progress behind to break the next unseal. I know because I wrote exactly that assertion first and watched it lie to me.

Testing

tests/key-rotation goes from 28 assertions to 46. Three mutations, all watched to fail.

# Mutation Caught by
U1 vault-unseal's keys are not kept, as before this change the bootstrap kept vault-unseal's own keys; and the kept keys open it again (10 in total)
U2 --unseal-keys addresses the recovery endpoint the rekey failing outright (5)
U3 Unseal mode reads recovery_keys_b64 the rekey failing outright (5)

U3 is recorded for what it actually broke rather than what it was aimed at: it missed both assertions about handing the wrong key file to the wrong mode, because the run was refused anyway for a different reason and the refusal happened to satisfy them. The table records observations, not intentions.

Still uncovered

A rekey of a Shamir-sealed cluster — several nodes, each needing the new shares — as opposed to the single-node vault-unseal. And migration between two cloud KMS providers, which needs a cloud account.

🤖 Generated with Claude Code

sethbergman and others added 2 commits September 8, 2026 15:52
rotate-keys.sh covered the recovery-key path only, because the local
cluster is auto-unsealed and its shares are recovery keys. The
unseal-key path had no coverage at all. --unseal-keys runs the same
ceremony against sys/rekey rather than sys/rekey-recovery-key, and
tests/key-rotation exercises it against vault-unseal, which is the
Shamir-sealed Vault underneath the profile.

Which kind of key a Vault has depends only on how it is sealed, and
migrate-seal.sh turns each into the other without changing their values,
so this is one ceremony against two endpoints rather than two scripts.
The CLI calls the second one "barrier" and makes it the default target,
which is worth knowing: `vault operator rekey` with no arguments, run
against an auto-unsealed cluster, addresses a set of keys that cluster
does not use.

THE PREREQUISITE, WHICH TURNED OUT TO BE A BUG

Rekeying needs a quorum of the current shares, and vault-unseal had
none. bootstrap-dev-cluster.sh held its unseal key and root token in
shell variables and never wrote them down.

That made a single `docker compose restart vault-unseal` unrecoverable,
which is not hypothetical -- it is what a Docker Desktop restart or a
host reboot does. And it does not fail gracefully. vault-unseal comes
back sealed, and a cluster node restarted after that does not come back
sealed, it fails to start:

    error parsing Seal configuration: ... 503  * Vault is sealed

with no key anywhere to fix it. The only route back was `make destroy`,
which is to say: losing the cluster because the container providing its
seal was restarted. This is the same shape as the recovery keys the
bootstrap used to discard, fixed in v0.15, in the one place where it
costs more.

docker/dev/.unseal-keys.json is written 0600 and gitignored, and the
suite restarts vault-unseal on every run to prove the kept keys open it
and that a cluster node auto-unseals against it afterwards.

ONE STALE SHARE PROVES NOTHING

The suite rekeys vault-unseal twice -- 1-of-1 to 5-of-3, then again --
so that a full quorum of the superseded generation exists to test with.
Vault accepts unseal shares and only validates the combination once the
threshold is reached, so a single stale share returns success and 1/3
progress. An assertion built on one old share would pass whether or not
the rekey did anything, and would leave that progress behind to break
the next unseal. I found that by writing exactly that assertion first.

tests/key-rotation goes from 28 assertions to 46. Three mutations, all
watched to fail; the table records what each actually broke rather than
what it was aimed at, because U3 missed the two assertions it was
pointed at and was caught by others.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The security scan failed on the Shamir rekey change with two
generic-api-key findings, both false. Vault returns key shares under
unseal_keys_b64 or recovery_keys_b64 depending on how the Vault is
sealed, and rotate-keys.sh picks between them with a variable:

    KEY_FIELD="unseal_keys_b64"

which the rule reads as KEY = <high-entropy value>, entropy 3.5, for a
string that appears verbatim in Vault's own documentation.

There was no .gitleaks.toml at all until now -- the run logs "no gitleaks
config found in path .gitleaks.toml, using default gitleaks config" -- so
this adds one that keeps every default rule via useDefault and records
the single accepted finding, on the same terms .trivyignore.yaml sets: an
entry states what it permits and why.

The allowlist is anchored to those two exact strings rather than to the
file, the line or the rule. That distinction is the whole point, and it
is verified rather than asserted: with the config in place, a real Vault
service token and a high-entropy generic secret in the same file, on
adjacent lines, are both still reported. Only the field names are muted.

Reproduced locally against the same commit CI scanned, with the same
gitleaks version, before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sethbergman
sethbergman merged commit 1ea023c into main Sep 8, 2026
37 checks passed
@sethbergman
sethbergman deleted the feat/shamir-rekey branch September 8, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant