encryption
encryption
¶
Passphrase plumbing and SQLCipher helpers for at-rest credential encryption.
Walks the five-tier resolution chain — systemd-creds → OS keyring →
kernel keyring → passphrase_command helper → interactive prompt —
and exposes the SQLCipher open / rekey / migrate primitives the rest
of the package builds on. The tier vocabulary lives in
tiers; resolve_passphrase
documents the chain order; open_sqlcipher is the only entry point
that ever calls sqlcipher3.connect.
The setup-time plaintext→SQLCipher migration (deprecated in 0.8.0, removed in 0.9.0) lives at the bottom of the file; nothing in the runtime chain touches it.
KEYRING_SERVICE = 'terok-sandbox'
module-attribute
¶
KEYRING_USERNAME = 'credentials-db'
module-attribute
¶
__all__ = ['KEYRING_SERVICE', 'KEYRING_USERNAME', 'NoPassphraseError', 'PassphraseTier', 'WrongPassphraseError', 'encrypt_in_place', 'forget_passphrase_in_keyring', 'generate_passphrase', 'is_plaintext_sqlite', 'keyring_backend_available', 'load_passphrase_from_command', 'load_passphrase_from_keyring', 'open_sqlcipher', 'open_sqlcipher_via_chain', 'prompt_new_passphrase', 'prompt_passphrase', 'rekey_in_place', 'resolve_passphrase', 'resolve_passphrase_with_source', 'store_passphrase_in_keyring']
module-attribute
¶
PassphraseTier
¶
Bases: StrEnum
Where the vault passphrase can live, in resolution-chain priority order.
A StrEnum so members compare, hash, and serialise as their
plain string values — status JSON, config knobs, and CLI arguments
all speak the same vocabulary without conversion shims.
SYSTEMD_CREDS = 'systemd-creds'
class-attribute
instance-attribute
¶
Sealed machine-bound credential (TPM2 / host key); needs systemd ≥ 257.
KEYRING = 'keyring'
class-attribute
instance-attribute
¶
OS keyring entry, unlocked together with the login session.
KERNEL_KEYRING = 'kernel-keyring'
class-attribute
instance-attribute
¶
Linux kernel keyring (@u) — a volatile unlock cache in
unswappable kernel memory, uid-scoped, cleared on logout. Sits
below the zero-friction durable tiers but above the
passphrase-command helper: it never shadows a tier that already
unlocks non-interactively, yet still spares the operator from
re-running a helper or re-typing a prompt on every open.
PASSPHRASE_COMMAND = 'passphrase-command'
class-attribute
instance-attribute
¶
Operator-supplied helper command that prints the passphrase
(pass show …, bw get …, op read …, cloud secret CLIs).
PROMPT = 'prompt'
class-attribute
instance-attribute
¶
Interactive TTY entry — stores nothing, ever.
durable
property
¶
True iff the tier survives a reboot.
A volatile tier resolving on top of a durable one is shadowing it — the vault silently reads the copy that dies on the next boot.
provisionable
property
¶
True iff a value can be written into the tier programmatically.
passphrase-command is the counter-example: the secret lives
in a store the operator owns (pass / bitwarden / a cloud
secret manager), so the sandbox can read it but never write it.
prompt stores nothing at all.
chooser_offered
property
¶
True iff the interactive setup chooser lists the tier.
systemd-creds is deliberately not offered — it auto-selects
whenever the host supports it, so listing it would only add a
dead option to the menu.
NoPassphraseError
¶
Bases: RuntimeError
No SQLCipher passphrase resolved — the DB cannot be opened.
WrongPassphraseError
¶
Bases: RuntimeError
SQLCipher could not decrypt the DB — passphrase doesn't match its encryption key.
TierPresence(source, present, detail)
dataclass
¶
Whether one passphrase-chain tier currently holds material — for vault status.
A diagnostic, non-short-circuiting counterpart to
resolve_passphrase_with_source:
that walker stops at the first tier that resolves, so it can only
ever name the winner. vault status needs the whole chain to
show every tier that currently holds material, not just the one that
would unlock the vault.
open_sqlcipher_via_chain(db_path, *, systemd_creds_file=None, use_keyring=False, passphrase_command=None, prompt_on_tty=False, **connect_kwargs)
¶
Resolve the passphrase through the runtime chain and open db_path.
Raises NoPassphraseError
when the chain yields nothing. prompt_on_tty turns on the
interactive fallback for CLI consumers; daemons leave it False.
Source code in src/terok_sandbox/vault/store/encryption.py
resolve_passphrase_with_source(*, credentials_db, systemd_creds_file=None, use_keyring=False, passphrase_command=None, prompt_on_tty=False)
¶
Walk the runtime resolution chain; return (passphrase, source).
Single source of truth for the resolution order — see
resolve_passphrase
for the tier semantics. Both elements of the tuple are None
when no tier had a passphrase.
credentials_db is the vault the passphrase is for: it scopes the
kernel-keyring lookup to that DB's key (see
kernel_keyring.key_description),
so the cache for one vault never resolves another's — pass the same
path the caller is about to open.
The source half feeds a TUI/CLI status display — keep the labels stable, callers dispatch on them.
Source code in src/terok_sandbox/vault/store/encryption.py
resolve_passphrase(*, credentials_db, systemd_creds_file=None, use_keyring=False, passphrase_command=None, prompt_on_tty=False)
¶
Walk the runtime resolution chain; return None if nothing has it.
Order:
- systemd_creds_file — sealed credential decrypted via
systemd-creds(1). Machine-bound (TPM2 or host key), survives reboot, no OS keyring required. Seeterok_sandbox.vault.store.systemd_creds. - OS keyring — only when use_keyring is true; off by default because Linux Secret Service grants access per-collection, not per-item.
- Session cache — the volatile unlock cache
(
terok_sandbox.vault.store.session_cache): the kernel keyring, or a tmpfs session file where the kernel facility is unusable. Consulted unconditionally (an absent/expired/unavailable cache just yieldsNone); positioned so it never shadows a zero-friction durable tier above but still spares re-running the helper below. - passphrase_command — operator-supplied shell command
(
pass show …,bw get,op read, cloud secret-manager CLIs). Delegates retrieval without per-backend integration code, same shape asgit config credential.helperorBORG_PASSCOMMAND. Configured-but-broken fails closed so a misbehaving helper can't silently demote security to a weaker tier. - Interactive prompt — only when prompt_on_tty and
sys.stdin.isatty().
passphrase_command is threaded through as a parameter rather than read here so this module stays free of any dependency on the sandbox config layer — the config module already imports from credentials.db, and the back-edge would close a tach cycle.
Source code in src/terok_sandbox/vault/store/encryption.py
probe_passphrase_chain(*, credentials_db, systemd_creds_file=None, use_keyring=False, passphrase_command=None)
¶
Report per-tier presence across the resolution chain without short-circuiting.
Presence is judged from material on hand, not by resolving the
secret: the sealed systemd-creds credential is never unsealed and
the passphrase_command is never executed (both can be slow or
have side effects), so their mere configuration counts as present.
The keyring and kernel-keyring tiers are cheap to read, so those are
probed for real. Tiers appear in resolution order; the first
present one is the tier that would unlock the vault. The
interactive prompt tier is omitted — it stores nothing, so it
can never be "present".
Source code in src/terok_sandbox/vault/store/encryption.py
load_passphrase_from_keyring(*, allow_prompt=False)
¶
Return the keyring-stored passphrase, or None when a read cannot succeed.
A read from a locked Secret Service collection triggers the desktop's unlock dialog and waits for the answer. That wait is legitimate exactly once: an interactive caller (allow_prompt) on a host with a graphical session, where the operator sees the dialog and answers or cancels it. Every other read — a status probe, a poll, a daemon, a headless or SSH session — skips a locked collection instead of waiting on a dialog nobody sees. A timeout guards the promptless read, so a misbehaving backend degrades this tier instead of freezing its caller.
Source code in src/terok_sandbox/vault/store/encryption.py
os_keyring_read_blocked(*, allow_prompt=False)
¶
Explain why an OS-keyring read would block or fail, or None when safe.
Only the Secret Service backend can block: a read from a locked collection triggers a D-Bus unlock prompt, and the prompt waits for a desktop dialog. This probe reads the lock state without a prompt. A locked collection does not block an allow_prompt read when a graphical session is present — the operator answers the dialog. A probe error (no D-Bus session, no Secret Service daemon) also makes the tier unusable and returns a reason. Non-D-Bus backends never prompt, so they pass.
Source code in src/terok_sandbox/vault/store/encryption.py
retire_keyring_worker()
¶
Join the keyring worker so the process is single-threaded again.
A process that confines itself after its passphrase chain needs this: Landlock below ABI 8 restricts one thread, and the helper refuses a process with two. A worker abandoned in a wedged read cannot be joined and is left where it is — the confinement then reports the thread, which is the honest outcome. The next read starts a fresh worker.
Source code in src/terok_sandbox/vault/store/encryption.py
keyring_backend_available()
¶
Return True iff a usable OS keyring backend is reachable.
Availability probe for setup frontends (the TUI tier chooser)
deciding whether to offer the keyring tier at all. A probe, not
a guarantee — the definitive answer stays with
store_passphrase_in_keyring's
return value at provisioning time. The fail and null
backends both answer "no": they accept calls but hold nothing.
Source code in src/terok_sandbox/vault/store/encryption.py
store_passphrase_in_keyring(passphrase)
¶
Persist passphrase in the OS keyring; return True on success.
Refuses to store an empty value — SQLCipher interprets it as "no encryption", and a later resolve hit on a blank keyring entry would silently open the DB plaintext.
Source code in src/terok_sandbox/vault/store/encryption.py
forget_passphrase_in_keyring()
¶
Remove the keyring entry; return None when it is gone, else why it may survive.
"Gone" covers a successful delete and an entry that never existed — the caller's goal is absence, not the delete call. A locked keyring cannot prove absence and never prompts here, so it returns its reason; so does a backend that rejects the delete while the entry still reads back. Callers render the reason instead of guessing.
Source code in src/terok_sandbox/vault/store/encryption.py
load_passphrase_from_command(command, *, timeout=_PASSPHRASE_COMMAND_TIMEOUT_S)
¶
Run command, return its stdout with the trailing newline removed, or None on any failure.
Same shape as the other tier primitives
(load_passphrase_from_keyring):
silent on every failure path so the resolver can decide whether
None means "skip this tier" or "fail closed". Diagnostic
detail (parse error, exec failure, non-zero exit, helper stderr,
timeout) is logged at WARNING so operators can triage their helper
from the invoking command's output (or the per-container supervisor
log under <state_root>/logs/) without us crashing the chain
walk.
Same vocabulary as git config credential.helper, ssh pinentry,
BORG_PASSCOMMAND: one field plugs any credential backend into
the resolver — pass show …, bw get password …,
op read op://…, vault kv get -field=passphrase …,
aws secretsmanager get-secret-value … — without per-backend
integration code in the sandbox.
Source code in src/terok_sandbox/vault/store/encryption.py
prompt_passphrase(*, confirm=False)
¶
Read a passphrase from the controlling TTY with *-masked echo.
Mirrors the _prompt_api_key helper in terok_executor.credentials.auth:
prompt_toolkit.prompt(is_password=True) for the TTY path —
proper terminal raw-mode handling, Ctrl+C raises
KeyboardInterrupt cleanly, every character is masked. Non-TTY
input (e.g. terok-sandbox credentials encrypt-db < passphrase.txt)
falls back to a plain readline so pipe-fed automation still
works.
Empty entries are SQLCipher's no-encryption sentinel and never
return a blank string. In confirm mode (setup-time provisioning
of a brand-new passphrase) hitting Enter is treated as
"generate one for me": a fresh random passphrase is minted, echoed
once so the operator can copy it out, and returned. In single-shot
mode (unlocking an existing DB) an empty entry raises — generating
here would produce a wrong key that fails to decrypt the DB.
Source code in src/terok_sandbox/vault/store/encryption.py
prompt_new_passphrase()
¶
Read a typed-and-confirmed new passphrase from the TTY, or None on empty entry.
The entry half of prompt_passphrase's
confirm mode, split out so flows that mint-and-announce on
their own schedule (vault passphrase change reveals only after
the rekey succeeded) can reuse the exact same typed-entry
experience: masked echo, a confirmation read, a mismatch error.
None means the operator hit Enter on the first prompt —
the established "generate one for me" gesture.
Source code in src/terok_sandbox/vault/store/encryption.py
open_sqlcipher(db_path, passphrase, **connect_kwargs)
¶
Return a sqlcipher3 connection with passphrase applied.
Rejects an empty passphrase at the lowest level — set_key("")
is SQLCipher's "open me plaintext" sentinel and would silently
produce or read an unencrypted DB. All higher-level call paths
already screen for empties; this is the load-bearing guard.
Source code in src/terok_sandbox/vault/store/encryption.py
generate_passphrase()
¶
rekey_in_place(db_path, old_passphrase, new_passphrase)
¶
Re-encrypt db_path under new_passphrase via SQLCipher PRAGMA rekey.
The change-passphrase counterpart of
encrypt_in_place
— same journal discipline, different starting point: an
already-encrypted DB instead of a legacy plaintext one, re-encrypted
page by page inside SQLCipher's own journaled transaction (no temp
copy, no backup — the operator's off-host passphrase copy is the
recovery story).
The WAL is drained and journaling switched to DELETE first: WAL
frames are encrypted with the old key, so rekeying around a
populated WAL would leave frames the new key cannot read. Both
steps report contention through their result rows rather than by
raising — wal_checkpoint returns a busy flag and
journal_mode echoes the old mode when it couldn't switch — so
each is verified explicitly, and a live per-container supervisor
still holding the DB surfaces as database is locked before
anything is modified.
Raises WrongPassphraseError
when old_passphrase doesn't open the DB and ValueError
on an empty new passphrase (SQLCipher's no-encryption sentinel).
Source code in src/terok_sandbox/vault/store/encryption.py
is_plaintext_sqlite(db_path)
¶
Return True if db_path is a legacy plaintext sqlite DB.
Stdlib sqlite refuses to open SQLCipher files with DatabaseError:
file is not a database; a successful PRAGMA quick_check means
the file is plain sqlite. Used only by the one-shot setup
migration — not on any runtime open path.
Source code in src/terok_sandbox/vault/store/encryption.py
encrypt_in_place(db_path, passphrase)
¶
Convert plaintext db_path into a SQLCipher-encrypted DB.
Deprecated in 0.8.0; scheduled for removal in 0.9.0. After
removal, this function and its CLI surface
(terok-sandbox credentials encrypt-db) disappear — installs
older than 0.8.0 must migrate before upgrading past 0.9.0.
Atomic: a crash between export and rename leaves the original plaintext file untouched, so a re-run starts cleanly.
WAL-aware: the legacy DB may have been opened in WAL mode (the
daemon sets journal_mode=WAL on every connection), so its
pages can live in .db-wal rather than the main file. Before
exporting we force a full checkpoint and switch to DELETE
journaling, then unlink the -wal / -shm / -journal
sidecars; otherwise plaintext secrets would survive the migration
in the leftover sidecars even after the main file is encrypted.
Permission-tight: the temp file is created up-front at 0o600 so
SQLCipher's ATTACH doesn't materialise a world-readable
encrypted DB under a permissive umask.