collin/anvil
RenderedSource
Repository secrets
Per-repository secrets that anvil stores but cannot read. Values are encrypted
on your machine — in the browser, or by anvild secret — to the ssh-ed25519
keys the repository owner has registered. What lands in the database is an
opaque envelope; the private half that opens it never leaves your laptop.
CI is the one consumer that needs plaintext, and it only gets it while the repository is unlocked (see Unlocking for CI).
What this does and does not protect
Holds even if the server is fully compromised:
- Nothing on disk opens the envelopes. The database, a backup, a volume
snapshot, a stolen
data/directory: all ciphertext. anvil holds no key. - The web UI is write-only. A value can be set and replaced, never displayed.
Does not hold:
- An unlocked repository has plaintext in the server's memory. That is the price of CI seeing the values at all; a root-level attacker on the host can read another process's memory. Unlock for as long as you need and no longer.
- CI jobs receive the values as environment variables, so any code that runs in that pipeline can print them, POST them somewhere, or bake them into an artifact. Only give a pipeline the secrets it needs, and remember that anyone who can push to the repo can change the pipeline. anvil masks known values in captured logs, which stops accidents, not intent.
- A key you remove can still open old envelopes it already saw. Removing a key from your account stops it authenticating; it does not un-encrypt. After removing a key, rotate the affected secrets (set new values).
The wider threat model for a multi-user instance lives in untrusted-mode.md.
Setting a secret
In the browser: repository → settings → Secrets. Type a name and a value,
press Encrypt and save. The page seals the value with WebCrypto before any
request is made — the plaintext never appears in a request body, a URL, or the
server's logs. (The form is deliberately not a <form> element, so there is no
default submission path that could send the value before the script runs.)
From a terminal:
export ANVIL_SERVER=https://anvil.example.com ANVIL_USER=collin
printf '%s' "$TOKEN" | anvild secret set collin/anvil DEPLOY_TOKEN
anvild secret list collin/anvil
anvild secret get collin/anvil DEPLOY_TOKEN # needs your private key
Names are environment-variable shaped: A-Z, 0-9, _, not starting with a
digit. anvild secret talks to a running anvil over HTTP (Basic auth with your
account password, the same credential git-over-HTTPS pushes use), because the
crypto belongs on the machine holding your ssh key — usually not the server.
Unlocking for CI
A pipeline declares what it needs:
image: alpine:3.20
secrets: [DEPLOY_TOKEN]
steps:
- run: curl -sf -H "Authorization: Bearer $DEPLOY_TOKEN" https://example.com/deploy
anvil cannot open DEPLOY_TOKEN on its own, so the run fails immediately —
before any container starts — unless you have unlocked the repository:
anvild secret unlock collin/anvil --ttl 8h
That command opens every envelope locally with your ssh key and hands the
values to the server, which keeps them in memory only: no file, no database
row, no log. They vanish when the TTL expires, when you run
anvild secret lock collin/anvil (or press Lock now in settings), and on
every restart or redeploy. Maximum TTL is seven days.
Repository settings shows the current state — sealed, or unlocked with an expiry.
Adding a key: rekeying
A secret is sealed to the key set that existed when it was written. Register a new ssh key and it cannot open anything older, which settings flags per secret ("1 key(s) cannot open this — rekey"). Fix it from a machine holding a key that can open them:
anvild secret rekey collin/anvil
This decrypts each secret locally and writes it back sealed to every currently registered ssh-ed25519 key. Nothing else can do this — the server cannot, by construction — so keep at least one working key until you have rekeyed.
Only ssh-ed25519 keys participate. RSA keys can authenticate pushes but not
receive secrets (that would need a second scheme), and FIDO/-sk keys cannot do
key agreement at all.
The envelope format (anvil-secret-v1)
One random 256-bit file key per secret encrypts the value; the file key is wrapped once per recipient:
file_key = 32 random bytes
body = AES-256-GCM(file_key, nonce, value, aad)
aad = "anvil-secret-v1\n{owner}/{repo}\n{NAME}"
per recipient r:
esk, epk = fresh X25519 keypair
shared = X25519(esk, r.x25519)
wrap_key = HKDF-SHA256(ikm = shared, salt = epk ‖ r.x25519,
info = "anvil-secret-v1 wrap")
wrap = nonce ‖ AES-256-GCM(wrap_key, nonce, file_key, aad = r.fingerprint)
stored as JSON:
{ "v": 1, "alg": "x25519-hkdf-sha256+aes256gcm",
"recipients": [{ "fp": "SHA256:…", "epk": "…", "wrap": "…" }],
"nonce": "…", "ct": "…" }
A recipient's X25519 public key is the birational map of their Ed25519 one; the
matching secret is clamp(SHA-512(seed)[..32]) — the same derivation age uses
for ssh-ed25519 recipients.
The associated data binds each ciphertext to its repository and its variable name, so a stolen envelope cannot be replayed into another repo or re-pointed at a different variable.
Why AES-GCM and HKDF-SHA256 rather than age's ChaCha20-Poly1305: the browser
is a first-class encryptor here, and WebCrypto ships neither ChaCha nor a stream
AEAD. Every primitive above is native in crypto.subtle; the only hand-written
arithmetic on either side is the Edwards → Montgomery point map, which has no
WebCrypto API. The cost is that envelopes are not age-compatible.
The two implementations — crates/anvil-core/src/secrets.rs and the SEAL_JS
string in crates/anvil-web/src/secrets.rs — are kept honest by
crates/anvil-web/tests/js_interop.rs, which runs the browser's code under node
and opens the result in Rust.