encryption
encryption
¶
Passphrase plumbing and SQLCipher helpers for at-rest credential encryption.
Walks the six-tier resolution chain — session-unlock file →
systemd-creds → OS keyring → passphrase_command helper →
plaintext config fallback → interactive prompt — and exposes the
SQLCipher open / migrate primitives the rest of the package builds on.
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
¶
PassphraseSource = Literal['session-file', 'systemd-creds', 'keyring', 'passphrase-command', 'config', 'prompt']
module-attribute
¶
__all__ = ['KEYRING_SERVICE', 'KEYRING_USERNAME', 'NoPassphraseError', 'PassphraseSource', 'WrongPassphraseError', 'encrypt_in_place', 'forget_passphrase_in_keyring', 'generate_passphrase', 'is_plaintext_sqlite', 'load_passphrase_from_command', 'load_passphrase_from_file', 'load_passphrase_from_keyring', 'open_sqlcipher', 'open_sqlcipher_via_chain', 'prompt_passphrase', 'resolve_passphrase', 'resolve_passphrase_with_source', 'store_passphrase_in_keyring']
module-attribute
¶
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.
open_sqlcipher_via_chain(db_path, *, passphrase_file=None, systemd_creds_file=None, use_keyring=False, passphrase_command=None, config_fallback=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, config_fallback=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, config_fallback=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 plaintext. - config_fallback —
credentials.passphrasefromconfig.yml. Plaintext-on-disk trust boundary: the operator accepts that filesystem-level protection (LUKS / signed image / permissions) is their security perimeter. Sandbox#282 surfaces a permanent WARNING invault statusand sickbay whenever this tier is set, regardless of which tier actually unlocked the call. - Interactive prompt — only when prompt_on_tty and
sys.stdin.isatty().
config_fallback and passphrase_command are threaded through as parameters 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
load_passphrase_from_file(path)
¶
Return the passphrase stored at path, or None if absent or unreadable.
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
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
via journalctl --user -u terok-vault 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
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()
¶
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.