anvilsign in

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.