anvilsign in

collin/anvil

RenderedSource

1# Repository secrets
2
3Per-repository secrets that anvil stores but cannot read. Values are encrypted
4on your machine — in the browser, or by `anvild secret` — to the ssh-ed25519
5keys the repository owner has registered. What lands in the database is an
6opaque envelope; the private half that opens it never leaves your laptop.
7
8CI is the one consumer that needs plaintext, and it only gets it while the
9repository is *unlocked* (see [Unlocking for CI](#unlocking-for-ci)).
10
11## What this does and does not protect
12
13**Holds even if the server is fully compromised:**
14
15- Nothing on disk opens the envelopes. The database, a backup, a volume
16 snapshot, a stolen `data/` directory: all ciphertext. anvil holds no key.
17- The web UI is write-only. A value can be set and replaced, never displayed.
18
19**Does not hold:**
20
21- **An unlocked repository has plaintext in the server's memory.** That is the
22 price of CI seeing the values at all; a root-level attacker on the host can
23 read another process's memory. Unlock for as long as you need and no longer.
24- **CI jobs receive the values as environment variables**, so any code that runs
25 in that pipeline can print them, POST them somewhere, or bake them into an
26 artifact. Only give a pipeline the secrets it needs, and remember that anyone
27 who can push to the repo can change the pipeline. anvil masks known values in
28 captured logs, which stops accidents, not intent.
29- **A key you remove can still open old envelopes** it already saw. Removing a
30 key from your account stops it authenticating; it does not un-encrypt. After
31 removing a key, rotate the affected secrets (set new values).
32
33The wider threat model for a multi-user instance lives in
34[untrusted-mode.md](untrusted-mode.md).
35
36## Setting a secret
37
38In the browser: **repository → settings → Secrets**. Type a name and a value,
39press *Encrypt and save*. The page seals the value with WebCrypto before any
40request is made — the plaintext never appears in a request body, a URL, or the
41server's logs. (The form is deliberately not a `<form>` element, so there is no
42default submission path that could send the value before the script runs.)
43
44From a terminal:
45
46```sh
47export ANVIL_SERVER=https://anvil.example.com ANVIL_USER=collin
48printf '%s' "$TOKEN" | anvild secret set collin/anvil DEPLOY_TOKEN
49anvild secret list collin/anvil
50anvild secret get collin/anvil DEPLOY_TOKEN # needs your private key
51```
52
53Names are environment-variable shaped: `A-Z`, `0-9`, `_`, not starting with a
54digit. `anvild secret` talks to a running anvil over HTTP (Basic auth with your
55account password, the same credential git-over-HTTPS pushes use), because the
56crypto belongs on the machine holding your ssh key — usually not the server.
57
58## Unlocking for CI
59
60A pipeline declares what it needs:
61
62```yaml
63image: alpine:3.20
64secrets: [DEPLOY_TOKEN]
65steps:
66 - run: curl -sf -H "Authorization: Bearer $DEPLOY_TOKEN" https://example.com/deploy
67```
68
69anvil cannot open `DEPLOY_TOKEN` on its own, so the run fails immediately —
70before any container starts — unless you have unlocked the repository:
71
72```sh
73anvild secret unlock collin/anvil --ttl 8h
74```
75
76That command opens every envelope locally with your ssh key and hands the
77values to the server, which keeps them **in memory only**: no file, no database
78row, no log. They vanish when the TTL expires, when you run
79`anvild secret lock collin/anvil` (or press *Lock now* in settings), and on
80every restart or redeploy. Maximum TTL is seven days.
81
82Repository settings shows the current state — sealed, or unlocked with an
83expiry.
84
85## Adding a key: rekeying
86
87A secret is sealed to the key set that existed when it was written. Register a
88new ssh key and it cannot open anything older, which settings flags per secret
89(*"1 key(s) cannot open this — rekey"*). Fix it from a machine holding a key
90that *can* open them:
91
92```sh
93anvild secret rekey collin/anvil
94```
95
96This decrypts each secret locally and writes it back sealed to every currently
97registered ssh-ed25519 key. Nothing else can do this — the server cannot, by
98construction — so keep at least one working key until you have rekeyed.
99
100Only `ssh-ed25519` keys participate. RSA keys can authenticate pushes but not
101receive secrets (that would need a second scheme), and FIDO/`-sk` keys cannot do
102key agreement at all.
103
104## The envelope format (`anvil-secret-v1`)
105
106One random 256-bit *file key* per secret encrypts the value; the file key is
107wrapped once per recipient:
108
109```text
110file_key = 32 random bytes
111body = AES-256-GCM(file_key, nonce, value, aad)
112aad = "anvil-secret-v1\n{owner}/{repo}\n{NAME}"
113
114per recipient r:
115 esk, epk = fresh X25519 keypair
116 shared = X25519(esk, r.x25519)
117 wrap_key = HKDF-SHA256(ikm = shared, salt = epk ‖ r.x25519,
118 info = "anvil-secret-v1 wrap")
119 wrap = nonce ‖ AES-256-GCM(wrap_key, nonce, file_key, aad = r.fingerprint)
120```
121
122stored as JSON:
123
124```json
125{ "v": 1, "alg": "x25519-hkdf-sha256+aes256gcm",
126 "recipients": [{ "fp": "SHA256:…", "epk": "…", "wrap": "…" }],
127 "nonce": "…", "ct": "…" }
128```
129
130A recipient's X25519 public key is the birational map of their Ed25519 one; the
131matching secret is `clamp(SHA-512(seed)[..32])` — the same derivation age uses
132for `ssh-ed25519` recipients.
133
134The associated data binds each ciphertext to its repository *and* its variable
135name, so a stolen envelope cannot be replayed into another repo or re-pointed at
136a different variable.
137
138**Why AES-GCM and HKDF-SHA256 rather than age's ChaCha20-Poly1305:** the browser
139is a first-class encryptor here, and WebCrypto ships neither ChaCha nor a stream
140AEAD. Every primitive above is native in `crypto.subtle`; the only hand-written
141arithmetic on either side is the Edwards → Montgomery point map, which has no
142WebCrypto API. The cost is that envelopes are not `age`-compatible.
143
144The two implementations — `crates/anvil-core/src/secrets.rs` and the `SEAL_JS`
145string in `crates/anvil-web/src/secrets.rs` — are kept honest by
146`crates/anvil-web/tests/js_interop.rs`, which runs the browser's code under node
147and opens the result in Rust.