db
db
¶
SQLite-backed credential store, SSH key registry, and phantom token registry.
Provides host-side storage for the three kinds of secret material the vault mediates:
- Provider credentials (API keys, OAuth tokens) stored as JSON blobs keyed
by
(credential_set, provider). - SSH keys stored as unencrypted PKCS#8 DER + SSH wire-format public blob, deduplicated by standard-format fingerprint, linked to project scopes through an assignments join table.
- Phantom tokens minted per-
(scope, subject)so containers can authenticate to the vault without ever seeing real credentials.subjectis an opaque caller-supplied correlation label — the sandbox stores it verbatim and never interprets its contents. Callers (the orchestrator) decide what it identifies; today terok puts the task id there, but the sandbox treats it as a string label.
The database is never mounted into task containers — only the vault daemon reads it. sqlite3 in WAL mode gives lock-free concurrent reads across multiple terok processes (CLI commands, vault daemon, task runners).
Schema declarations and forward migrations live in
terok_sandbox.vault.store.migrations
— this module is the data-access layer only.
The on-disk file is always SQLCipher-encrypted; the passphrase
resolution chain (keyring → credentials.passphrase config field)
and the SQLCipher open helpers live in
terok_sandbox.vault.store.encryption.
__all__ = ['CredentialDB', 'InvalidScopeName', 'NoPassphraseError', 'PlaintextDBFoundError', 'SSHKeyRecord', 'SSHKeyRow', 'UnsafeCommentError', 'WrongPassphraseError', 'ensure_credentials_schema', 'migrate_credential_db_schema', 'open_credential_db', 'open_credential_db_with_source']
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.
InvalidScopeName
¶
Bases: ValueError
Raised when a scope name would be unsafe as a filesystem path segment.
Scopes are embedded verbatim in per-scope Unix-socket paths
(ssh-agent-local-<scope>.sock), so unrestricted input could lead
to traversal (../) or oversized sockaddr strings. Every write
path that persists a scope validates through this helper first, so
a malicious or buggy caller can't slip a hostile name past the CLI.
UnsafeCommentError
¶
Bases: ValueError
Raised when a comment contains control characters or is too long.
Comments flow into SSH authorized_keys lines, public-line rendering,
ssh-add -L output, and terminal summaries — so embedded newlines or
escape sequences could break the wire format or spoof terminal output.
Rejection happens at the storage entry points; every display site then
trusts the DB to hold only safe strings.
SSHKeyRow(id, key_type, fingerprint, comment, created_at)
dataclass
¶
SSH key metadata — everything except the private material.
Returned from listing operations where the caller wants to render information about what is stored without decoding the private key.
SSHKeyRecord(id, key_type, private_der, public_blob, comment, fingerprint)
dataclass
¶
SSH key record carrying both metadata and raw key bytes.
Returned from loading operations that feed the signer. The raw bytes are not decoded here — decoding is the signer's responsibility so the storage layer stays free of cryptography imports.
PlaintextDBFoundError
¶
Bases: RuntimeError
A legacy plaintext sqlite DB was found where an encrypted one was expected.
CredentialDB(db_path, *, passphrase)
¶
SQLite-backed store for provider credentials, SSH keys, and phantom tokens.
The on-disk file is always SQLCipher-encrypted. Callers either
supply passphrase explicitly or leave it None to walk the
runtime resolution chain (keyring → credentials.passphrase).
A missing passphrase raises NoPassphraseError;
a stale plaintext file raises PlaintextDBFoundError
— both are diagnostic-only. Operator-facing remediation (which CLI
verb to run, which doc page to read) is the caller's job: library
code shouldn't bake one frontend's verbs into its exception text.
Source code in src/terok_sandbox/vault/store/db.py
transaction()
¶
Run the body in an explicit BEGIN IMMEDIATE transaction.
Take the write lock up front so callers can compose
read-then-write sequences and trust the whole thing serialises
against concurrent writers. Every mutating method on this
class (credentials, SSH keys, phantom tokens) consults the
self._in_outer_tx flag this context manager sets and skips
its own per-call commit — so the API contract is "any
composition of write methods inside with db.transaction():
is atomic", with no kwarg plumbing at the call site.
On exit: COMMIT on clean exit, ROLLBACK on any
BaseException (KeyboardInterrupt / SystemExit
included — leaving a half-written %scope keypair around
would be worse than a re-mint on retry).
Source code in src/terok_sandbox/vault/store/db.py
store_credential(credential_set, provider, data)
¶
Insert or replace a credential entry.
Source code in src/terok_sandbox/vault/store/db.py
load_credential(credential_set, provider)
¶
Return the credential dict, or None if not found.
Source code in src/terok_sandbox/vault/store/db.py
list_credentials(credential_set)
¶
Return provider names that have stored credentials.
Source code in src/terok_sandbox/vault/store/db.py
list_credential_sets()
¶
Return distinct credential-set names with at least one stored credential.
Source code in src/terok_sandbox/vault/store/db.py
delete_credential(credential_set, provider)
¶
Remove a credential entry (idempotent).
Source code in src/terok_sandbox/vault/store/db.py
store_ssh_key(key_type, private_der, public_blob, comment, fingerprint)
¶
Register a keypair, dedup-by-fingerprint; return the ssh_keys.id.
When a row with the same fingerprint already exists the stored bytes
and comment are left untouched (the caller is re-asserting an
already-known key, which is expected on repeat ssh-import).
Auto-commits unless called inside a
transaction()
scope — in which case the outer block owns the commit.
Source code in src/terok_sandbox/vault/store/db.py
get_ssh_key_by_fingerprint(fingerprint)
¶
Look up a key by fingerprint; returns metadata only.
Source code in src/terok_sandbox/vault/store/db.py
set_ssh_key_comment(fingerprint, comment)
¶
Update the comment of the key with fingerprint.
Returns True if a row was updated, False if the fingerprint
is unknown. The comment is validated by the same safety helper
that gates import_ssh_keypair — control characters and
overlong strings raise
UnsafeCommentError
so the storage-entry-point invariant holds for this path too.
The new comment surfaces to subsequent ssh-add -L queries from
the container because the signer resolves keys fresh from the DB
on every request.
Source code in src/terok_sandbox/vault/store/db.py
assign_ssh_key(scope, key_id, *, allow_infra=False)
¶
Grant scope access to key_id (idempotent).
Rejects unsafe scope names with InvalidScopeName — the
value is later embedded in per-scope Unix-socket paths, so
traversal-like strings (../, /) must not be persisted.
By default also rejects %-prefixed infrastructure scopes so
callers driven by user input can't write to sandbox-reserved
names (%host for the krun host-side keypair, future
%name slots). Sandbox internals that legitimately provision
infrastructure scopes pass allow_infra=True.
Auto-commits unless called inside a
transaction()
scope — in which case the outer block owns the commit.
Source code in src/terok_sandbox/vault/store/db.py
unassign_ssh_key(scope, key_id, *, allow_infra=False)
¶
Revoke scope's access to key_id; drop the key row if orphaned.
Refuses %-prefixed infrastructure scopes by default — pair
with allow_infra=True for sandbox internals that need to
decommission a reserved scope.
Source code in src/terok_sandbox/vault/store/db.py
replace_ssh_keys_for_scope(scope, *, keep_key_id, allow_infra=False)
¶
Atomically make keep_key_id the scope's sole assigned key.
Wraps the "assign new + revoke every other" sequence in a single
SQLite transaction so two concurrent init(force=True) calls
can't both leave their own keys assigned — whichever transaction
commits last wins the scope, and exactly one primary survives.
Orphaned ssh_keys rows for revoked keys are cleaned up in the
same step via unassign_ssh_key semantics.
Refuses %-prefixed infrastructure scopes by default; sandbox
internals provisioning infra keys pass allow_infra=True.
Source code in src/terok_sandbox/vault/store/db.py
unassign_all_ssh_keys(scope, *, allow_infra=False)
¶
Revoke every key currently assigned to scope. Returns count removed.
Refuses %-prefixed infrastructure scopes by default — pair
with allow_infra=True for sandbox internals.
Source code in src/terok_sandbox/vault/store/db.py
list_ssh_keys_for_scope(scope)
¶
Return metadata rows for every key assigned to scope.
Ordered by assigned_at with k.id as a secondary key so
two assignments inside the same SQLite-second (datetime('now')
has 1-second resolution) sort by insert order rather than
implementation-defined order. Callers that do rows[-1] to
pick "the most recently assigned" get a deterministic answer
even under sub-second concurrency.
Source code in src/terok_sandbox/vault/store/db.py
load_ssh_keys_for_scope(scope)
¶
Return full records (with raw bytes) for every key assigned to scope.
Same deterministic ordering as
list_ssh_keys_for_scope
— assigned_at first, then k.id as the sub-second tiebreak.
Source code in src/terok_sandbox/vault/store/db.py
list_scopes_with_ssh_keys()
¶
Return every scope that currently has at least one assigned key.
Source code in src/terok_sandbox/vault/store/db.py
count_ssh_keys()
¶
Return the number of distinct keypairs stored in the DB.
Counts ssh_keys rows (deduplicated by fingerprint) rather
than ssh_key_assignments rows — a single key shared across
scopes is one stored key, not N. Surfaces to TUI/CLI status
consumers so they can show a count without opening the DB
themselves.
Source code in src/terok_sandbox/vault/store/db.py
create_token(scope, subject, credential_set, provider)
¶
Mint a phantom token bound to (scope, subject, credential_set, provider).
subject is an opaque caller-supplied correlation label — the
sandbox stores it verbatim and never interprets its contents.
Today terok puts the orchestrator's task id there; the sandbox
treats the value as a string.
Token format: terok-p-<32 hex chars>.
Source code in src/terok_sandbox/vault/store/db.py
lookup_token(token)
¶
Return {scope, subject, credential_set, provider} or None.
Source code in src/terok_sandbox/vault/store/db.py
list_tokens()
¶
Return every proxy-token row as a list of dicts.
Read-only inventory for operator-facing CLI inspection
(terok vault list --include-tokens). The raw token value
is included so the operator can cross-reference what's actually
mounted into containers; callers MUST mask it before display.
Source code in src/terok_sandbox/vault/store/db.py
revoke_tokens(scope, subject)
¶
Revoke every phantom token bound to (scope, subject).
Returns the number of rows removed. The sandbox makes no claim
about what subject identifies; callers (the orchestrator) pass
whatever opaque label they used at
create_token
time.
Source code in src/terok_sandbox/vault/store/db.py
close()
¶
__del__()
¶
ensure_credentials_schema(conn)
¶
Create the credential / SSH-key / phantom-token tables if missing.
Idempotent — every statement is IF NOT EXISTS. Exposed at module
level so every opener of the DB file runs it before issuing queries.
Without this, a daemon that opens an empty DB on a fresh install
(before any CLI command has touched the file) hits no such table:
credentials on the first query and crashes the unit.
Source code in src/terok_sandbox/vault/store/migrations.py
migrate_credential_db_schema(conn)
¶
Walk legacy credential-DB rows forward to the current schema.
Tracked via PRAGMA user_version so the whole function is a no-op
on already-upgraded DBs. Each current < N branch handles one
forward step; the final PRAGMA user_version set commits the
whole upgrade in one go.
Exposed at module level so every opener of the DB file
(CredentialDB for
writers, _TokenDB in the vault daemon for readers) runs it
before issuing queries — otherwise a daemon that restarts before any
CLI command has touched the DB would hit "no such column: …" on a
freshly-upgraded host.
The cryptography import is scoped to the v0 → v1 branch so
already-migrated DBs (the common case) don't pay an import cost,
and the storage module keeps tach-clean at import time.
Source code in src/terok_sandbox/vault/store/migrations.py
open_credential_db_with_source(db_path, *, passphrase_file=None, systemd_creds_file=None, use_keyring=False, passphrase_command=None, config_fallback=None, prompt_on_tty=False)
¶
Same as open_credential_db
but also returns which tier the passphrase came from.
Lets a TUI/CLI status display label the unlocked vault by its source without re-walking the chain itself.
Source code in src/terok_sandbox/vault/store/db.py
open_credential_db(db_path, *, passphrase_file=None, systemd_creds_file=None, use_keyring=False, passphrase_command=None, config_fallback=None, prompt_on_tty=False)
¶
Open the credential DB, resolving the passphrase via the runtime chain.
Walks: passphrase_file (tmpfs session-unlock) → systemd_creds_file
(sealed credential decrypted via systemd-creds(1)) → OS keyring
(when use_keyring) → passphrase_command (operator-supplied
helper, e.g. pass show … / op read …) → config_fallback
→ (when prompt_on_tty and a TTY is attached) interactive prompt.
CLI consumers pass prompt_on_tty=True; daemons leave it
False so they fail fast instead of blocking on stdin.