Skip to content

vault

vault

Vault passphrase CLI verbs — unlock / lock plus passphrase management.

The unlock/lock pair drives the volatile-cache slot of the SQLCipher passphrase resolution chain: unlock caches a passphrase in the kernel keyring; lock removes it. Everything else lives under vault passphrase:

  • vault passphrase seal promotes the current passphrase into a machine-bound systemd-creds credential.
  • vault passphrase to-keyring moves it from whichever tier holds it now into the OS keyring (the recommended upgrade path off the volatile kernel-keyring cache).
  • vault passphrase reveal resolves and prints the current passphrase (to /dev/tty by default, or stdout with --allow-redirect) and offers to mark the recovery key as saved.
  • vault passphrase acknowledge marks the current passphrase as saved without displaying it — the silent ack a TUI / CI captures.
  • vault passphrase change re-encrypts the DB under a new passphrase and rewrites every tier that stores the old one — change_passphrase is the prompt-free core the TUI shares. vault lock clears every stored copy of the passphrase — the kernel-keyring cache, the OS keyring, and the sealed systemd-creds credential — so the vault becomes irrecoverable without an off-host copy. The machine-bound tiers are an automatic-unlock convenience on top of a passphrase the operator is expected to have saved; locking peels them away. purge_passphrase_tiers is the prompt-free core lock and panic share.

Each container mounts its own short-lived VaultProxy that resolves the passphrase on demand. vault unlock / vault lock therefore only manage the passphrase tier; a supervisor that's already running keeps the passphrase it resolved at spawn, so picking up a changed tier means starting a fresh task (delete the matching one — per the no-state-preservation rule).

VAULT_COMMANDS = (CommandDef(name='vault', help='Vault passphrase management', children=(CommandDef(name='status', help='Show lock state, the passphrase resolution chain, and stored secrets', handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_status'), args=(ArgDef(name='--json', dest='as_json', action='store_true', help='Machine-readable JSON output'),)), CommandDef(name='unlock', help='Cache the credentials-DB passphrase for this session (kernel keyring)', handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_unlock'), args=(ArgDef(name='--force', action='store_true', help='Cache even if a durable tier already unlocks the vault'),)), CommandDef(name='lock', help="Clear every stored copy of the passphrase — you'll need it to unlock again", handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_lock'), args=(ArgDef(name='--force', action='store_true', help="Skip the 'have you saved the passphrase?' confirmation"),)), CommandDef(name='list', help='Inventory stored credentials (and optionally proxy tokens)', handler=LazyHandler('terok_sandbox.commands.vault:_handle_vault_list'), args=(ArgDef(name='--include-tokens', action='store_true', help='Also show proxy-token rows (token values are masked)'), ArgDef(name='--json', dest='as_json', action='store_true', help='Machine-readable JSON output'))), _PASSPHRASE_GROUP)),) module-attribute

VAULT = VAULT_COMMANDS[0] module-attribute

__all__ = ['VAULT', 'VAULT_COMMANDS'] module-attribute

SessionProvisionResult(written, validated=False, shadowed_durable=None) dataclass

Outcome of provision_session_passphrase.

written is the load-bearing bit: False means the write was refused because a durable tier already resolves the vault, so caching the passphrase would be pointless (shadowed_durable names that tier). Refusal is a normal outcome, not an error — callers report it ("already unlocked via X") rather than raising. validated says whether the written value was test-opened against an existing DB (vs. an empty install where it becomes the key on first use).

written instance-attribute

validated = False class-attribute instance-attribute

shadowed_durable = None class-attribute instance-attribute

TierRewrite(tier, ok, detail) dataclass

What happened to one passphrase-holding tier during a change.

ok means the tier now holds the new passphrase. A failed rewrite (ok=False) is reported, never raised — by the time the fan-out runs the DB is already rekeyed, so aborting would only hide which tiers still need the operator's attention.

tier instance-attribute

ok instance-attribute

detail instance-attribute

PassphraseChangeResult(passphrase, generated, rekeyed, rewrites) dataclass

Outcome of change_passphrase.

generated carries the same follow-up duty as TierProvisionResult: a minted value must be revealed to the operator. The recovery marker has been dropped either way — whoever renders this result owns re-running the acknowledgement flow.

passphrase instance-attribute

The new passphrase — mint or caller-supplied.

generated instance-attribute

True iff this call minted the value (caller passed None).

rekeyed instance-attribute

True iff an existing DB was re-encrypted (False on a fresh install where the new value simply becomes the key on first use).

rewrites instance-attribute

Per-tier outcomes, in resolution-chain order.

problems property

The rewrites that failed — non-empty means the operator has cleanup to do.

provision_session_passphrase(cfg, passphrase, *, force=False)

Validate passphrase against the DB, then cache it in the kernel keyring.

The single writer of the volatile kernel-keyring unlock cache — the CLI vault unlock and terok's TUI unlock modal both funnel through here, so the no-cache and validation guards apply to every caller by construction; neither can store a value the DB rejects, nor cache one redundantly on top of a working durable tier.

Two guards, in order:

  1. No-cache. The cache only earns its keep when no durable tier already unlocks the vault non-interactively. When a durable tier (systemd-creds / keyring) resolves and force is false, nothing is written and the result reports written=False + the durable tier. force (re-key / deliberate override) skips this guard.
  2. Validation. When the DB exists (and isn't a legacy plaintext file) the value is test-opened first; a mismatch raises WrongPassphraseError and nothing is written. A missing DB skips validation (opening it just to check would create it as a side effect) — the value becomes its key on first use.

Raises RuntimeError if the kernel keyring is unavailable on this host (no libkeyutils, CONFIG_KEYS off) — a genuine "can't cache here", distinct from the written=False no-cache refusal.

Source code in src/terok_sandbox/commands/vault.py
def provision_session_passphrase(
    cfg: SandboxConfig, passphrase: str, *, force: bool = False
) -> SessionProvisionResult:
    """Validate *passphrase* against the DB, then cache it in the kernel keyring.

    The single writer of the volatile kernel-keyring unlock cache — the
    CLI ``vault unlock`` and terok's TUI unlock modal both funnel through
    here, so the no-cache and validation guards apply to every caller by
    construction; neither can store a value the DB rejects, nor cache one
    redundantly on top of a working durable tier.

    Two guards, in order:

    1. **No-cache.** The cache only earns its keep when no durable tier
       already unlocks the vault non-interactively.  When a durable tier
       (systemd-creds / keyring) resolves and *force* is false, nothing
       is written and the result reports ``written=False`` + the durable
       tier.  *force* (re-key / deliberate override) skips this guard.
    2. **Validation.** When the DB exists (and isn't a legacy plaintext
       file) the value is test-opened first; a mismatch raises
       [`WrongPassphraseError`][terok_sandbox.WrongPassphraseError] and
       **nothing is written**.  A missing DB skips validation (opening it
       just to check would create it as a side effect) — the value
       becomes its key on first use.

    Raises [`RuntimeError`][RuntimeError] if the kernel keyring is
    unavailable on this host (no ``libkeyutils``, ``CONFIG_KEYS`` off) —
    a genuine "can't cache here", distinct from the ``written=False``
    no-cache refusal.
    """
    from ..vault.store import session_cache
    from ..vault.store.db import CredentialDB
    from ..vault.store.encryption import is_plaintext_sqlite
    from ..vault.store.status import active_durable_source

    if not force:
        shadowed = active_durable_source(cfg)
        if shadowed is not None:
            return SessionProvisionResult(written=False, shadowed_durable=shadowed)

    validated = False
    if cfg.db_path.exists() and not is_plaintext_sqlite(cfg.db_path):
        # Raises WrongPassphraseError / PlaintextDBFoundError on mismatch —
        # deliberately before the write so a bad value never lands.
        CredentialDB(cfg.db_path, passphrase=passphrase).close()
        validated = True
    if not session_cache.store(passphrase, cfg.db_path):
        raise RuntimeError(
            "the session cache is unavailable here"
            f" ({session_cache.unavailable_reason() or 'store failed'});"
            " seal a durable tier instead (vault passphrase seal / to-keyring)"
        )
    return SessionProvisionResult(written=True, validated=validated)

purge_passphrase_tiers(cfg)

Remove every stored copy of the credentials-DB passphrase.

Clears the kernel-keyring cache, the OS keyring entry, the sealed systemd-creds credential, and the credentials.passphrase_command wiring in config.yml — then drops the recovery-acknowledged marker, since it's meaningless once no tier remains. After this the vault can only be reopened by re-supplying the passphrase (vault unlock); it is unrecoverable without an off-host copy.

No prompts and no acknowledgement check: this is the raw destructive action. The lock verb gates it behind a typed-SAVED confirmation when recovery is unacknowledged; panic calls it directly — no questions asked.

Source code in src/terok_sandbox/commands/vault.py
def purge_passphrase_tiers(cfg: SandboxConfig) -> None:
    """Remove every stored copy of the credentials-DB passphrase.

    Clears the kernel-keyring cache, the OS keyring entry, the
    sealed systemd-creds credential, and the
    ``credentials.passphrase_command`` wiring in ``config.yml`` — then
    drops the recovery-acknowledged marker, since it's meaningless once
    no tier remains.  After this the
    vault can only be reopened by re-supplying the passphrase
    (``vault unlock``); it is **unrecoverable** without an off-host copy.

    No prompts and no acknowledgement check: this is the raw destructive
    action.  The ``lock`` verb gates it behind a typed-``SAVED``
    confirmation when recovery is unacknowledged; panic calls it
    directly — no questions asked.
    """
    from ..vault.store import session_cache
    from ..vault.store.encryption import (
        forget_passphrase_in_keyring,
    )

    # Call forget() directly and branch on its result: it distinguishes
    # cleared/absent (True) from a lookup or unlink failure (False), whereas
    # load() reports None for both an absent key and a failed lookup — which
    # would let a live cache survive the purge unnoticed.
    if session_cache.forget(cfg.db_path):
        print("→ cleared session cache")
    else:
        raise SystemExit(
            "failed to clear the session cache; future processes may still auto-unlock from it"
        )

    if cfg.credentials_use_keyring:
        if (keyring_reason := forget_passphrase_in_keyring()) is None:
            print("→ keyring entry cleared or absent")
        else:
            raise SystemExit(
                f"failed to clear the keyring entry ({keyring_reason});"
                " future supervisors may still auto-unlock from keyring —"
                " resolve it and run `vault lock` again"
            )

    config_updates = _forget_config_tier_updates(cfg)
    if config_updates:
        from .. import config as _config
        from .._yaml import update_section as _yaml_update_section
        from ..paths import config_file_paths

        user_config = next((p for label, p in config_file_paths() if label == "user"), None)
        if user_config is not None and user_config.exists():
            _yaml_update_section(user_config, "credentials", config_updates)
            _config._credentials_section.cache_clear()
            for key in config_updates:
                print(f"→ cleared credentials.{key} from config.yml")

    sealed_cred = cfg.vault_systemd_creds_file
    if sealed_cred.exists():
        try:
            sealed_cred.unlink()
        except OSError as exc:
            raise SystemExit(f"failed to remove sealed credential at {sealed_cred}: {exc}") from exc
        print(f"→ removed sealed credential at {sealed_cred}")

    from ..vault.store.recovery import forget as forget_recovery_marker

    forget_recovery_marker(cfg.vault_recovery_marker_file)

handle_vault_seal(*, cfg=None, key='auto')

Seal the credentials-DB passphrase into a systemd-creds credential.

Adds the systemd-creds tier to the resolution chain: machine-bound (TPM2 + host key, or either alone), survives reboot, no OS keyring required. After sealing, every new supervisor resolves the passphrase via systemd-creds decrypt on start — no operator interaction needed at boot, no plaintext-on-disk.

Requires an already-resolvable passphrase — typically from a fresh vault unlock in the current session.

Source code in src/terok_sandbox/commands/vault.py
def handle_vault_seal(*, cfg: SandboxConfig | None = None, key: str = "auto") -> None:
    """Seal the credentials-DB passphrase into a systemd-creds credential.

    Adds the systemd-creds tier to the resolution chain: machine-bound
    (TPM2 + host key, or either alone), survives reboot, no OS
    keyring required.  After sealing, every new supervisor resolves the
    passphrase via ``systemd-creds decrypt`` on start — no operator
    interaction needed at boot, no plaintext-on-disk.

    Requires an already-resolvable passphrase — typically from a fresh
    ``vault unlock`` in the current session.
    """
    from ..vault.store import systemd_creds
    from ..vault.store.encryption import WrongPassphraseError

    cfg = _resolve_cfg(cfg)

    if not systemd_creds.is_available():
        raise SystemExit(
            "systemd-creds unavailable: needs systemd ≥ 257 with the Varlink"
            " io.systemd.Credentials interface (Fedora ≥ 42, Debian ≥ 13)"
        )

    key_mode = _SEAL_KEY_MODES.get(key)
    if key_mode is None:
        choices = ", ".join(sorted(_SEAL_KEY_MODES))
        raise SystemExit(f"unknown --key value: {key!r} (expected one of: {choices})")

    # A prompt here would accept a freshly-typed value and seal *that*,
    # leaving the next chain walk holding a key that doesn't open the DB.
    try:
        passphrase = cfg.resolve_passphrase()
    except WrongPassphraseError as exc:
        raise SystemExit(f"cannot seal: {exc}") from exc
    if passphrase is None:
        raise SystemExit("no current passphrase to seal — run `terok-sandbox vault unlock` first")
    _require_recovery_acknowledged(cfg, tier="systemd-creds")

    try:
        systemd_creds.seal(passphrase, cfg.vault_systemd_creds_file, key_mode=key_mode)
    except RuntimeError as exc:
        # ``tpm2`` requested on a TPM-less host surfaces as a CalledProcessError
        # bubbled to RuntimeError — pass it through with the hint attached.
        raise SystemExit(str(exc)) from exc

    print(f"→ sealed passphrase to {cfg.vault_systemd_creds_file} (--with-key={key_mode})")

    # The passphrase now lives in the durable sealed credential, which
    # outranks the volatile kernel-keyring cache in the chain — so the
    # cache is superfluous residue.  Drop it so the chain resolves from
    # the tier the operator just established (same cleanup ``to-keyring``
    # does).
    from ..vault.store import session_cache

    if not session_cache.forget(cfg.db_path):
        print(
            "⚠ could not clear the now-redundant session cache;"
            " it may still auto-unlock the vault — run `vault lock` to clear it"
        )

    print(
        "  the resolution chain will pick this up the next time a supervisor"
        " starts; no restart required"
    )

handle_vault_to_keyring(*, cfg=None)

Move the current passphrase from its current tier into the OS keyring.

Resolves the passphrase via the chain (or prompts as a last resort), writes it to the keyring, flips credentials.use_keyring to true in config.yml, clears any plaintext credentials.passphrase / credentials.passphrase_command wiring, and removes the kernel-keyring cache and sealed systemd-creds copies.

The validate-before-destroy ordering is deliberate: if the keyring write fails, the source tier is still intact.

Source code in src/terok_sandbox/commands/vault.py
def handle_vault_to_keyring(*, cfg: SandboxConfig | None = None) -> None:
    """Move the current passphrase from its current tier into the OS keyring.

    Resolves the passphrase via the chain (or prompts as a last resort),
    writes it to the keyring, flips ``credentials.use_keyring`` to true
    in ``config.yml``, clears any plaintext ``credentials.passphrase`` /
    ``credentials.passphrase_command`` wiring, and removes the
    kernel-keyring cache and sealed systemd-creds copies.

    The validate-before-destroy ordering is deliberate: if the keyring
    write fails, the source tier is still intact.
    """
    from .. import config as _config
    from .._yaml import update_section as _yaml_update_section
    from ..vault.store.encryption import (
        WrongPassphraseError,
        store_passphrase_in_keyring,
    )

    cfg = _resolve_cfg(cfg)

    try:
        passphrase, source = cfg.resolve_passphrase_with_source(prompt_on_tty=True)
    except WrongPassphraseError as exc:
        raise SystemExit(f"cannot move to keyring: {exc}") from exc

    if not passphrase:
        raise SystemExit("no current passphrase resolvable; run `terok-sandbox vault unlock` first")
    if source == "keyring":
        print("→ passphrase is already in the keyring; nothing to do")
        return
    _require_recovery_acknowledged(cfg, tier="keyring")

    if not store_passphrase_in_keyring(passphrase):
        raise SystemExit("OS keyring is unreachable or denied; aborting (nothing was changed)")
    print(f"→ stored passphrase in keyring (was: {source})")

    # Switch the config's tier wiring atomically: flip use_keyring on,
    # drop the plaintext + helper fallbacks so the chain can't re-resolve
    # via a stale lower tier.
    from ..paths import config_file_paths

    user_config = next((p for label, p in config_file_paths() if label == "user"), None)
    if user_config is not None:
        # nosec: B105 — clearing config keys to None, not hardcoding secrets
        updates = {  # nosec: B105
            "use_keyring": True,
            "passphrase": None,  # nosec: B105
            "passphrase_command": None,  # nosec: B105
        }
        _yaml_update_section(user_config, "credentials", updates)
        _config._credentials_section.cache_clear()
        print(f"→ updated {user_config} (use_keyring: true, plaintext fields cleared)")

    # Remove the old tier's copies.  Sealed systemd-creds outranks
    # keyring on the resolution order, so it must go; the volatile
    # kernel-keyring cache is cleared too so nothing stale lingers.
    from ..vault.store import session_cache

    if not session_cache.forget(cfg.db_path):
        print(
            "⚠ could not clear the kernel-keyring cache;"
            " it may still auto-unlock the vault — run `vault lock` to clear it"
        )
    if cfg.vault_systemd_creds_file.exists():
        cfg.vault_systemd_creds_file.unlink()
        print(f"→ removed {sanitize_tty(str(cfg.vault_systemd_creds_file))}")

change_passphrase(cfg=None, *, old=None, new=None)

Re-encrypt the vault under a new passphrase and rewrite every tier holding the old one.

The prompt-free core shared by the vault passphrase change CLI verb and the TUI — same contract shape as provision_passphrase_tier: no /dev/tty, no ack prompt; the caller owns the conversation.

old is only consulted when the resolution chain can't produce the current passphrase (locked vault) — when a caller supplies it explicitly it wins over the chain, so a stale tier value can't override an operator who knows better. new None mints a fresh generate_passphrase value (generated=True in the result — reveal it!).

The ordering is the safety argument:

  1. Escrow the new value first (vault_pending_passphrase_file, owner-only, RAM-backed): from this point a crash can never leave the DB encrypted under a key that exists nowhere on the host.
  2. Verify + rekey the DB (rekey_in_place). Every failure here — wrong old passphrase, a live supervisor holding the WAL ("database is locked") — aborts with nothing changed anywhere (the escrow is removed on the way out).
  3. Only then fan the new value out to every tier that currently holds material, in resolution order, collecting per-tier outcomes instead of raising: a tier that can't take the new value (keyring denied, systemd-creds host regressed) is purged where possible so no tier keeps resolving the old passphrase, and reported either way. Once at least one tier holds the new value the escrow is deleted; if every rewrite failed it stays, so the key remains recoverable.
  4. Drop the recovery-acknowledged marker — the saved copy the operator confirmed is now the wrong passphrase.

Refuses up front (RuntimeError) while passphrase_command is configured: that tier's secret lives in a store the operator owns, so the sandbox rewriting everything else would leave the helper resolving a stale value that fails closed on the next boot. Update the external store first, or remove the wiring.

Raises NoPassphraseError when neither the chain nor old yields the current passphrase, WrongPassphraseError when that value doesn't open the DB, and ValueError for an empty or unchanged new.

Source code in src/terok_sandbox/commands/vault.py
def change_passphrase(
    cfg: SandboxConfig | None = None,
    *,
    old: str | None = None,
    new: str | None = None,
) -> PassphraseChangeResult:
    """Re-encrypt the vault under a new passphrase and rewrite every tier holding the old one.

    The prompt-free core shared by the ``vault passphrase change`` CLI
    verb and the TUI — same contract shape as
    [`provision_passphrase_tier`][terok_sandbox.commands.credentials.provision_passphrase_tier]:
    no ``/dev/tty``, no ack prompt; the caller owns the conversation.

    *old* is only consulted when the resolution chain can't produce the
    current passphrase (locked vault) — when a caller supplies it
    explicitly it wins over the chain, so a stale tier value can't
    override an operator who knows better.  *new* ``None`` mints a
    fresh [`generate_passphrase`][terok_sandbox.vault.store.encryption.generate_passphrase]
    value (``generated=True`` in the result — reveal it!).

    The ordering is the safety argument:

    1. Escrow the new value first
       ([`vault_pending_passphrase_file`][terok_sandbox.SandboxConfig.vault_pending_passphrase_file],
       owner-only, RAM-backed): from this point a crash can never leave
       the DB encrypted under a key that exists nowhere on the host.
    2. Verify + rekey the DB ([`rekey_in_place`][terok_sandbox.vault.store.encryption.rekey_in_place]).
       Every failure here — wrong old passphrase, a live supervisor
       holding the WAL ("database is locked") — aborts with **nothing
       changed anywhere** (the escrow is removed on the way out).
    3. Only then fan the new value out to every tier that currently
       holds material, in resolution order, collecting per-tier
       outcomes instead of raising: a tier that can't take the new
       value (keyring denied, systemd-creds host regressed) is purged
       where possible so no tier keeps resolving the *old* passphrase,
       and reported either way.  Once at least one tier holds the new
       value the escrow is deleted; if every rewrite failed it stays,
       so the key remains recoverable.
    4. Drop the recovery-acknowledged marker — the saved copy the
       operator confirmed is now the wrong passphrase.

    Refuses up front (``RuntimeError``) while ``passphrase_command`` is
    configured: that tier's secret lives in a store the operator owns,
    so the sandbox rewriting everything else would leave the helper
    resolving a stale value that fails closed on the next boot.  Update
    the external store first, or remove the wiring.

    Raises [`NoPassphraseError`][terok_sandbox.NoPassphraseError] when
    neither the chain nor *old* yields the current passphrase,
    [`WrongPassphraseError`][terok_sandbox.WrongPassphraseError] when
    that value doesn't open the DB, and [`ValueError`][ValueError] for
    an empty or unchanged *new*.
    """
    from ..vault.store.encryption import (
        NoPassphraseError,
        generate_passphrase,
        is_plaintext_sqlite,
        probe_passphrase_chain,
        rekey_in_place,
    )
    from ..vault.store.recovery import forget as forget_recovery_marker

    cfg = _resolve_cfg(cfg)

    if cfg.credentials_passphrase_command:
        raise RuntimeError(
            "credentials.passphrase_command is configured — the passphrase lives in"
            " an external secret store terok cannot write to.  Update the secret"
            " there first (or remove passphrase_command from config.yml), then"
            " re-run the change."
        )
    if new == "":  # nosec: B105 — rejecting the empty sentinel, not comparing a secret
        raise ValueError("refusing an empty passphrase (SQLCipher reads it as no encryption)")
    if cfg.db_path.exists() and is_plaintext_sqlite(cfg.db_path):
        raise RuntimeError(
            f"{cfg.db_path} is still the legacy plaintext format — run the"
            " encrypt-db migration before changing the passphrase"
        )

    # Explicit *old* wins; the chain fills in for the common unlocked case.
    current = old or cfg.resolve_passphrase()  # raises WrongPassphraseError on a broken tier
    db_exists = cfg.db_path.exists()
    if current is None:
        if db_exists:
            raise NoPassphraseError(
                "the vault is locked — supply the current passphrase to change it"
            )
        raise NoPassphraseError(
            "no credentials DB and no stored passphrase — provision the vault"
            " (setup) instead of changing it"
        )

    generated = new is None
    if new is None:
        new = generate_passphrase()
    if new == current:
        raise ValueError("the new passphrase is identical to the current one")

    from .._yaml import write_secret_text

    if db_exists:
        # Crash-recovery escrow: the rekeyed DB's key must exist on disk
        # *before* the DB is rekeyed — a crash between the rekey and the
        # tier fan-out would otherwise strand the vault under a key
        # nobody has seen (fatal for a freshly minted value).
        write_secret_text(cfg.vault_pending_passphrase_file, new + "\n")
        try:
            rekey_in_place(cfg.db_path, current, new)
        except BaseException:
            # Nothing was modified — don't leave escrow debris behind.
            cfg.vault_pending_passphrase_file.unlink(missing_ok=True)
            raise

    present = [
        row.source
        for row in probe_passphrase_chain(
            credentials_db=cfg.db_path,
            systemd_creds_file=cfg.vault_systemd_creds_file,
            use_keyring=cfg.credentials_use_keyring,
            passphrase_command=cfg.credentials_passphrase_command,
        )
        if row.present
    ]
    if not present:
        # Locked vault changed via an explicitly-supplied *old*: nothing
        # holds material yet, so land the new value where `vault unlock`
        # would — otherwise the change succeeds and nobody can open the DB.
        present = [PassphraseTier.KERNEL_KEYRING]
    rewrites = tuple(_rewrite_tier(cfg, tier, new) for tier in present)

    # The confirmed-saved copy (if any) is now the wrong passphrase —
    # the marker doesn't auto-invalidate (deliberately fingerprint-free,
    # see vault.store.recovery), so the change flow must drop it.
    forget_recovery_marker(cfg.vault_recovery_marker_file)

    if any(rewrite.ok for rewrite in rewrites):
        # At least one tier now holds the new value — the escrow has
        # done its job.  When every rewrite failed it stays behind as
        # the only on-host copy of the key the DB is now encrypted with.
        cfg.vault_pending_passphrase_file.unlink(missing_ok=True)

    # Stamp the rekey so health surfaces can flag supervisors spawned
    # before it — they keep the passphrase they resolved at spawn.
    write_secret_text(cfg.vault_rekey_stamp_file, "")

    return PassphraseChangeResult(
        passphrase=new, generated=generated, rekeyed=db_exists, rewrites=rewrites
    )