collin/anvil
RenderedSource
| 1 | # Repository secrets |
| 2 | |
| 3 | Per-repository secrets that anvil stores but cannot read. Values are encrypted |
| 4 | on your machine — in the browser, or by `anvild secret` — to the ssh-ed25519 |
| 5 | keys the repository owner has registered. What lands in the database is an |
| 6 | opaque envelope; the private half that opens it never leaves your laptop. |
| 7 | |
| 8 | CI is the one consumer that needs plaintext, and it only gets it while the |
| 9 | repository 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 | |
| 33 | The wider threat model for a multi-user instance lives in |
| 34 | [untrusted-mode.md](untrusted-mode.md). |
| 35 | |
| 36 | ## Setting a secret |
| 37 | |
| 38 | In the browser: **repository → settings → Secrets**. Type a name and a value, |
| 39 | press *Encrypt and save*. The page seals the value with WebCrypto before any |
| 40 | request is made — the plaintext never appears in a request body, a URL, or the |
| 41 | server's logs. (The form is deliberately not a `<form>` element, so there is no |
| 42 | default submission path that could send the value before the script runs.) |
| 43 | |
| 44 | From a terminal: |
| 45 | |
| 46 | ```sh |
| 47 | export ANVIL_SERVER=https://anvil.example.com ANVIL_USER=collin |
| 48 | printf '%s' "$TOKEN" | anvild secret set collin/anvil DEPLOY_TOKEN |
| 49 | anvild secret list collin/anvil |
| 50 | anvild secret get collin/anvil DEPLOY_TOKEN # needs your private key |
| 51 | ``` |
| 52 | |
| 53 | Names are environment-variable shaped: `A-Z`, `0-9`, `_`, not starting with a |
| 54 | digit. `anvild secret` talks to a running anvil over HTTP (Basic auth with your |
| 55 | account password, the same credential git-over-HTTPS pushes use), because the |
| 56 | crypto belongs on the machine holding your ssh key — usually not the server. |
| 57 | |
| 58 | ## Unlocking for CI |
| 59 | |
| 60 | A pipeline declares what it needs: |
| 61 | |
| 62 | ```yaml |
| 63 | image: alpine:3.20 |
| 64 | secrets: [DEPLOY_TOKEN] |
| 65 | steps: |
| 66 | - run: curl -sf -H "Authorization: Bearer $DEPLOY_TOKEN" https://example.com/deploy |
| 67 | ``` |
| 68 | |
| 69 | anvil cannot open `DEPLOY_TOKEN` on its own, so the run fails immediately — |
| 70 | before any container starts — unless you have unlocked the repository: |
| 71 | |
| 72 | ```sh |
| 73 | anvild secret unlock collin/anvil --ttl 8h |
| 74 | ``` |
| 75 | |
| 76 | That command opens every envelope locally with your ssh key and hands the |
| 77 | values to the server, which keeps them **in memory only**: no file, no database |
| 78 | row, 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 |
| 80 | every restart or redeploy. Maximum TTL is seven days. |
| 81 | |
| 82 | Repository settings shows the current state — sealed, or unlocked with an |
| 83 | expiry. |
| 84 | |
| 85 | ## Adding a key: rekeying |
| 86 | |
| 87 | A secret is sealed to the key set that existed when it was written. Register a |
| 88 | new 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 |
| 90 | that *can* open them: |
| 91 | |
| 92 | ```sh |
| 93 | anvild secret rekey collin/anvil |
| 94 | ``` |
| 95 | |
| 96 | This decrypts each secret locally and writes it back sealed to every currently |
| 97 | registered ssh-ed25519 key. Nothing else can do this — the server cannot, by |
| 98 | construction — so keep at least one working key until you have rekeyed. |
| 99 | |
| 100 | Only `ssh-ed25519` keys participate. RSA keys can authenticate pushes but not |
| 101 | receive secrets (that would need a second scheme), and FIDO/`-sk` keys cannot do |
| 102 | key agreement at all. |
| 103 | |
| 104 | ## The envelope format (`anvil-secret-v1`) |
| 105 | |
| 106 | One random 256-bit *file key* per secret encrypts the value; the file key is |
| 107 | wrapped once per recipient: |
| 108 | |
| 109 | ```text |
| 110 | file_key = 32 random bytes |
| 111 | body = AES-256-GCM(file_key, nonce, value, aad) |
| 112 | aad = "anvil-secret-v1\n{owner}/{repo}\n{NAME}" |
| 113 | |
| 114 | per 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 | |
| 122 | stored as JSON: |
| 123 | |
| 124 | ```json |
| 125 | { "v": 1, "alg": "x25519-hkdf-sha256+aes256gcm", |
| 126 | "recipients": [{ "fp": "SHA256:…", "epk": "…", "wrap": "…" }], |
| 127 | "nonce": "…", "ct": "…" } |
| 128 | ``` |
| 129 | |
| 130 | A recipient's X25519 public key is the birational map of their Ed25519 one; the |
| 131 | matching secret is `clamp(SHA-512(seed)[..32])` — the same derivation age uses |
| 132 | for `ssh-ed25519` recipients. |
| 133 | |
| 134 | The associated data binds each ciphertext to its repository *and* its variable |
| 135 | name, so a stolen envelope cannot be replayed into another repo or re-pointed at |
| 136 | a different variable. |
| 137 | |
| 138 | **Why AES-GCM and HKDF-SHA256 rather than age's ChaCha20-Poly1305:** the browser |
| 139 | is a first-class encryptor here, and WebCrypto ships neither ChaCha nor a stream |
| 140 | AEAD. Every primitive above is native in `crypto.subtle`; the only hand-written |
| 141 | arithmetic on either side is the Edwards → Montgomery point map, which has no |
| 142 | WebCrypto API. The cost is that envelopes are not `age`-compatible. |
| 143 | |
| 144 | The two implementations — `crates/anvil-core/src/secrets.rs` and the `SEAL_JS` |
| 145 | string 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 |
| 147 | and opens the result in Rust. |