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```toml
63image = "alpine:3.20"
64secrets = ["DEPLOY_TOKEN"]
65
66[[steps]]
67run = 'curl -sf -H "Authorization: Bearer $DEPLOY_TOKEN" https://example.com/deploy'
68```
69
70anvil cannot open `DEPLOY_TOKEN` on its own, so the run fails immediately —
71before any container starts — unless you have unlocked the repository:
72
73```sh
74anvild secret unlock collin/anvil --ttl 8h
75```
76
77That command opens every envelope locally with your ssh key and hands the
78values to the server, which keeps them **in memory only**: no file, no database
79row, no log. They vanish when the TTL expires, when you run
80`anvild secret lock collin/anvil` (or press *Lock now* in settings), and on
81every restart or redeploy. Maximum TTL is seven days.
82
83Repository settings shows the current state — sealed, or unlocked with an
84expiry.
85
86## Adding a key: rekeying
87
88A secret is sealed to the key set that existed when it was written. Register a
89new ssh key and it cannot open anything older, which settings flags per secret
90(*"1 key(s) cannot open this — rekey"*). Fix it from a machine holding a key
91that *can* open them:
92
93```sh
94anvild secret rekey collin/anvil
95```
96
97This decrypts each secret locally and writes it back sealed to every currently
98registered ssh-ed25519 key. Nothing else can do this — the server cannot, by
99construction — so keep at least one working key until you have rekeyed.
100
101Only `ssh-ed25519` keys participate. RSA keys can authenticate pushes but not
102receive secrets (that would need a second scheme), and FIDO/`-sk` keys cannot do
103key agreement at all.
104
105## The envelope format (`anvil-secret-v1`)
106
107One random 256-bit *file key* per secret encrypts the value; the file key is
108wrapped once per recipient:
109
110```text
111file_key = 32 random bytes
112body = AES-256-GCM(file_key, nonce, value, aad)
113aad = "anvil-secret-v1\n{owner}/{repo}\n{NAME}"
114
115per recipient r:
116 esk, epk = fresh X25519 keypair
117 shared = X25519(esk, r.x25519)
118 wrap_key = HKDF-SHA256(ikm = shared, salt = epk ‖ r.x25519,
119 info = "anvil-secret-v1 wrap")
120 wrap = nonce ‖ AES-256-GCM(wrap_key, nonce, file_key, aad = r.fingerprint)
121```
122
123stored as JSON:
124
125```json
126{ "v": 1, "alg": "x25519-hkdf-sha256+aes256gcm",
127 "recipients": [{ "fp": "SHA256:…", "epk": "…", "wrap": "…" }],
128 "nonce": "…", "ct": "…" }
129```
130
131A recipient's X25519 public key is the birational map of their Ed25519 one; the
132matching secret is `clamp(SHA-512(seed)[..32])` — the same derivation age uses
133for `ssh-ed25519` recipients.
134
135The associated data binds each ciphertext to its repository *and* its variable
136name, so a stolen envelope cannot be replayed into another repo or re-pointed at
137a different variable.
138
139**Why AES-GCM and HKDF-SHA256 rather than age's ChaCha20-Poly1305:** the browser
140is a first-class encryptor here, and WebCrypto ships neither ChaCha nor a stream
141AEAD. Every primitive above is native in `crypto.subtle`; the only hand-written
142arithmetic on either side is the Edwards → Montgomery point map, which has no
143WebCrypto API. The cost is that envelopes are not `age`-compatible.
144
145The two implementations — `crates/anvil-core/src/secrets.rs` and the `SEAL_JS`
146string in `crates/anvil-web/src/secrets.rs` — are kept honest by
147`crates/anvil-web/tests/js_interop.rs`, which runs the browser's code under node
148and opens the result in Rust.