decoyrail

Vault, decoys, and secret release

The vault holds your real secrets encrypted at rest and pairs each with a deterministic decoy. Where a real secret may travel is not stored here: the policy decides that, through each rule's allow_secrets.

decoyrail vault add --name anthropic --env ANTHROPIC_API_KEY --location bearer
decoyrail vault ls            # human view (real value redacted)
decoyrail vault ls --json     # machine view (real value omitted entirely)
decoyrail vault rm anthropic

How a secret is stored

flowchart LR
    real["real secret<br/>(prompted hidden / stdin)"] --> enc["ChaCha20-Poly1305<br/>key: ~/.decoyrail/vault.key (0600)"]
    enc --> file[("vault.json")]
    real --> h["SHA-256(name + real)"]
    h --> decoy["deterministic decoy<br/>format-correct fake"]
    decoy --> env["injected into the agent env<br/>by `decoyrail run`"]

Where the key lives: file or keychain

A release build's first run on macOS against the default home mints the vault key directly in the login keychain; there is never a vault.key file to steal. Everywhere else (development builds, any overridden DECOYRAIL_HOME, and installs created before this default) the key is a file on disk: simple, but readable by any process running as your user. decoyrail key migrate moves an existing key in either direction:

decoyrail key status                  # active backend, item presence, bound home
decoyrail key migrate --to keychain   # move the key in (removes vault.key)
decoyrail key migrate --to file       # move it back out

The keychain backend buys you two things:

There is no config flag for any of this. The backend is chosen by presence: if a keychain item bound to the default home exists it is used, otherwise the file is. A flag in a same-user-writable file would just be one more thing an attacker could flip.

Migration is safe to interrupt (the key is verified in its new location before the old copy is destroyed) and reversible at any time with the opposite migrate. If a keychain read is ever denied or fails, Decoyrail aborts rather than inventing a fresh key; a fresh key would present as an inexplicably empty vault and mask the failure.

First-run creation is the one moment with a fallback: if the keychain is unavailable when the very first key is minted (a headless SSH session, say), Decoyrail writes a vault.key file instead and carries on. Nothing is encrypted yet at that point, so no ciphertext can be orphaned; the strict no-fallback rule above guards reads of a key that already protects data.

Development builds are unsigned, so the keychain identifies the binary itself and prompts again after every rebuild; that is why dev builds keep the file backend even on a first run. Upgrading a release install replaces the binary too, so expect a single consent prompt on the first read after an upgrade: approve it once and the new binary is trusted. Any non-default DECOYRAIL_HOME, including tests and throwaway runs, always uses a file key inside that directory.

Decoys: deterministic, format-correct honeytokens

The decoy is derived from a hash of the entry name and the real value, so re-adding the same secret always reproduces the same decoy; tripwire matches and audit history stay stable across vault edits. Decoys mimic the real secret's format so SDKs and validators accept them:

Real secret looks like Decoy generated
sk-ant-… (Anthropic) sk-ant-api03- + 93 chars
sk-… (OpenAI) sk-proj- + 48 chars
github_pat_… github_pat_ + 70 chars
ghp_… ghp_ + 36 chars
AKIA… (AWS access key) AKIA + 16 uppercase chars
xoxb-… (Slack) xoxb- + 48 chars
connection string (scheme://user:pass@host) postgres://decoy:…@db.invalid:5432/app
anything else opaque token, same length class (16-64 chars)

A decoy is not valid anywhere. Its only function is that Decoyrail recognizes it.

Recognized formats also give the entry a provider label (anthropic, openai, github, gitlab, slack, npm). A policy rule can release a secret by that label (allow_secrets = ["provider:github"]) instead of by name; the shipped default policy does exactly that, so provider keys work with zero configuration.

The session vault: automatic decoys for decoyrail run

Vault entries are the secrets you added explicitly. On top of them, decoyrail run builds a session vault each time it starts: env vars with credential-shaped names or known key formats are replaced with decoys in the child's environment.

Session secrets run through the same swap/tripwire pipeline as vault entries, but live only in memory and only for that run. Recognized provider keys carry their provider label, and the default policy's provider rules release those labels, so they keep working. Everything else is tripwire-only: usable nowhere, and any attempt to send it is blocked and recorded.

To make an auto-decoyed credential usable, release it in the policy by its session name, which is env: plus the variable: a rule with allow_secrets = ["env:AWS_SECRET_ACCESS_KEY"] releases that one at the hosts the rule matches. Add it to the vault instead if you want an entry that outlives the session. To exempt it from decoying entirely, use --pass-env VAR (or --pass-all-env to disable the scanning). Details and caveats: getting started, threat model.

Release: the one road a real secret may travel

The policy rule that wins a request decides whether the real secret rides it. decoyrail vault add --allow-host HOST appends the releasing rule for you:

decoyrail vault add --name acme --env ACME_API_KEY \
  --allow-host api.acme.com --location header:x-api-key

which adds to policy.toml:

[[rule]]
name = "acme"
hosts = ["api.acme.com"]
action = "allow"
allow_secrets = ["acme"]

Narrower release (a path prefix, specific methods) is a policy edit away; everything in the policy reference applies, including first-match ordering. Without a releasing rule the secret is tripwire-only, and vault ls says so.

Location: where in the request the secret rides

The one release axis that stays on the vault entry is the location, because it is a property of the credential, not of the destination:

--location Swaps when the decoy is found in Typical use
bearer the Authorization header Anthropic, OpenAI, most APIs
header:<name> that named header vendor APIs with custom headers
body the request body (UTF-8) form posts, webhook payloads
any (default) headers and body when unsure

The location is enforced, not advisory: a decoy found at a releasing rule but in the wrong location (say, the location is bearer and the decoy appears in a custom header) is treated as a tripwire, not swapped. The URL is never a swappable location: a decoy in the path or query string always tripwires.

Entering the secret safely

decoyrail vault add accepts the value three ways, safest first:

decoyrail vault add --name gh --allow-host api.github.com --location bearer
# → prompted, hidden: never in shell history or `ps`

op read "op://vault/github/token" | decoyrail vault add … --secret -
# → piped via stdin

decoyrail vault add … --secret ghp_xxx
# → inline: works, but visible in history and `ps` (decoyrail warns)

What the swap engine does with all this

Per request, per secret: if the decoy (or an encoded form of it) appears anywhere in the request, Decoyrail either swaps it (winning rule releases the secret, TLS transport, correct location) or fires a tripwire (anything else, with one exception: a secret listed on a deny or escalate rule blocks quietly, since the agent's own credential riding a blocked request is expected). The complete decision tree, including the encoded-decoy, URL, and plaintext-HTTP rules, is diagrammed in how-it-works.

Reachability and release live in one file and one evaluation: the rule that lets a request through is the rule that says which credentials it may carry. There is no separate binding to keep in sync.