Skip to content

vault

vault

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

The unlock/lock pair drives the session-tier slot of the SQLCipher passphrase resolution chain: unlock lands a passphrase on the session-unlock tmpfs file; 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 session-file / plaintext-config tiers).
  • 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 lock clears every stored copy of the passphrase — session file, keyring, sealed systemd-creds credential, and plaintext config — 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=_handle_vault_status, args=(ArgDef(name='--json', dest='as_json', action='store_true', help='Machine-readable JSON output'),)), CommandDef(name='unlock', help='Provision the credentials-DB passphrase for this session (tmpfs file)', handler=_handle_vault_unlock, args=(ArgDef(name='--force', action='store_true', help='Write the session file 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=_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=_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

__all__ = ['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 a session file would only shadow it (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

SessionShadow(durable_source, redundant) dataclass

A session-file tier sitting on top of a durable tier.

redundant is the actionable bit:

  • True — the session copy is byte-identical to what the durable tier resolves to, so it's pure residue (a past unlock on a box that already auto-unlocks). Safe to delete; nothing is lost.
  • False — the two differ. Either a deliberate re-key in progress or a stale unlock masking the durable value; never auto-removed.
  • None — the durable tier is present but couldn't be read to compare (broken seal, dead helper), so the session file may be doing real work. Left alone.

durable_source instance-attribute

redundant instance-attribute

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

Validate passphrase against the DB, then land it on the session tier.

The single writer of the session-unlock tmpfs file — the CLI vault unlock and terok's TUI unlock modal both funnel through here, so the no-shadow and validation guards apply to every caller by construction; neither can store a value the DB rejects, nor silently shadow a working durable tier.

Two guards, in order:

  1. No-shadow. The session tier is the highest-priority slot, so writing it masks any durable tier (systemd-creds / keyring / config) underneath — a reboot then wipes the session copy and the operator only thought the sealed key was in use. When a durable tier already resolves and force is false, nothing is written and the result reports written=False + the shadowed 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.
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 land it on the session tier.

    The single writer of the session-unlock tmpfs file — the CLI
    ``vault unlock`` and terok's TUI unlock modal both funnel through
    here, so the no-shadow and validation guards apply to every caller
    by construction; neither can store a value the DB rejects, nor
    silently shadow a working durable tier.

    Two guards, in order:

    1. **No-shadow.** The session tier is the highest-priority slot, so
       writing it masks any durable tier (systemd-creds / keyring /
       config) underneath — a reboot then wipes the session copy and the
       operator only *thought* the sealed key was in use.  When a durable
       tier already resolves and *force* is false, nothing is written and
       the result reports ``written=False`` + the shadowed 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.
    """
    from .._yaml import write_secret_text
    from ..vault.store.db import CredentialDB
    from ..vault.store.encryption import is_plaintext_sqlite

    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
    write_secret_text(cfg.vault_passphrase_file, passphrase + "\n")
    return SessionProvisionResult(written=True, validated=validated)

session_shadow_state(cfg)

Describe a session-file-over-durable-tier shadow, or None if there is none.

Returns None in the common cases — no session file, or no durable tier beneath it — without resolving anything. Only when both are present does it resolve the durable chain (omitting the session file) to compare values; that one comparison is the only place status / remediation pay an unseal. The session secret never leaves the process — this reads two tiers and compares, exactly what the resolver already does internally.

Source code in src/terok_sandbox/commands/vault.py
def session_shadow_state(cfg: SandboxConfig) -> SessionShadow | None:
    """Describe a session-file-over-durable-tier shadow, or ``None`` if there is none.

    Returns ``None`` in the common cases — no session file, or no durable
    tier beneath it — without resolving anything.  Only when both are
    present does it resolve the *durable* chain (omitting the session
    file) to compare values; that one comparison is the only place
    status / remediation pay an unseal.  The session secret never leaves
    the process — this reads two tiers and compares, exactly what the
    resolver already does internally.
    """
    from ..vault.store.encryption import (
        NoPassphraseError,
        WrongPassphraseError,
        load_passphrase_from_file,
        resolve_passphrase_with_source,
    )

    session_value = load_passphrase_from_file(cfg.vault_passphrase_file)
    if not session_value:
        return None
    durable_source = _active_durable_source(cfg)
    if durable_source is None:
        return None
    try:
        durable_value, _ = resolve_passphrase_with_source(
            systemd_creds_file=cfg.vault_systemd_creds_file,
            use_keyring=cfg.credentials_use_keyring,
            passphrase_command=cfg.credentials_passphrase_command,
            config_fallback=cfg.credentials_passphrase,
            # ``passphrase_file`` omitted on purpose — resolve the durable
            # chain *under* the session file so we can compare against it.
        )
    except (WrongPassphraseError, NoPassphraseError):
        durable_value = None
    if not durable_value:
        return SessionShadow(durable_source, redundant=None)
    return SessionShadow(durable_source, redundant=(durable_value == session_value))

clear_redundant_session_file(cfg)

Remove the session-unlock file iff it merely duplicates a durable tier.

Same-key residue only: a session file whose passphrase differs from the durable tier is a deliberate override (or a re-key mid-flight) and is kept. Re-verifies state at call time rather than trusting a caller's stale read. Returns the durable tier it deduplicated against, or None when there was nothing safe to remove.

Source code in src/terok_sandbox/commands/vault.py
def clear_redundant_session_file(cfg: SandboxConfig) -> str | None:
    """Remove the session-unlock file iff it merely duplicates a durable tier.

    Same-key residue only: a session file whose passphrase differs from
    the durable tier is a deliberate override (or a re-key mid-flight)
    and is kept.  Re-verifies state at call time rather than trusting a
    caller's stale read.  Returns the durable tier it deduplicated
    against, or ``None`` when there was nothing safe to remove.
    """
    shadow = session_shadow_state(cfg)
    if shadow is None or shadow.redundant is not True:
        return None
    cfg.vault_passphrase_file.unlink(missing_ok=True)
    return shadow.durable_source

purge_passphrase_tiers(cfg)

Remove every stored copy of the credentials-DB passphrase.

Clears the session-unlock tmpfs file, the OS keyring entry, the sealed systemd-creds credential, and the plaintext credentials.passphrase / 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 session-unlock tmpfs file, the OS keyring entry, the
    sealed systemd-creds credential, and the plaintext
    ``credentials.passphrase`` / ``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.encryption import forget_passphrase_in_keyring, load_passphrase_from_keyring

    path = cfg.vault_passphrase_file
    if path.exists():
        path.unlink()
        print(f"→ removed {path}")

    if cfg.credentials_use_keyring:
        if forget_passphrase_in_keyring():
            print("→ cleared keyring entry")
        elif load_passphrase_from_keyring() is None:
            # ``keyring.delete_password`` raises on a missing entry on most
            # backends, which the helper folds to False — a residual entry
            # after that means the backend rejected the delete.
            print("→ keyring entry already absent")
        else:
            raise SystemExit(
                "failed to clear keyring entry;"
                " future supervisors may still auto-unlock from keyring"
            )

    config_updates = _forget_config_tier_updates(cfg)
    if config_updates:
        from .. import config as _config
        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

    if cfg is None:
        cfg = SandboxConfig()

    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, so the
    # session file is redundant — and, being higher priority, it would
    # *shadow* the seal until the next reboot wiped it.  Drop it so the
    # chain resolves from the tier the operator just established (same
    # cleanup ``to-keyring`` does).
    if cfg.vault_passphrase_file.exists():
        cfg.vault_passphrase_file.unlink()
        print(f"→ removed now-redundant session file {cfg.vault_passphrase_file}")

    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 session-file 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
    session-file 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 ..vault.store.encryption import (
        WrongPassphraseError,
        store_passphrase_in_keyring,
    )

    if cfg is None:
        cfg = SandboxConfig()

    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 persistent copy.  Session file is removed
    # because the chain prefers it over keyring; sealed systemd-creds
    # likewise outranks keyring on the resolution order.
    for stale in (cfg.vault_passphrase_file, cfg.vault_systemd_creds_file):
        if stale.exists():
            stale.unlink()
            print(f"→ removed {sanitize_tty(str(stale))}")