Security 5 - Credentials vault and scopes
API keys, OAuth tokens, database connection strings - every real
Digitorn app needs at least one secret. The naive way is to drop
them in env vars and pull them through {{env.X}} at compile time.
That's fine for a single-user dev daemon. The moment more than one
person or app shares a daemon, env vars stop scaling: the same
value resolves for everyone, and rotating a key means a redeploy.
The credentials vault is the structured alternative: every secret lives in an encrypted store instead of baked into the compiled bundle.
Scopes
The schema accepts three scope values (system_wide,
per_app_shared, per_user) on credential.scope, but today only
per_user is actually implemented end to end: every vault entry is
stored against the user who added it (user_id + provider_name),
and the other two values compile without error but don't change
resolution - there's no system-wide or app-shared credential store
behind them yet.
Resolution for a brain.credential is by (user_id, provider),
not by ref: at session start the daemon looks up the signed-in
user's vault entry for that provider name and takes the most
recently updated one. ref and scope are accepted in the YAML
(and shown back in the UI) but aren't consulted during lookup - if
a user stores two credentials for the same provider, whichever was
saved or edited most recently wins, regardless of which ref the
app declared.
agents:
- id: main
brain:
provider: deepseek
backend: openai_compat
credential:
ref: deepseek_main
scope: per_user
provider: deepseek
The schema's other job
security.credentials_schema declares what an app expects -
the client (chat UI, install flow) reads it and renders a typed
form so a user installing the app can fill in exactly the secrets
it needs:
security:
credentials_schema:
providers:
- name: notion_main
label: "Notion workspace"
type: oauth2
scope: per_user
oauth_provider: notion # pre-registered OAuth flow
- name: stripe_secret
label: "Stripe API key"
type: api_key
scope: per_app_shared
fields:
- name: api_key
type: secret
required: true
validation_regex: "^sk_(live|test)_[a-zA-Z0-9]{24,}$"
The user sees an install page with two fields: an OAuth button for
Notion and a text input for the Stripe key, validated against the
regex before it's submitted. type: oauth2 skips field rendering
entirely - the client opens the provider's authorization page and
the daemon handles the token exchange.
There's no compile-time cross-check between an agent's
credential.ref and the names declared in credentials_schema -
the two are independent; a typo'd ref surfaces at runtime, as a
"credential missing" resolution failure when the session starts,
not as a compile error.
Encryption
Every credential field is encrypted with NaCl secretbox
(XSalsa20-Poly1305) under a single local key,
~/.digitorn/server.key - created with 0600 permissions the
first time the daemon needs it. There's no KMS backend or
environment-variable override; the key lives on the machine
running the daemon. See
Credentials reference → Security architecture
for the exact format.
Resolution at runtime
When a session starts, the daemon resolves the brain's credential
by looking up (user_id, provider) in the vault and taking the
most recently updated matching entry. If nothing matches, the
session fails with a structured "credential missing" error instead
of attempting the LLM call and hitting a cryptic 401.
It's resolved at session start and hot-swapped onto the live LLM
provider instance; the agent loop never sees a stale key. This
path is the same regardless of which scope the YAML declares -
see Scopes above.
The full lifecycle - OAuth refresh, the built-in provider catalog, the credential type list - is documented in Credentials reference.
Picking a scope today
per_user is the only scope with real behavior behind it right
now: each user stores and resolves their own credential for a
given provider, independent of every other user on the daemon.
Declare per_app_shared or system_wide if that's the intent
you want to record in the YAML, but don't rely on the daemon to
enforce app- or install-wide sharing yet - it resolves identically
to per_user.
Going further
- Full credentials reference (credential types, the built-in provider catalog, OAuth flow): Credentials.
- The legacy
{{secret.X}}/{{env.X}}template form still works as a dev fallback but doesn't get the typed install-form schema: see the migration example in Credentials → YAML reference. - How this fits the rest of the gate chain - credential resolution happens as part of turn setup, before any tool-call gate runs: Security 2 - Gates.