encryption
encryption
¶
Passphrase plumbing and SQLCipher helpers for at-rest credential encryption.
Walks the five-tier resolution chain — session-unlock file →
systemd-creds → OS 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_file', '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.
SESSION_FILE = 'session-file'
class-attribute
instance-attribute
¶
Tmpfs session-unlock file — RAM-backed, cleared on reboot.
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.
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 when a high-priority tier (typically the session file) is
shadowing a durable tier underneath — the operator's "why is my
TPM2 box reading a RAM-backed file?" question.
open_sqlcipher_via_chain(db_path, *, passphrase_file=None, 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(*, passphrase_file=None, 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.
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(*, passphrase_file=None, 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:
- passphrase_file — session-unlock tmpfs file (cleared on reboot).
- 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.
- 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(*, passphrase_file=None, 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 session-file and 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 neither be "present" nor shadow anything.
Source code in src/terok_sandbox/vault/store/encryption.py
load_passphrase_from_file(path)
¶
Return the passphrase stored at path, or None if absent or unreadable.
An absent file is the normal locked state and stays silent. Any
other OSError (EACCES from a permissions slip, an SELinux
denial, an unmounted tmpfs) also degrades to None so the chain
can fall through — but it logs a warning first: without the log a
blocked read is indistinguishable from "locked" on every surface,
which buries the actual fault.
Source code in src/terok_sandbox/vault/store/encryption.py
load_passphrase_from_keyring()
¶
Return the keyring-stored passphrase, or None if no backend is reachable.
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 True on success.
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_file,
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.