Vault / authd
Keep 0.1 — credential authority (host-side)
- Agent never sees raw passwords in tool results: the egress broker injects secrets after allowlist checks.
CredentialVault::authorize_resolveonly returns a secret when(credential_id, host, method, path, port, user_id)matches the descriptor (host,allowed_methods,path_prefixes,allowed_ports,allowed_users).- Surrogate tokens (
zy_sur_…) can stand in for intercepted TLS hosts; the real secret is swapped only at approved egress (seemitm). - Honesty: secret material still lives in the host process environment / descriptor file. The operator of the host can read it. This is not a claim that the agent cell is unread by the operator.
Credentials from a file (source)
A descriptor can name a file instead of an environment variable, so the secret does not have to live in a unit file, an env file or a pod spec, and can be rotated without restarting the runtime:
{ "stripe": { "host": "api.stripe.com", "header": "authorization", "prefix": "Bearer ",
"source": { "kind": "file", "path": "/run/secrets/stripe-key", "ttl_seconds": 60 } } }
This covers Vault Agent (which renders secrets to files), Kubernetes Secrets mounted as volumes, the CSI Secrets Store driver and systemd
LoadCredential=. Set either env or source, not both; source is not for fabric or oauth-refresh credentials.
- Same checks. Host, method, path, port, user and approval are checked first and unchanged. A request that fails them never causes the file to be read.
- Cached, then re-read. The value is cached for
ttl_seconds(default 60, at most 3600) with one read in flight however many requests arrive. The TTL is also how long a rotated or revoked secret keeps working. - Fails closed. If the file cannot be read after the cache expires, the request is refused; a cached value is never used past its TTL. Errors name the credential and the file, never its contents.
- The file must be private. A file readable by group or others (anything but 0400 or 0600) stops startup, unless the source sets
allow_loose_permissions: true; a Kubernetes Secret volume is 0644 unless itsdefaultModesays otherwise. A file that does not exist yet only warns at startup, since whatever renders it may start later. - What is read. A regular file (symlinks are followed, as Kubernetes needs), at most 64 KiB, UTF-8, trimmed. An empty file, or a value with a control character such as a newline inside it, is refused so it cannot add a second header.
- The limit. The secret is still in the runtime's memory while it is used, so the host operator can read it. A source changes where it is kept and who can rotate it, not that.
Credentials from HashiCorp Vault (source.kind: "vault")
The runtime can read one field of a KV v2 secret directly:
{ "github": { "host": "api.github.com", "header": "authorization", "prefix": "Bearer ",
"source": { "kind": "vault", "addr": "https://vault.internal:8200", "mount": "secret",
"path": "keep/github", "field": "token", "ttl_seconds": 300,
"auth": { "method": "token", "token_file": "/vault/secrets/token" } } } }
It sends GET {addr}/v1/{mount}/data/{path} with X-Vault-Token (and X-Vault-Namespace if namespace is set) and uses the text at
data.data.{field}. Everything under "Credentials from a file" applies here too: the authorisation checks run first and a refused request never
reaches Vault, the value is cached and read once per burst, and a failure fails closed with no stale value.
- Auth: a token you provide.
token_file(what a Vault Agent file sink writes; it is re-read on every fetch, so a renewed token is picked up, and it must be private like any secret file) ortoken_env. Set exactly one. - Auth: AppRole.
{"method": "approle", "role_id": "…", "secret_id_file": "/run/…"}(orsecret_id_env;mountdefaults toapprole). The runtime logs in withPOST /v1/auth/{mount}/login, keeps the returned token until shortly before its lease ends (30 s early, at most an hour), and reads with it. The secret id goes only in the login request body. - Auth: Kubernetes.
{"method": "kubernetes", "role": "keep"}(mountdefaults tokubernetes,jwt_fileto/var/run/secrets/kubernetes.io/serviceaccount/token). The pod's service account token is sent to Vault, which checks it against its role. It is read at each login, so a rotated projected token is used, and its file permissions are not checked because Kubernetes makes it world-readable. - When Vault refuses a token. If a token that was cached is refused (revoked, expired early), the runtime gets a new one (a new login, or the token file read again) and tries once more. A token that was just issued and is refused means the policy says no, so it is not retried, and nothing loops.
- Transport.
addrmust behttps(plainhttponly to a loopback address). Redirects are never followed. Each request has a timeout (timeout_seconds, default 10, at most 30) and the answer is capped at 256 KiB. For a private CA,ca_filenames a PEM certificate to trust. - Cache.
ttl_secondsdefaults to 300 (at most 3600). It is also how long a revoked secret keeps working. - What it will not build a URL from.
mount,pathandnamespaceare plain segments (letters, digits,_ - .), with no.., no empty segment and no leading slash, and each is pushed onto the URL as a separate segment, so nothing in the configuration can change the shape of the request. - At startup every Vault-sourced secret is read once. A failure only warns, since Vault may come up after the runtime; the first request tries again and fails closed if it still cannot.
- Errors say which class of thing went wrong (could not be reached, refused the token, not found, not a KV v2 secret) and never include the answer, which can echo paths or policy.
- Not yet verified against a real Vault. The behaviour is tested against a mock; a real server, a real Vault Agent and a Vault Enterprise namespace have not been tried.
Seeing where a source stands
GET /v1/vault/status (operator token only; keepctl vault-status) lists each credential that has a source: its name, its kind (file or
vault), whether a value is cached, how many seconds ago the last read worked, whether a read is in flight, and the class of the last failure:
unreachable, refused, not_found, malformed, config or file. It never shows a value, a path or an answer. Failures also log a warning
with the same class. config usually means a token, secret id or CA file that is missing or unreadable; refused means Vault answered 400, 401 or
403.
On Kubernetes
The zyvor-keep chart (charts/zyvor-keep) takes credentials.descriptors (rendered to a ConfigMap and mounted read-only, and never holding a
secret), runtime.extraVolumes and runtime.extraVolumeMounts (to mount the Secret, CSI or Vault Agent files that file sources read), and
serviceAccount.name (the account whose token Vault's Kubernetes login sends). A changed descriptor restarts the runtime, which reads them once at
startup.
The design is in design/credential-sources.md.
Refreshing OAuth credentials
A descriptor with kind: "oauth-refresh" keeps its client id, client secret and refresh token in host env and injects a short-lived access token that the runtime refreshes in the background; a failed refresh fails closed. See connectors (Google Gmail and Calendar).
Optional software unwrap ceremony
When ZYVOR_AGENT_VAULT_UNWRAP_REQUIRED=1:
keepctl unwrap-token vault # → token (still host-env secrets)
keepctl unwrap "$TOKEN" # unlocks inject for ~1h
keepctl vault-status # names + honesty; never secret values
POST /v1/vault/unwrap-tokens, POST /v1/vault/unwrap, GET /v1/vault/status.
Cockpit includes vault.{secret_backend,unwrap_required,unlocked,honesty}.
Set ZYVOR_AGENT_VAULT_USER_HELD=1 to label secret_backend: user-held-pending
(still host-env material).
Keep 0.2 scaffolding (software-test)
keepctl user-held-challenge # → {id, nonce} for phone/YubiKey ceremony design
keepctl user-held-complete ID NONCE # → 403 until snp/tdx_launch_verified
POST /v1/vault/user-held/challenge and …/complete. Complete reads FluxVM
GET /v1/security/capabilities and is fail-closed while verified flags are
false. When a hardware run flips them, complete grants a vault lease via the
key-broker stub (wrapped LUKS disk key still not implemented). Vault opens
for real only after the user’s phone or YubiKey unwraps a key onto a
measured/attested guest. No “operator may open for support” on confidential.
Connector scopes like OAuth for CLI tools: github:read:one-repo, not a raw PAT.
Payment rails stay pluggable (Stripe Link / virtual card / paste OTP).