anvilsign in

collin/anvil · 31844800

Add end-to-end encrypted per-repo secrets, sealed to ssh keys

Collin Richards · 2026-08-18 08:21 UTC · 318448007c8adcccb0175233cd6426d5d627439a · parent 5da15b54 · browse files

modifiedCargo.lock+43 −0
⋯ 145 unchanged lines
146146 "anvil-web",
147147 "anyhow",
148148 "clap",
149+ "reqwest",
150+ "rpassword",
151+ "rustls",
152+ "serde",
153+ "serde_json",
154+ "ssh-key",
155+ "time",
149156 "tokio",
150157 "tracing",
151158 "tracing-subscriber",
⋯ 3 unchanged lines
155162 name = "anvil-core"
156163 version = "0.0.0"
157164 dependencies = [
165+ "aes-gcm",
158166 "argon2 0.5.3",
159167 "async-trait",
168+ "base64",
169+ "curve25519-dalek",
160170 "gix",
161171 "hmac 0.12.1",
162172 "pulldown-cmark",
⋯ 45 unchanged lines
208218 dependencies = [
209219 "anvil-core",
210220 "anvil-git",
221+ "argon2 0.5.3",
211222 "axum",
212223 "axum-extra",
213224 "base64",
⋯ 3 unchanged lines
217228 "serde",
218229 "serde_json",
219230 "similar",
231+ "ssh-key",
220232 "syntect",
233+ "tempfile",
221234 "time",
222235 "tokio",
223236 "tokio-util",
⋯ 3272 unchanged lines
34963509 ]
34973510
34983511 [[package]]
3512+name = "rpassword"
3513+version = "7.5.4"
3514+source = "registry+https://github.com/rust-lang/crates.io-index"
3515+checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
3516+dependencies = [
3517+ "libc",
3518+ "rtoolbox",
3519+ "windows-sys 0.61.2",
3520+]
3521+
3522+[[package]]
34993523 name = "rsa"
35003524 version = "0.10.0-rc.18"
35013525 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 23 unchanged lines
35253549 ]
35263550
35273551 [[package]]
3552+name = "rtoolbox"
3553+version = "0.0.5"
3554+source = "registry+https://github.com/rust-lang/crates.io-index"
3555+checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
3556+dependencies = [
3557+ "libc",
3558+ "windows-sys 0.59.0",
3559+]
3560+
3561+[[package]]
35283562 name = "rusqlite"
35293563 version = "0.39.0"
35303564 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 1516 unchanged lines
50475081
50485082 [[package]]
50495083 name = "windows-sys"
5084+version = "0.59.0"
5085+source = "registry+https://github.com/rust-lang/crates.io-index"
5086+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
5087+dependencies = [
5088+ "windows-targets",
5089+]
5090+
5091+[[package]]
5092+name = "windows-sys"
50505093 version = "0.61.2"
50515094 source = "registry+https://github.com/rust-lang/crates.io-index"
50525095 checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
⋯ 299 unchanged lines
modifiedCargo.toml+13 −0
⋯ 21 unchanged lines
2222
2323 anyhow = "1"
2424 argon2 = { version = "0.5", features = ["std"] }
25+# Repo secrets (docs/secrets.md): sealed to users' ssh-ed25519 keys with
26+# X25519 + HKDF-SHA256 + AES-256-GCM. AES-GCM rather than ChaCha20-Poly1305
27+# because the *browser* is the encryptor and WebCrypto has no ChaCha.
28+# Both are pinned to the exact pre-releases already in the lockfile: cargo
29+# unifies each onto a single version tree-wide, and asking for the final
30+# release instead walks russh (and half of RustCrypto) *backwards* to a set
31+# that does not compile. X25519 comes from `MontgomeryPoint::mul_clamped`
32+# rather than the x25519-dalek wrapper, which would want its own
33+# curve25519-dalek.
34+aes-gcm = "=0.11.0-rc.4"
35+curve25519-dalek = "=5.0.0-rc.0"
2536 async-trait = "0.1"
2637 axum = "0.8"
2738 axum-extra = { version = "0.10", features = ["cookie"] }
⋯ 23 unchanged lines
5162 # Used directly only for idempotent schema shims on existing databases; the
5263 # version tracks what toasty-driver-sqlite already pulls in.
5364 rusqlite = "0.39"
65+# `anvild secret` prompts for an ssh key passphrase / an account password.
66+rpassword = "7"
5467 serde = { version = "1", features = ["derive"] }
5568 serde_json = "1"
5669 serde_yaml = "0.9"
⋯ 25 unchanged lines
modifiedREADME.md+8 −1
⋯ 24 unchanged lines
2525 cargo run -- serve # start the server
2626 ```
2727
28-Configuration is optional; see [`anvil.example.toml`](anvil.example.toml).
28+Configuration is optional; see [`anvil.example.toml`](anvil.example.toml).
29+
30+## Docs
31+
32+- [CI artifacts](docs/ci-artifacts.md)
33+- [Repository secrets](docs/secrets.md) — encrypted to your ssh keys in the
34+ browser; anvil stores ciphertext it cannot open
35+- [Threat model for untrusted users](docs/untrusted-mode.md)
modifiedTODO.md+5 −1
⋯ 28 unchanged lines
2929 from README.md on a periodic scan, cache the attachment hash, and display in
3030 repo listings for visual browsing
3131 - [ ] API tokens: a `write` scope (would need CSRF-exempt write paths) and
32- `last_used_at` tracking
32+ `last_used_at` tracking
33+- [ ] secrets follow-ups (docs/secrets.md): authenticate `anvild secret` with an
34+ ssh signature instead of the account password; per-step rather than per-
35+ pipeline scoping; `ssh-rsa` recipients (needs an RSA-OAEP branch in both the
36+ Rust and the browser halves); drop a repo's secrets when repo delete lands
modifiedcrates/anvil-ci/src/lib.rs+49 −1
⋯ 139 unchanged lines
140140 .join(run.repo_id.to_string())
141141 .join(format!(".collecting-{run_id}"));
142142
143+ // Secrets the pipeline asked for, from the in-memory vault. anvil holds no
144+ // key that opens the stored envelopes, so an unlock must have happened
145+ // (`anvild secret unlock`) or the run cannot proceed.
146+ let env = match app.vault.take(run.repo_id, &pipeline.secrets) {
147+ Ok(values) => values,
148+ Err(missing) => {
149+ log.push_str(&format!(
150+ "\n[secrets unavailable: {}]\n\
151+ This repository is sealed or was unlocked without them. Run:\n \
152+ anvild secret unlock {}/{}\n",
153+ missing.join(", "),
154+ owner.username,
155+ repo.name,
156+ ));
157+ ci::append_log(&app.db, run_id, &log).await.ok();
158+ ci::finish(&app.db, run_id, ci::status::ERROR).await.ok();
159+ tracing::warn!("ci: run {run_id} needs secrets but {} is sealed", repo.name);
160+ return Ok(());
161+ }
162+ };
163+ if !env.is_empty() {
164+ log.push_str(&format!(
165+ "secrets: {}\n",
166+ env.iter()
167+ .map(|(name, _)| name.as_str())
168+ .collect::<Vec<_>>()
169+ .join(", ")
170+ ));
171+ }
172+
143173 let (status, collected) =
144- match execute(&pipeline, tar, &mut log, &app.config.ci, &scratch).await {
174+ match execute(&pipeline, tar, &mut log, &app.config.ci, &scratch, &env).await {
145175 Ok((0, collected)) => (ci::status::SUCCESS, collected),
146176 Ok((code, collected)) => {
147177 log.push_str(&format!("\n[exited with status {code}]\n"));
⋯ 44 unchanged lines
192222 }
193223 let _ = std::fs::remove_dir_all(&scratch); // no-op when renamed away
194224
225+ // A step that echoes its environment (`set -x`, `curl -v`, a failing
226+ // command that prints its arguments) would otherwise publish the value on
227+ // a page anyone with read access can see.
228+ mask_secrets(&mut log, &env);
195229 ci::append_log(&app.db, run_id, &log).await.ok();
196230 ci::finish(&app.db, run_id, status).await.ok();
197231 tracing::info!("ci: run {run_id} {status}");
⋯ 136 unchanged lines
334368 /// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the
335369 /// wall-clock timeout, network access, the container user, and the image
336370 /// allowlist come from `cfg`.
371+/// Replace every secret value in `log` with `***`.
372+///
373+/// Only values worth hiding: very short ones (a one-character secret) would
374+/// mask half the log for no benefit, and are not credentials in practice.
375+fn mask_secrets(log: &mut String, env: &[(String, String)]) {
376+ for (_, value) in env {
377+ if value.len() >= 4 && log.contains(value.as_str()) {
378+ *log = log.replace(value.as_str(), "***");
379+ }
380+ }
381+}
382+
337383 async fn execute(
338384 pipeline: &Pipeline,
339385 tar: Vec<u8>,
340386 log: &mut String,
341387 cfg: &CiConfig,
342388 scratch: &Path,
389+ env: &[(String, String)],
343390 ) -> Result<(i64, Vec<Collected>), String> {
344391 if !cfg.image_allowed(&pipeline.image) {
345392 return Err(format!(
⋯ 65 unchanged lines
411458 let config = Config {
412459 image: Some(pipeline.image.clone()),
413460 cmd: Some(vec!["sh".to_string(), "-c".to_string(), script]),
461+ env: (!env.is_empty()).then(|| env.iter().map(|(k, v)| format!("{k}={v}")).collect()),
414462 working_dir: Some(WORKDIR.to_string()),
415463 user: (!cfg.run_as.is_empty()).then(|| cfg.run_as.clone()),
416464 host_config: Some(host_config),
⋯ 423 unchanged lines
modifiedcrates/anvil-cli/Cargo.toml+9 −0
⋯ 19 unchanged lines
2020 tokio.workspace = true
2121 clap.workspace = true
2222 anyhow.workspace = true
23+reqwest.workspace = true
24+rustls.workspace = true
25+rpassword.workspace = true
26+serde.workspace = true
27+serde_json.workspace = true
28+time.workspace = true
29+# `encryption` so a passphrase-protected private key can be opened locally —
30+# the CLI is the only half of anvil that ever holds a private key.
31+ssh-key = { workspace = true, features = ["encryption"] }
2332 tracing.workspace = true
2433 tracing-subscriber.workspace = true
modifiedcrates/anvil-cli/src/main.rs+21 −0
⋯ 16 unchanged lines
1717 Subcommand,
1818 };
1919
20+mod secret;
21+
2022 #[derive(Parser)]
2123 #[command(name = "anvild", version, about = "anvil git forge")]
2224 struct Cli {
⋯ 25 unchanged lines
4850 #[command(subcommand)]
4951 command: RepoCommand,
5052 },
53+ /// Manage a repository's end-to-end encrypted secrets (docs/secrets.md).
54+ ///
55+ /// Unlike the other subcommands these talk to a *running* anvil over
56+ /// HTTP rather than to the database, because the crypto belongs on the
57+ /// machine holding your ssh key — which is usually not the server.
58+ Secret {
59+ #[command(subcommand)]
60+ command: secret::SecretCommand,
61+ #[command(flatten)]
62+ opts: secret::SecretOpts,
63+ },
5164 }
5265
5366 #[derive(Subcommand)]
⋯ 82 unchanged lines
136149 Command::Migrate => migrate(config).await,
137150 Command::User { command } => user(config, command).await,
138151 Command::Repo { command } => repo(config, command).await,
152+ Command::Secret { command, opts } => {
153+ secret::run(command, &opts, &config.http.base_url).await
154+ }
139155 }
140156 }
141157
⋯ 23 unchanged lines
165181 Box::new(anvil_core::periodic::DiskUsageCacheJob)
166182 as Box<dyn anvil_core::periodic::PeriodicJob>,
167183 ),
184+ (
185+ std::time::Duration::from_secs(300),
186+ Box::new(anvil_core::periodic::SecretVaultSweepJob)
187+ as Box<dyn anvil_core::periodic::PeriodicJob>,
188+ ),
168189 ];
169190 anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await;
170191
⋯ 140 unchanged lines
addedcrates/anvil-cli/src/secret.rs+506 −0
1+//! `anvild secret` — the client half of repository secrets.
2+//!
3+//! Everything cryptographic happens here, on a machine that holds an ssh
4+//! private key. The server stores sealed envelopes it cannot open, so reading a
5+//! secret, re-sealing it for a newly added key, and unlocking a repository for
6+//! CI are all client operations. See `docs/secrets.md`.
7+
8+use std::{
9+ collections::BTreeMap,
10+ io::{
11+ IsTerminal,
12+ Read,
13+ },
14+ path::PathBuf,
15+};
16+
17+use anvil_core::secrets::{
18+ Envelope,
19+ Identity,
20+ Recipient,
21+ body_aad,
22+ seal,
23+};
24+use anyhow::{
25+ Context,
26+ Result,
27+ anyhow,
28+ bail,
29+};
30+use clap::Subcommand;
31+use serde::Deserialize;
32+
33+#[derive(Subcommand)]
34+pub enum SecretCommand {
35+ /// List a repository's secrets (names and key coverage, never values).
36+ List {
37+ /// Repository in `owner/name` form.
38+ repo: String,
39+ },
40+ /// Encrypt a value and store it. Reads the value from stdin unless
41+ /// `--value` is given.
42+ Set {
43+ repo: String,
44+ /// Variable name, e.g. `DEPLOY_TOKEN`.
45+ name: String,
46+ /// The value. Prefer stdin or the prompt: an argument is visible in
47+ /// `ps` output and lands in your shell history.
48+ #[arg(long)]
49+ value: Option<String>,
50+ },
51+ /// Decrypt and print one secret.
52+ Get { repo: String, name: String },
53+ /// Delete a secret.
54+ Rm { repo: String, name: String },
55+ /// Decrypt every secret and hand the values to the server, which holds
56+ /// them in memory (never on disk) so CI can use them until they expire.
57+ Unlock {
58+ repo: String,
59+ /// How long the unlock lasts, e.g. `8h`, `45m`, `7d`.
60+ #[arg(long, default_value = "8h")]
61+ ttl: String,
62+ },
63+ /// Forget the unlocked values on the server immediately.
64+ Lock { repo: String },
65+ /// Re-seal every secret to the owner's current ssh keys — run this after
66+ /// adding a key, which otherwise cannot open anything sealed before it.
67+ Rekey { repo: String },
68+}
69+
70+/// Connection and identity options shared by every `secret` subcommand.
71+#[derive(clap::Args)]
72+pub struct SecretOpts {
73+ /// anvil base URL. Defaults to `$ANVIL_SERVER`, then the config's
74+ /// `http.base_url`.
75+ #[arg(long, global = true)]
76+ pub server: Option<String>,
77+ /// Account username. Defaults to `$ANVIL_USER`.
78+ #[arg(long = "as", global = true)]
79+ pub username: Option<String>,
80+ /// SSH private key that opens the envelopes. Defaults to
81+ /// `$ANVIL_IDENTITY`, then `~/.ssh/id_ed25519`.
82+ #[arg(long, short = 'i', global = true)]
83+ pub identity: Option<PathBuf>,
84+}
85+
86+pub async fn run(command: SecretCommand, opts: &SecretOpts, config_base_url: &str) -> Result<()> {
87+ let client = Client::new(opts, config_base_url)?;
88+ match command {
89+ SecretCommand::List { repo } => list(&client, &repo).await,
90+ SecretCommand::Set { repo, name, value } => set(&client, opts, &repo, &name, value).await,
91+ SecretCommand::Get { repo, name } => get(&client, opts, &repo, &name).await,
92+ SecretCommand::Rm { repo, name } => {
93+ client.delete(&repo, &name).await?;
94+ println!("deleted {name} from {repo}");
95+ Ok(())
96+ }
97+ SecretCommand::Unlock { repo, ttl } => unlock(&client, opts, &repo, &ttl).await,
98+ SecretCommand::Lock { repo } => {
99+ client.lock(&repo).await?;
100+ println!("{repo} sealed — CI runs that declare secrets will fail until unlocked");
101+ Ok(())
102+ }
103+ SecretCommand::Rekey { repo } => rekey(&client, opts, &repo).await,
104+ }
105+}
106+
107+// --- commands --------------------------------------------------------------
108+
109+async fn list(client: &Client, repo: &str) -> Result<()> {
110+ let state = client.fetch(repo).await?;
111+ if state.secrets.is_empty() {
112+ println!("{repo} has no secrets.");
113+ }
114+ let current: Vec<&str> = state
115+ .recipients
116+ .iter()
117+ .map(|r| r.fingerprint.as_str())
118+ .collect();
119+ for secret in &state.secrets {
120+ let missing = current
121+ .iter()
122+ .filter(|fp| !secret.recipients.iter().any(|s| s == **fp))
123+ .count();
124+ let note = if missing > 0 {
125+ format!(
126+ " — {missing} registered key(s) cannot open it; run `anvild secret rekey {repo}`"
127+ )
128+ } else {
129+ String::new()
130+ };
131+ println!(
132+ "{:<24} sealed to {} key(s){note}",
133+ secret.name,
134+ secret.recipients.len()
135+ );
136+ }
137+ match state.unlocked_until {
138+ 0 => println!("\nsealed (CI cannot read these)"),
139+ until => println!("\nunlocked for CI until {}", fmt_time(until)),
140+ }
141+ Ok(())
142+}
143+
144+async fn set(
145+ client: &Client,
146+ opts: &SecretOpts,
147+ repo: &str,
148+ name: &str,
149+ value: Option<String>,
150+) -> Result<()> {
151+ if !anvil_core::secrets::valid_name(name) {
152+ bail!("secret names are A–Z, 0–9 and _, and cannot start with a digit");
153+ }
154+ let state = client.fetch(repo).await?;
155+ let recipients = state.recipient_keys()?;
156+ let value = match value {
157+ Some(v) => v,
158+ None if std::io::stdin().is_terminal() => {
159+ rpassword::prompt_password(format!("value for {name}: "))?
160+ }
161+ None => {
162+ let mut buf = String::new();
163+ std::io::stdin().read_to_string(&mut buf)?;
164+ // A here-doc or `echo` adds a newline that is never part of a token.
165+ buf.trim_end_matches('\n').to_string()
166+ }
167+ };
168+ let (owner, name_only) = split_repo(repo)?;
169+ let envelope = seal(
170+ value.as_bytes(),
171+ &body_aad(owner, name_only, name),
172+ &recipients,
173+ )?;
174+ client.put(repo, name, &envelope).await?;
175+ println!(
176+ "sealed {name} to {} key(s) in {repo}",
177+ envelope.recipients.len()
178+ );
179+ if state.unlocked_until > 0 {
180+ println!(
181+ "note: {repo} is unlocked with the *old* set — re-run `anvild secret unlock` for CI to see this value"
182+ );
183+ }
184+ // `opts` participates only through the client; the identity is not needed
185+ // to seal, which is the point of a public-key scheme.
186+ let _ = opts;
187+ Ok(())
188+}
189+
190+async fn get(client: &Client, opts: &SecretOpts, repo: &str, name: &str) -> Result<()> {
191+ let state = client.fetch(repo).await?;
192+ let identity = load_identity(opts)?;
193+ let (owner, repo_name) = split_repo(repo)?;
194+ let secret = state
195+ .secrets
196+ .iter()
197+ .find(|s| s.name == name)
198+ .ok_or_else(|| anyhow!("{repo} has no secret named {name}"))?;
199+ let envelope = secret.parse()?;
200+ let plaintext = envelope.open(&body_aad(owner, repo_name, name), &identity)?;
201+ print!("{}", String::from_utf8_lossy(&plaintext));
202+ Ok(())
203+}
204+
205+async fn unlock(client: &Client, opts: &SecretOpts, repo: &str, ttl: &str) -> Result<()> {
206+ let state = client.fetch(repo).await?;
207+ if state.secrets.is_empty() {
208+ bail!("{repo} has no secrets to unlock");
209+ }
210+ let identity = load_identity(opts)?;
211+ let (owner, repo_name) = split_repo(repo)?;
212+ let mut values = BTreeMap::new();
213+ for secret in &state.secrets {
214+ let envelope = secret.parse()?;
215+ let plaintext = envelope
216+ .open(&body_aad(owner, repo_name, &secret.name), &identity)
217+ .with_context(|| format!("opening {}", secret.name))?;
218+ values.insert(
219+ secret.name.clone(),
220+ String::from_utf8(plaintext)
221+ .with_context(|| format!("{} is not valid UTF-8", secret.name))?,
222+ );
223+ }
224+ let response = client.unlock(repo, values, parse_ttl(ttl)?).await?;
225+ println!(
226+ "unlocked {repo} with {} value(s) until {} — held in memory only, and lost on restart",
227+ response.count,
228+ fmt_time(response.unlocked_until)
229+ );
230+ Ok(())
231+}
232+
233+async fn rekey(client: &Client, opts: &SecretOpts, repo: &str) -> Result<()> {
234+ let state = client.fetch(repo).await?;
235+ let recipients = state.recipient_keys()?;
236+ let identity = load_identity(opts)?;
237+ let (owner, repo_name) = split_repo(repo)?;
238+ let mut rekeyed = 0;
239+ for secret in &state.secrets {
240+ let current: Vec<String> = recipients.iter().map(|r| r.fingerprint.clone()).collect();
241+ if current.len() == secret.recipients.len()
242+ && current.iter().all(|fp| secret.recipients.contains(fp))
243+ {
244+ continue; // already sealed to exactly the current key set
245+ }
246+ let aad = body_aad(owner, repo_name, &secret.name);
247+ let plaintext = secret
248+ .parse()?
249+ .open(&aad, &identity)
250+ .with_context(|| format!("opening {}", secret.name))?;
251+ let resealed = seal(&plaintext, &aad, &recipients)?;
252+ client.put(repo, &secret.name, &resealed).await?;
253+ println!("re-sealed {} to {} key(s)", secret.name, recipients.len());
254+ rekeyed += 1;
255+ }
256+ if rekeyed == 0 {
257+ println!("nothing to do — every secret is already sealed to the current keys");
258+ }
259+ Ok(())
260+}
261+
262+// --- identity --------------------------------------------------------------
263+
264+fn load_identity(opts: &SecretOpts) -> Result<Identity> {
265+ let path = opts
266+ .identity
267+ .clone()
268+ .or_else(|| std::env::var("ANVIL_IDENTITY").ok().map(PathBuf::from))
269+ .or_else(|| {
270+ std::env::var("HOME")
271+ .ok()
272+ .map(|home| PathBuf::from(home).join(".ssh/id_ed25519"))
273+ })
274+ .ok_or_else(|| anyhow!("no ssh key given; pass --identity"))?;
275+
276+ let key = ssh_key::PrivateKey::read_openssh_file(&path)
277+ .with_context(|| format!("reading ssh key {}", path.display()))?;
278+ let key = if key.is_encrypted() {
279+ let passphrase =
280+ rpassword::prompt_password(format!("passphrase for {}: ", path.display()))?;
281+ key.decrypt(passphrase)
282+ .with_context(|| format!("decrypting {}", path.display()))?
283+ } else {
284+ key
285+ };
286+ Ok(Identity::from_private_key(&key)?)
287+}
288+
289+// --- HTTP client -----------------------------------------------------------
290+
291+struct Client {
292+ base: String,
293+ username: String,
294+ password: String,
295+ http: reqwest::Client,
296+}
297+
298+#[derive(Deserialize)]
299+struct SecretsState {
300+ unlocked_until: i64,
301+ recipients: Vec<RecipientJson>,
302+ secrets: Vec<SecretJson>,
303+}
304+
305+#[derive(Deserialize)]
306+struct RecipientJson {
307+ fingerprint: String,
308+ key: String,
309+}
310+
311+#[derive(Deserialize)]
312+struct SecretJson {
313+ name: String,
314+ envelope: serde_json::Value,
315+ recipients: Vec<String>,
316+}
317+
318+#[derive(Deserialize)]
319+struct UnlockResponse {
320+ unlocked_until: i64,
321+ count: usize,
322+}
323+
324+impl SecretsState {
325+ fn recipient_keys(&self) -> Result<Vec<Recipient>> {
326+ if self.recipients.is_empty() {
327+ bail!("the repository owner has no ssh-ed25519 key registered — add one first");
328+ }
329+ self.recipients
330+ .iter()
331+ .map(|r| Recipient::from_openssh(&r.key).map_err(Into::into))
332+ .collect()
333+ }
334+}
335+
336+impl SecretJson {
337+ fn parse(&self) -> Result<Envelope> {
338+ Ok(Envelope::parse(&serde_json::to_string(&self.envelope)?)?)
339+ }
340+}
341+
342+impl Client {
343+ fn new(opts: &SecretOpts, config_base_url: &str) -> Result<Self> {
344+ // HTTPS needs a crypto provider installed; the build deliberately has
345+ // only ring (see the workspace manifest).
346+ let _ = rustls::crypto::ring::default_provider().install_default();
347+
348+ let base = opts
349+ .server
350+ .clone()
351+ .or_else(|| std::env::var("ANVIL_SERVER").ok())
352+ .unwrap_or_else(|| config_base_url.to_string());
353+ if base.is_empty() {
354+ bail!("no server URL; pass --server or set ANVIL_SERVER");
355+ }
356+ let username = opts
357+ .username
358+ .clone()
359+ .or_else(|| std::env::var("ANVIL_USER").ok())
360+ .ok_or_else(|| anyhow!("no username; pass --as or set ANVIL_USER"))?;
361+ let password = match std::env::var("ANVIL_PASSWORD") {
362+ Ok(p) => p,
363+ Err(_) => rpassword::prompt_password(format!("anvil password for {username}: "))?,
364+ };
365+ Ok(Self {
366+ base: base.trim_end_matches('/').to_string(),
367+ username,
368+ password,
369+ http: reqwest::Client::new(),
370+ })
371+ }
372+
373+ fn url(&self, repo: &str, suffix: &str) -> String {
374+ format!("{}/{repo}/-/api/secrets{suffix}", self.base)
375+ }
376+
377+ async fn fetch(&self, repo: &str) -> Result<SecretsState> {
378+ split_repo(repo)?;
379+ let response = self
380+ .http
381+ .get(self.url(repo, ""))
382+ .basic_auth(&self.username, Some(&self.password))
383+ .send()
384+ .await
385+ .context("contacting anvil")?;
386+ check(response).await?.json().await.context("reading reply")
387+ }
388+
389+ async fn put(&self, repo: &str, name: &str, envelope: &Envelope) -> Result<()> {
390+ let response = self
391+ .http
392+ .post(self.url(repo, ""))
393+ .basic_auth(&self.username, Some(&self.password))
394+ .json(&serde_json::json!({ "name": name, "envelope": envelope }))
395+ .send()
396+ .await
397+ .context("contacting anvil")?;
398+ check(response).await?;
399+ Ok(())
400+ }
401+
402+ async fn delete(&self, repo: &str, name: &str) -> Result<()> {
403+ let response = self
404+ .http
405+ .delete(self.url(repo, &format!("/{name}")))
406+ .basic_auth(&self.username, Some(&self.password))
407+ .send()
408+ .await
409+ .context("contacting anvil")?;
410+ check(response).await?;
411+ Ok(())
412+ }
413+
414+ async fn unlock(
415+ &self,
416+ repo: &str,
417+ values: BTreeMap<String, String>,
418+ ttl_secs: i64,
419+ ) -> Result<UnlockResponse> {
420+ let response = self
421+ .http
422+ .post(self.url(repo, "/unlock"))
423+ .basic_auth(&self.username, Some(&self.password))
424+ .json(&serde_json::json!({ "values": values, "ttl_secs": ttl_secs }))
425+ .send()
426+ .await
427+ .context("contacting anvil")?;
428+ check(response).await?.json().await.context("reading reply")
429+ }
430+
431+ async fn lock(&self, repo: &str) -> Result<()> {
432+ let response = self
433+ .http
434+ .post(self.url(repo, "/lock"))
435+ .basic_auth(&self.username, Some(&self.password))
436+ .send()
437+ .await
438+ .context("contacting anvil")?;
439+ check(response).await?;
440+ Ok(())
441+ }
442+}
443+
444+async fn check(response: reqwest::Response) -> Result<reqwest::Response> {
445+ if response.status().is_success() {
446+ return Ok(response);
447+ }
448+ let status = response.status();
449+ let body = response.text().await.unwrap_or_default();
450+ bail!("anvil returned {status}: {}", body.trim())
451+}
452+
453+// --- small helpers ---------------------------------------------------------
454+
455+fn split_repo(repo: &str) -> Result<(&str, &str)> {
456+ repo.split_once('/')
457+ .filter(|(o, n)| !o.is_empty() && !n.is_empty() && !n.contains('/'))
458+ .ok_or_else(|| anyhow!("expected a repository as `owner/name`, got `{repo}`"))
459+}
460+
461+/// Parse `30m` / `8h` / `7d` (bare digits are seconds) into seconds.
462+fn parse_ttl(ttl: &str) -> Result<i64> {
463+ let (digits, multiplier) = match ttl.chars().last() {
464+ Some('s') => (&ttl[..ttl.len() - 1], 1),
465+ Some('m') => (&ttl[..ttl.len() - 1], 60),
466+ Some('h') => (&ttl[..ttl.len() - 1], 3600),
467+ Some('d') => (&ttl[..ttl.len() - 1], 86400),
468+ _ => (ttl, 1),
469+ };
470+ let n: i64 = digits
471+ .parse()
472+ .with_context(|| format!("bad --ttl `{ttl}` (try 45m, 8h, 7d)"))?;
473+ Ok(n * multiplier)
474+}
475+
476+fn fmt_time(unix: i64) -> String {
477+ time::OffsetDateTime::from_unix_timestamp(unix)
478+ .ok()
479+ .and_then(|t| {
480+ t.format(&time::format_description::well_known::Rfc3339)
481+ .ok()
482+ })
483+ .unwrap_or_else(|| unix.to_string())
484+}
485+
486+#[cfg(test)]
487+mod tests {
488+ use super::*;
489+
490+ #[test]
491+ fn parses_ttls() {
492+ assert_eq!(parse_ttl("45m").unwrap(), 2700);
493+ assert_eq!(parse_ttl("8h").unwrap(), 28800);
494+ assert_eq!(parse_ttl("7d").unwrap(), 604800);
495+ assert_eq!(parse_ttl("90").unwrap(), 90);
496+ assert!(parse_ttl("soon").is_err());
497+ }
498+
499+ #[test]
500+ fn splits_repository_references() {
501+ assert_eq!(split_repo("collin/anvil").unwrap(), ("collin", "anvil"));
502+ assert!(split_repo("anvil").is_err());
503+ assert!(split_repo("collin/anvil/extra").is_err());
504+ assert!(split_repo("/anvil").is_err());
505+ }
506+}
modifiedcrates/anvil-core/Cargo.toml+7 −0
⋯ 12 unchanged lines
1313 toasty.workspace = true
1414 rusqlite.workspace = true
1515 argon2.workspace = true
16+aes-gcm.workspace = true
17+curve25519-dalek.workspace = true
18+base64.workspace = true
1619 hmac.workspace = true
1720 sha2.workspace = true
1821 ssh-key.workspace = true
⋯ 9 unchanged lines
2831 [dev-dependencies]
2932 tokio = { workspace = true }
3033 tempfile = "3"
34+# Tests mint throwaway ssh keys; the `ed25519` feature is what derives a public
35+# key from a seed. The library itself only ever parses keys, so it does not
36+# need it.
37+ssh-key = { workspace = true, features = ["ed25519"] }
modifiedcrates/anvil-core/src/ci.rs+13 −0
⋯ 35 unchanged lines
3636 pub steps: Vec<Step>,
3737 #[serde(default)]
3838 pub artifacts: Vec<ArtifactSpec>,
39+ /// Names of repository secrets to expose as environment variables (see
40+ /// `docs/secrets.md`). The run fails before starting a container unless
41+ /// every one of them is available, which requires the repository to be
42+ /// unlocked — anvil cannot decrypt them by itself.
43+ #[serde(default)]
44+ pub secrets: Vec<String>,
3945 }
4046
4147 /// A declared artifact: a path in the workspace to collect after the steps
⋯ 84 unchanged lines
126132 }
127133 }
128134 }
135+ for name in &pipeline.secrets {
136+ if !crate::secrets::valid_name(name) {
137+ return Err(Error::Invalid(format!(
138+ "{PIPELINE_PATH}: secret `{name}` must be A–Z, 0–9 and _, not starting with a digit"
139+ )));
140+ }
141+ }
129142 Ok(pipeline)
130143 }
131144
⋯ 363 unchanged lines
modifiedcrates/anvil-core/src/db.rs+15 −1
⋯ 11 unchanged lines
1212 CiRun,
1313 Issue,
1414 IssueComment,
15+ RepoSecret,
1516 Repository,
1617 Session,
1718 SshKey,
⋯ 27 unchanged lines
4546 IssueComment,
4647 Attachment,
4748 ApiToken,
48- AdminCache
49+ AdminCache,
50+ RepoSecret
4951 ))
5052 .connect(&url)
5153 .await?;
⋯ 24 unchanged lines
7678 r#"CREATE INDEX IF NOT EXISTS "index_api_tokens_by_user_id" ON "api_tokens" ("user_id")"#,
7779 r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_api_tokens_by_token_hash" ON "api_tokens" ("token_hash")"#,
7880 ADMIN_CACHE_DDL,
81+ REPO_SECRETS_DDL,
82+ r#"CREATE INDEX IF NOT EXISTS "index_repo_secrets_by_repo_id" ON "repo_secrets" ("repo_id")"#,
7983 ];
8084
8185 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 43 unchanged lines
125129 "scopes" TEXT NOT NULL,
126130 "created_at" BIGINT NOT NULL )"#;
127131
132+const REPO_SECRETS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "repo_secrets" (
133+"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
134+"repo_id" BIGINT NOT NULL,
135+"name" TEXT NOT NULL,
136+"envelope" TEXT NOT NULL,
137+"recipients" TEXT NOT NULL,
138+"created_at" BIGINT NOT NULL,
139+"updated_at" BIGINT NOT NULL )"#;
140+
128141 const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" (
129142 "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
130143 "key" TEXT NOT NULL,
⋯ 65 unchanged lines
196209 "issue_comments",
197210 "attachments",
198211 "api_tokens",
212+ "repo_secrets",
199213 ];
200214
201215 /// Every schema object (table + indexes) for `table`, normalized.
⋯ 118 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+6 −0
⋯ 18 unchanged lines
1919 pub mod periodic;
2020 pub mod preview_images;
2121 pub mod repos;
22+pub mod secrets;
2223 pub mod sessions;
2324 pub mod ssh_keys;
2425 pub mod storage;
⋯ 12 unchanged lines
3738 CiRun,
3839 Issue,
3940 IssueComment,
41+ RepoSecret,
4042 Repository,
4143 Session,
4244 SshKey,
⋯ 19 unchanged lines
6264 /// Notifies the CI runner of newly-enqueued run ids. `None` until the runner
6365 /// is started (e.g. CLI commands don't run CI). Use [`App::notify_ci`].
6466 pub ci_tx: Option<tokio::sync::mpsc::UnboundedSender<i64>>,
67+ /// Plaintext repo secrets for CI, held in memory only and lost on
68+ /// restart — see [`secrets::Vault`].
69+ pub vault: secrets::Vault,
6570 /// Server-wide secret keying CSRF tokens. Persisted in the data dir so
6671 /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap.
6772 csrf_secret: std::sync::Arc<[u8; 32]>,
⋯ 13 unchanged lines
8186 config,
8287 db,
8388 ci_tx: None,
89+ vault: secrets::Vault::default(),
8490 csrf_secret,
8591 })
8692 }
⋯ 52 unchanged lines
modifiedcrates/anvil-core/src/models.rs+24 −0
⋯ 214 unchanged lines
215215 pub created_at: i64,
216216 }
217217
218+/// A per-repository secret, stored only as a sealed envelope.
219+///
220+/// The server cannot read `envelope`: it is encrypted to the owner's
221+/// ssh-ed25519 keys by the client that set it (see [`crate::secrets`]).
222+/// `recipients` denormalizes the envelope's fingerprints so the UI can tell,
223+/// without opening anything, which secrets a newly registered key still cannot
224+/// decrypt — those need `anvild secret rekey`.
225+#[derive(Clone, Debug, toasty::Model)]
226+pub struct RepoSecret {
227+ #[key]
228+ #[auto]
229+ pub id: i64,
230+ #[index]
231+ pub repo_id: i64,
232+ /// Environment variable name, e.g. `DEPLOY_TOKEN`. Unique per repository.
233+ pub name: String,
234+ /// The sealed envelope, as JSON (`anvil-secret-v1`).
235+ pub envelope: String,
236+ /// Comma-separated SSH fingerprints the envelope is sealed to.
237+ pub recipients: String,
238+ pub created_at: i64,
239+ pub updated_at: i64,
240+}
241+
218242 /// Cached admin metrics computed periodically (e.g., disk usage snapshot).
219243 #[derive(Clone, Debug, toasty::Model)]
220244 pub struct AdminCache {
⋯ 10 unchanged lines
modifiedcrates/anvil-core/src/periodic.rs+19 −0
⋯ 162 unchanged lines
163163 });
164164 }
165165
166+/// Job that drops expired repo-secret unlocks from memory.
167+///
168+/// [`crate::secrets::Vault`] already treats an expired entry as sealed on
169+/// every read; this only stops the plaintext from sitting in the process's
170+/// memory until something happens to look at it.
171+pub struct SecretVaultSweepJob;
172+
173+#[async_trait::async_trait]
174+impl PeriodicJob for SecretVaultSweepJob {
175+ async fn run(&self, app: &App) -> Result<()> {
176+ app.vault.sweep();
177+ Ok(())
178+ }
179+
180+ fn name(&self) -> &str {
181+ "secret_vault_sweep"
182+ }
183+}
184+
166185 /// Try to extract the first image URL from a repository's README file.
167186 /// Scans the repository for a README file, reads it, and returns the first image URL found.
168187 async fn extract_readme_image(repo_path: &Path) -> Option<String> {
⋯ 21 unchanged lines
addedcrates/anvil-core/src/secrets.rs+721 −0
1+//! Per-repository secrets, sealed to the owner's ssh-ed25519 keys.
2+//!
3+//! anvil stores only sealed envelopes: the plaintext is encrypted by the
4+//! *client* (the browser's WebCrypto, or the CLI) to every ssh-ed25519 key the
5+//! repository owner has registered, so nothing on disk — database, backup,
6+//! snapshot — can be opened by the server on its own. See `docs/secrets.md`
7+//! for the threat model and the CI unlock flow.
8+//!
9+//! # Envelope format (`anvil-secret-v1`)
10+//!
11+//! One random 256-bit *file key* per secret encrypts the value; that file key
12+//! is then wrapped once per recipient key:
13+//!
14+//! ```text
15+//! file_key = 32 random bytes
16+//! body = AES-256-GCM(file_key, nonce, value, aad = body_aad())
17+//! per recipient r:
18+//! epk, esk = fresh X25519 keypair
19+//! shared = X25519(esk, r.x25519)
20+//! wrap_key = HKDF-SHA256(ikm = shared, salt = epk ‖ r.x25519, info = INFO)
21+//! wrap = nonce ‖ AES-256-GCM(wrap_key, nonce, file_key, aad = r.fingerprint)
22+//! ```
23+//!
24+//! The recipient's X25519 public key is the birational map of their Ed25519
25+//! one; the matching secret is `clamp(SHA-512(seed)[..32])`, exactly as age
26+//! derives them for `ssh-ed25519` recipients.
27+//!
28+//! AES-GCM and HKDF-SHA256 (rather than age's ChaCha20-Poly1305) because the
29+//! browser is a first-class encryptor here and WebCrypto ships neither ChaCha
30+//! nor a stream AEAD — every primitive above is native in `crypto.subtle`.
31+
32+use aes_gcm::{
33+ Aes256Gcm,
34+ KeyInit,
35+ aead::{
36+ Aead,
37+ Payload,
38+ },
39+};
40+use base64::Engine;
41+use serde::{
42+ Deserialize,
43+ Serialize,
44+};
45+use sha2::{
46+ Digest,
47+ Sha512,
48+};
49+
50+use crate::{
51+ error::{
52+ Error,
53+ Result,
54+ },
55+ models::RepoSecret,
56+};
57+
58+/// Algorithm identifier carried in every envelope.
59+pub const ALG: &str = "x25519-hkdf-sha256+aes256gcm";
60+
61+/// HKDF `info` string binding derived wrap keys to this scheme.
62+const WRAP_INFO: &[u8] = b"anvil-secret-v1 wrap";
63+
64+/// Cap on a secret's plaintext. Environment variables, not blobs.
65+pub const MAX_VALUE_BYTES: usize = 64 * 1024;
66+
67+/// Cap on a stored envelope: the value plus per-recipient overhead, base64'd,
68+/// with room for a generous number of keys.
69+pub const MAX_ENVELOPE_BYTES: usize = 256 * 1024;
70+
71+fn b64() -> base64::engine::general_purpose::GeneralPurpose {
72+ base64::engine::general_purpose::STANDARD
73+}
74+
75+fn decode_b64(what: &str, s: &str) -> Result<Vec<u8>> {
76+ b64()
77+ .decode(s)
78+ .map_err(|e| Error::Invalid(format!("secret envelope: bad base64 in {what}: {e}")))
79+}
80+
81+fn decode_array<const N: usize>(what: &str, s: &str) -> Result<[u8; N]> {
82+ let bytes = decode_b64(what, s)?;
83+ <[u8; N]>::try_from(bytes.as_slice())
84+ .map_err(|_| Error::Invalid(format!("secret envelope: {what} must be {N} bytes")))
85+}
86+
87+/// A sealed secret value: the encrypted body plus one wrapped file key per
88+/// recipient. Serialized as JSON, which is what both the browser and the CLI
89+/// hand to the server.
90+#[derive(Clone, Debug, Deserialize, Serialize)]
91+pub struct Envelope {
92+ pub v: u32,
93+ pub alg: String,
94+ pub recipients: Vec<Stanza>,
95+ /// Base64 12-byte AES-GCM nonce for the body.
96+ pub nonce: String,
97+ /// Base64 AES-GCM ciphertext ‖ tag of the value.
98+ pub ct: String,
99+}
100+
101+/// One recipient's wrapped copy of the file key.
102+#[derive(Clone, Debug, Deserialize, Serialize)]
103+pub struct Stanza {
104+ /// The recipient key's canonical SSH fingerprint (`SHA256:…`).
105+ pub fp: String,
106+ /// Base64 32-byte ephemeral X25519 public key.
107+ pub epk: String,
108+ /// Base64 12-byte nonce ‖ AES-GCM ciphertext of the 32-byte file key.
109+ pub wrap: String,
110+}
111+
112+impl Envelope {
113+ /// Parse and structurally validate an envelope received from a client.
114+ pub fn parse(json: &str) -> Result<Self> {
115+ if json.len() > MAX_ENVELOPE_BYTES {
116+ return Err(Error::Invalid("secret envelope too large".into()));
117+ }
118+ let env: Envelope = serde_json::from_str(json)
119+ .map_err(|e| Error::Invalid(format!("secret envelope: {e}")))?;
120+ env.validate()?;
121+ Ok(env)
122+ }
123+
124+ /// Check the parts the *server* can check: version, algorithm, and that
125+ /// every field decodes to the right length. It cannot check the
126+ /// ciphertext — that is the whole point.
127+ pub fn validate(&self) -> Result<()> {
128+ if self.v != 1 || self.alg != ALG {
129+ return Err(Error::Invalid(format!(
130+ "secret envelope: unsupported version/algorithm ({}/{})",
131+ self.v, self.alg
132+ )));
133+ }
134+ if self.recipients.is_empty() {
135+ return Err(Error::Invalid("secret envelope: no recipients".into()));
136+ }
137+ decode_array::<12>("nonce", &self.nonce)?;
138+ if decode_b64("ct", &self.ct)?.len() < 16 {
139+ return Err(Error::Invalid("secret envelope: body too short".into()));
140+ }
141+ for r in &self.recipients {
142+ if !r.fp.starts_with("SHA256:") {
143+ return Err(Error::Invalid(
144+ "secret envelope: recipient fingerprint must be SHA256:…".into(),
145+ ));
146+ }
147+ decode_array::<32>("epk", &r.epk)?;
148+ if decode_b64("wrap", &r.wrap)?.len() != 12 + 32 + 16 {
149+ return Err(Error::Invalid("secret envelope: bad wrapped key".into()));
150+ }
151+ }
152+ Ok(())
153+ }
154+
155+ /// The fingerprints this envelope can be opened by, in order.
156+ pub fn recipient_fingerprints(&self) -> Vec<String> {
157+ self.recipients.iter().map(|r| r.fp.clone()).collect()
158+ }
159+
160+ /// Decrypt with `identity`, which must be one of the recipients.
161+ pub fn open(&self, aad: &[u8], identity: &Identity) -> Result<Vec<u8>> {
162+ self.validate()?;
163+ let stanza = self
164+ .recipients
165+ .iter()
166+ .find(|r| r.fp == identity.fingerprint)
167+ .ok_or_else(|| {
168+ Error::Invalid(format!(
169+ "secret is not sealed to {} — rekey it first",
170+ identity.fingerprint
171+ ))
172+ })?;
173+
174+ let epk = decode_array::<32>("epk", &stanza.epk)?;
175+ let shared = x25519(&identity.secret, &epk);
176+ if shared.iter().all(|b| *b == 0) {
177+ return Err(Error::Invalid(
178+ "secret envelope: degenerate key exchange".into(),
179+ ));
180+ }
181+ let mut salt = [0u8; 64];
182+ salt[..32].copy_from_slice(&epk);
183+ salt[32..].copy_from_slice(&identity.public);
184+ let wrap_key = hkdf_sha256(&shared, &salt, WRAP_INFO);
185+
186+ let wrap = decode_b64("wrap", &stanza.wrap)?;
187+ let wrap_nonce = <[u8; 12]>::try_from(&wrap[..12])
188+ .map_err(|_| Error::Invalid("secret envelope: bad wrap nonce".into()))?;
189+ let file_key = aes_open(&wrap_key, &wrap_nonce, &wrap[12..], stanza.fp.as_bytes())
190+ .map_err(|_| Error::Invalid("secret envelope: wrapped key did not open".into()))?;
191+ let file_key = <[u8; 32]>::try_from(file_key.as_slice())
192+ .map_err(|_| Error::Invalid("secret envelope: bad file key".into()))?;
193+
194+ let nonce = decode_array::<12>("nonce", &self.nonce)?;
195+ let ct = decode_b64("ct", &self.ct)?;
196+ aes_open(&file_key, &nonce, &ct, aad)
197+ .map_err(|_| Error::Invalid("secret envelope: body did not open".into()))
198+ }
199+}
200+
201+/// A key a secret can be sealed *to*: an ssh-ed25519 public key mapped onto
202+/// Curve25519.
203+#[derive(Clone, Debug)]
204+pub struct Recipient {
205+ pub fingerprint: String,
206+ pub x25519: [u8; 32],
207+}
208+
209+impl Recipient {
210+ /// Build a recipient from a registered OpenSSH public-key line. Only
211+ /// `ssh-ed25519` keys can receive secrets: RSA would need a second
212+ /// scheme, and `*-sk` (FIDO) keys cannot do key agreement at all.
213+ pub fn from_openssh(line: &str) -> Result<Self> {
214+ let key = ssh_key::PublicKey::from_openssh(line.trim())
215+ .map_err(|e| Error::Invalid(format!("invalid ssh public key: {e}")))?;
216+ let ed = key.key_data().ed25519().ok_or_else(|| {
217+ Error::Invalid(format!(
218+ "{} keys cannot receive secrets — register an ssh-ed25519 key",
219+ key.algorithm().as_str()
220+ ))
221+ })?;
222+ Ok(Self {
223+ fingerprint: key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
224+ x25519: ed25519_public_to_x25519(&ed.0)?,
225+ })
226+ }
227+}
228+
229+/// The private half: what the CLI holds to open envelopes.
230+#[derive(Clone)]
231+pub struct Identity {
232+ pub fingerprint: String,
233+ secret: [u8; 32],
234+ public: [u8; 32],
235+}
236+
237+impl std::fmt::Debug for Identity {
238+ /// Never render the secret scalar.
239+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240+ f.debug_struct("Identity")
241+ .field("fingerprint", &self.fingerprint)
242+ .finish_non_exhaustive()
243+ }
244+}
245+
246+impl Identity {
247+ /// Derive an identity from a decrypted OpenSSH private key.
248+ pub fn from_private_key(key: &ssh_key::PrivateKey) -> Result<Self> {
249+ let ed = key.key_data().ed25519().ok_or_else(|| {
250+ Error::Invalid(format!(
251+ "{} private keys cannot open secrets — use an ssh-ed25519 key",
252+ key.algorithm().as_str()
253+ ))
254+ })?;
255+ let secret = ed25519_seed_to_x25519(ed.private.as_ref());
256+ Ok(Self {
257+ fingerprint: key
258+ .public_key()
259+ .fingerprint(ssh_key::HashAlg::Sha256)
260+ .to_string(),
261+ public: ed25519_public_to_x25519(&ed.public.0)?,
262+ secret,
263+ })
264+ }
265+}
266+
267+/// Seal `plaintext` to every recipient. Mirrors `sealSecret()` in the
268+/// browser's `secrets.js` byte for byte — the interop test in
269+/// `tests/js_interop.rs` opens what that code produces.
270+pub fn seal(plaintext: &[u8], aad: &[u8], recipients: &[Recipient]) -> Result<Envelope> {
271+ if plaintext.len() > MAX_VALUE_BYTES {
272+ return Err(Error::Invalid(format!(
273+ "secret is larger than {MAX_VALUE_BYTES} bytes"
274+ )));
275+ }
276+ if recipients.is_empty() {
277+ return Err(Error::Invalid(
278+ "no ssh-ed25519 keys to seal to — register one first".into(),
279+ ));
280+ }
281+ let file_key: [u8; 32] = random_bytes();
282+ let nonce: [u8; 12] = random_bytes();
283+ let ct = aes_seal(&file_key, &nonce, plaintext, aad)?;
284+
285+ let mut stanzas = Vec::with_capacity(recipients.len());
286+ for r in recipients {
287+ let esk: [u8; 32] = random_bytes();
288+ let epk = x25519(&esk, &X25519_BASEPOINT);
289+ let shared = x25519(&esk, &r.x25519);
290+ if shared.iter().all(|b| *b == 0) {
291+ return Err(Error::Invalid(format!(
292+ "recipient {} has a degenerate public key",
293+ r.fingerprint
294+ )));
295+ }
296+ let mut salt = [0u8; 64];
297+ salt[..32].copy_from_slice(&epk);
298+ salt[32..].copy_from_slice(&r.x25519);
299+ let wrap_key = hkdf_sha256(&shared, &salt, WRAP_INFO);
300+ let wrap_nonce: [u8; 12] = random_bytes();
301+ let mut wrap = wrap_nonce.to_vec();
302+ wrap.extend_from_slice(&aes_seal(
303+ &wrap_key,
304+ &wrap_nonce,
305+ &file_key,
306+ r.fingerprint.as_bytes(),
307+ )?);
308+ stanzas.push(Stanza {
309+ fp: r.fingerprint.clone(),
310+ epk: b64().encode(epk),
311+ wrap: b64().encode(wrap),
312+ });
313+ }
314+ Ok(Envelope {
315+ v: 1,
316+ alg: ALG.to_string(),
317+ recipients: stanzas,
318+ nonce: b64().encode(nonce),
319+ ct: b64().encode(ct),
320+ })
321+}
322+
323+/// Associated data bound into a sealed body: the scheme, the repository, and
324+/// the variable name. Re-pointing a stolen envelope at another repo or another
325+/// variable name therefore fails to open.
326+pub fn body_aad(owner: &str, repo: &str, name: &str) -> Vec<u8> {
327+ format!("anvil-secret-v1\n{owner}/{repo}\n{name}").into_bytes()
328+}
329+
330+/// Whether `name` is usable as a shell environment variable: uppercase,
331+/// digits, and underscores, not starting with a digit.
332+pub fn valid_name(name: &str) -> bool {
333+ !name.is_empty()
334+ && name.len() <= 64
335+ && !name.starts_with(|c: char| c.is_ascii_digit())
336+ && name
337+ .chars()
338+ .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
339+}
340+
341+// --- primitives ------------------------------------------------------------
342+
343+fn random_bytes<const N: usize>() -> [u8; N] {
344+ use argon2::password_hash::rand_core::{
345+ OsRng,
346+ RngCore,
347+ };
348+ let mut bytes = [0u8; N];
349+ OsRng.fill_bytes(&mut bytes);
350+ bytes
351+}
352+
353+/// HKDF-SHA256 (RFC 5869) for a single 32-byte output — extract, then one
354+/// expand block. Written out rather than pulled in as a dependency: the `hkdf`
355+/// crate tracks a newer `sha2`/`digest` generation than the rest of the tree.
356+fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8]) -> [u8; 32] {
357+ use hmac::{
358+ Hmac,
359+ Mac,
360+ };
361+ type H = Hmac<sha2::Sha256>;
362+
363+ let mut extract = H::new_from_slice(salt).expect("HMAC accepts any key length");
364+ extract.update(ikm);
365+ let prk = extract.finalize().into_bytes();
366+
367+ let mut expand = H::new_from_slice(&prk).expect("HMAC accepts any key length");
368+ expand.update(info);
369+ expand.update(&[0x01]);
370+ expand.finalize().into_bytes().into()
371+}
372+
373+fn aes_seal(key: &[u8; 32], nonce: &[u8; 12], msg: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
374+ let cipher = Aes256Gcm::new(key.into());
375+ cipher
376+ .encrypt(nonce.into(), Payload { msg, aad })
377+ .map_err(|_| Error::Invalid("sealing secret failed".into()))
378+}
379+
380+fn aes_open(
381+ key: &[u8; 32],
382+ nonce: &[u8; 12],
383+ ct: &[u8],
384+ aad: &[u8],
385+) -> std::result::Result<Vec<u8>, ()> {
386+ let cipher = Aes256Gcm::new(key.into());
387+ cipher
388+ .decrypt(nonce.into(), Payload { msg: ct, aad })
389+ .map_err(|_| ())
390+}
391+
392+/// The Curve25519 base point in Montgomery form (u = 9).
393+const X25519_BASEPOINT: [u8; 32] = {
394+ let mut u = [0u8; 32];
395+ u[0] = 9;
396+ u
397+};
398+
399+/// X25519 scalar multiplication: clamp the scalar, multiply the u-coordinate.
400+fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> [u8; 32] {
401+ curve25519_dalek::montgomery::MontgomeryPoint(*point)
402+ .mul_clamped(*scalar)
403+ .to_bytes()
404+}
405+
406+/// Map an Ed25519 public key (compressed Edwards `y`) to its X25519
407+/// (Montgomery `u`) counterpart.
408+fn ed25519_public_to_x25519(public: &[u8; 32]) -> Result<[u8; 32]> {
409+ curve25519_dalek::edwards::CompressedEdwardsY(*public)
410+ .decompress()
411+ .map(|p| p.to_montgomery().to_bytes())
412+ .ok_or_else(|| Error::Invalid("ssh-ed25519 key is not a valid curve point".into()))
413+}
414+
415+/// Map an Ed25519 seed to the X25519 secret scalar: SHA-512, keep the low
416+/// half, clamp — the standard derivation OpenSSH keys share with age.
417+fn ed25519_seed_to_x25519(seed: &[u8]) -> [u8; 32] {
418+ let digest = Sha512::digest(seed);
419+ let mut scalar = [0u8; 32];
420+ scalar.copy_from_slice(&digest[..32]);
421+ scalar[0] &= 248;
422+ scalar[31] &= 127;
423+ scalar[31] |= 64;
424+ scalar
425+}
426+
427+// --- persistence -----------------------------------------------------------
428+
429+/// List a repository's secrets, oldest first. Envelopes are opaque here.
430+pub async fn list(db: &toasty::Db, repo_id: i64) -> Result<Vec<RepoSecret>> {
431+ let mut conn = db.clone();
432+ let mut secrets = RepoSecret::filter(RepoSecret::fields().repo_id().eq(repo_id))
433+ .exec(&mut conn)
434+ .await?;
435+ secrets.sort_by(|a, b| a.name.cmp(&b.name));
436+ Ok(secrets)
437+}
438+
439+/// Look one up by name within a repository.
440+pub async fn find(db: &toasty::Db, repo_id: i64, name: &str) -> Result<Option<RepoSecret>> {
441+ Ok(list(db, repo_id)
442+ .await?
443+ .into_iter()
444+ .find(|s| s.name == name))
445+}
446+
447+/// Create or replace a secret. `envelope` must already have been parsed with
448+/// [`Envelope::parse`]; its recipient fingerprints are denormalized onto the
449+/// row so the UI can flag secrets that a newly added key cannot open.
450+pub async fn put(db: &toasty::Db, repo_id: i64, name: &str, envelope: &Envelope) -> Result<()> {
451+ if !valid_name(name) {
452+ return Err(Error::Invalid(
453+ "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
454+ ));
455+ }
456+ let json = serde_json::to_string(envelope)
457+ .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
458+ let recipients = envelope.recipient_fingerprints().join(",");
459+ let now = crate::now();
460+ let mut conn = db.clone();
461+ match find(db, repo_id, name).await? {
462+ Some(mut existing) => {
463+ existing
464+ .update()
465+ .envelope(json)
466+ .recipients(recipients)
467+ .updated_at(now)
468+ .exec(&mut conn)
469+ .await?;
470+ }
471+ None => {
472+ toasty::create!(RepoSecret {
473+ repo_id: repo_id,
474+ name: name,
475+ envelope: json,
476+ recipients: recipients,
477+ created_at: now,
478+ updated_at: now,
479+ })
480+ .exec(&mut conn)
481+ .await?;
482+ }
483+ }
484+ Ok(())
485+}
486+
487+/// Delete a secret by name. No-op if it does not exist.
488+pub async fn delete(db: &toasty::Db, repo_id: i64, name: &str) -> Result<()> {
489+ if let Some(secret) = find(db, repo_id, name).await? {
490+ let mut conn = db.clone();
491+ secret.delete().exec(&mut conn).await?;
492+ }
493+ Ok(())
494+}
495+
496+/// Delete every secret of a repository (used when the repo goes away).
497+pub async fn delete_all(db: &toasty::Db, repo_id: i64) -> Result<()> {
498+ for secret in list(db, repo_id).await? {
499+ let mut conn = db.clone();
500+ secret.delete().exec(&mut conn).await?;
501+ }
502+ Ok(())
503+}
504+
505+// --- the unlock vault ------------------------------------------------------
506+
507+/// Plaintext secrets for unlocked repositories, held in memory only.
508+///
509+/// A repository is *sealed* until someone with a recipient ssh key runs
510+/// `anvild secret unlock`, which opens the envelopes locally and posts the
511+/// values here. They live in this map and nowhere else: no file, no database
512+/// row, no log. A restart re-seals every repository, and each entry expires on
513+/// its own TTL. CI reads from here (see `anvil-ci`), which is the one place
514+/// anvil handles plaintext at all.
515+#[derive(Clone, Default)]
516+pub struct Vault {
517+ inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<i64, Unlocked>>>,
518+}
519+
520+struct Unlocked {
521+ values: std::collections::BTreeMap<String, String>,
522+ expires_at: i64,
523+}
524+
525+impl Drop for Unlocked {
526+ /// Overwrite the plaintext when an entry expires or is replaced, so it
527+ /// does not linger in freed heap pages.
528+ fn drop(&mut self) {
529+ for value in self.values.values_mut() {
530+ // SAFETY-adjacent: writing over the bytes in place. `String`'s
531+ // buffer is the only copy we made.
532+ unsafe { value.as_bytes_mut() }.fill(0);
533+ }
534+ }
535+}
536+
537+/// What the UI shows about an unlocked repository.
538+#[derive(Clone, Copy, Debug)]
539+pub struct UnlockStatus {
540+ pub expires_at: i64,
541+ pub count: usize,
542+}
543+
544+/// Current Unix time in seconds, so the web layer can render an unlock
545+/// countdown against the same clock the vault expires on.
546+pub fn now_secs() -> i64 {
547+ crate::now()
548+}
549+
550+/// Longest an unlock may last before it has to be renewed.
551+pub const MAX_UNLOCK_SECS: i64 = 7 * 24 * 60 * 60;
552+
553+impl Vault {
554+ /// Store `values` for `repo_id`, replacing any previous unlock. Returns
555+ /// the expiry timestamp.
556+ pub fn unlock(
557+ &self,
558+ repo_id: i64,
559+ values: std::collections::BTreeMap<String, String>,
560+ ttl_secs: i64,
561+ ) -> i64 {
562+ let ttl = ttl_secs.clamp(60, MAX_UNLOCK_SECS);
563+ let expires_at = crate::now() + ttl;
564+ let mut map = self.inner.lock().expect("vault mutex");
565+ map.insert(repo_id, Unlocked { values, expires_at });
566+ expires_at
567+ }
568+
569+ /// Forget a repository's secrets immediately.
570+ pub fn lock(&self, repo_id: i64) {
571+ self.inner.lock().expect("vault mutex").remove(&repo_id);
572+ }
573+
574+ /// Current unlock state, or `None` if sealed or expired.
575+ pub fn status(&self, repo_id: i64) -> Option<UnlockStatus> {
576+ let mut map = self.inner.lock().expect("vault mutex");
577+ let entry = map.get(&repo_id)?;
578+ if entry.expires_at <= crate::now() {
579+ map.remove(&repo_id);
580+ return None;
581+ }
582+ Some(UnlockStatus {
583+ expires_at: entry.expires_at,
584+ count: entry.values.len(),
585+ })
586+ }
587+
588+ /// Fetch the named secrets for a CI run. Returns the names that are not
589+ /// available as the error, so the runner can say exactly what is missing.
590+ pub fn take(
591+ &self,
592+ repo_id: i64,
593+ names: &[String],
594+ ) -> std::result::Result<Vec<(String, String)>, Vec<String>> {
595+ let mut map = self.inner.lock().expect("vault mutex");
596+ let Some(entry) = map.get(&repo_id) else {
597+ return Err(names.to_vec());
598+ };
599+ if entry.expires_at <= crate::now() {
600+ map.remove(&repo_id);
601+ return Err(names.to_vec());
602+ }
603+ let mut found = Vec::with_capacity(names.len());
604+ let mut missing = Vec::new();
605+ for name in names {
606+ match entry.values.get(name) {
607+ Some(value) => found.push((name.clone(), value.clone())),
608+ None => missing.push(name.clone()),
609+ }
610+ }
611+ if missing.is_empty() {
612+ Ok(found)
613+ } else {
614+ Err(missing)
615+ }
616+ }
617+
618+ /// Drop expired entries (called from the periodic sweep).
619+ pub fn sweep(&self) {
620+ let now = crate::now();
621+ self.inner
622+ .lock()
623+ .expect("vault mutex")
624+ .retain(|_, entry| entry.expires_at > now);
625+ }
626+}
627+
628+#[cfg(test)]
629+mod tests {
630+ use ssh_key::{
631+ PrivateKey,
632+ private::Ed25519Keypair,
633+ };
634+
635+ use super::*;
636+
637+ fn keypair() -> (PrivateKey, Recipient) {
638+ let key = PrivateKey::from(Ed25519Keypair::from_seed(&random_bytes()));
639+ let line = key.public_key().to_openssh().unwrap();
640+ let recipient = Recipient::from_openssh(&line).unwrap();
641+ (key, recipient)
642+ }
643+
644+ #[test]
645+ fn seals_and_opens_for_every_recipient() {
646+ let (a_key, a) = keypair();
647+ let (b_key, b) = keypair();
648+ let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
649+
650+ let env = seal(b"hunter2", &aad, &[a.clone(), b.clone()]).unwrap();
651+ for key in [&a_key, &b_key] {
652+ let id = Identity::from_private_key(key).unwrap();
653+ assert_eq!(env.open(&aad, &id).unwrap(), b"hunter2");
654+ }
655+ }
656+
657+ #[test]
658+ fn a_key_that_is_not_a_recipient_cannot_open() {
659+ let (_, a) = keypair();
660+ let (outsider_key, _) = keypair();
661+ let aad = body_aad("collin", "anvil", "TOKEN");
662+ let env = seal(b"hunter2", &aad, &[a]).unwrap();
663+ let outsider = Identity::from_private_key(&outsider_key).unwrap();
664+ assert!(env.open(&aad, &outsider).is_err());
665+ }
666+
667+ #[test]
668+ fn associated_data_binds_the_name_and_repo() {
669+ let (key, r) = keypair();
670+ let id = Identity::from_private_key(&key).unwrap();
671+ let env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
672+ assert!(
673+ env.open(&body_aad("collin", "anvil", "OTHER"), &id)
674+ .is_err()
675+ );
676+ assert!(
677+ env.open(&body_aad("mallory", "anvil", "TOKEN"), &id)
678+ .is_err()
679+ );
680+ }
681+
682+ #[test]
683+ fn tampering_with_the_body_is_detected() {
684+ let (key, r) = keypair();
685+ let id = Identity::from_private_key(&key).unwrap();
686+ let aad = body_aad("collin", "anvil", "TOKEN");
687+ let mut env = seal(b"hunter2", &aad, &[r]).unwrap();
688+ let mut ct = b64().decode(&env.ct).unwrap();
689+ ct[0] ^= 1;
690+ env.ct = b64().encode(ct);
691+ assert!(env.open(&aad, &id).is_err());
692+ }
693+
694+ #[test]
695+ fn envelopes_round_trip_through_json() {
696+ let (key, r) = keypair();
697+ let id = Identity::from_private_key(&key).unwrap();
698+ let aad = body_aad("collin", "anvil", "TOKEN");
699+ let json = serde_json::to_string(&seal(b"hunter2", &aad, &[r]).unwrap()).unwrap();
700+ let parsed = Envelope::parse(&json).unwrap();
701+ assert_eq!(parsed.open(&aad, &id).unwrap(), b"hunter2");
702+ }
703+
704+ #[test]
705+ fn rejects_malformed_envelopes() {
706+ assert!(Envelope::parse("{}").is_err());
707+ assert!(
708+ Envelope::parse(r#"{"v":2,"alg":"x","recipients":[],"nonce":"","ct":""}"#).is_err()
709+ );
710+ }
711+
712+ #[test]
713+ fn validates_names() {
714+ assert!(valid_name("DEPLOY_TOKEN"));
715+ assert!(valid_name("TOKEN2"));
716+ assert!(!valid_name("2TOKEN"));
717+ assert!(!valid_name("deploy_token"));
718+ assert!(!valid_name("DEPLOY-TOKEN"));
719+ assert!(!valid_name(""));
720+ }
721+}
modifiedcrates/anvil-web/Cargo.toml+6 −0
⋯ 24 unchanged lines
2525 similar.workspace = true
2626 syntect.workspace = true
2727 time.workspace = true
28+
29+[dev-dependencies]
30+tempfile = "3"
31+serde_json.workspace = true
32+argon2.workspace = true
33+ssh-key = { workspace = true, features = ["ed25519"] }
modifiedcrates/anvil-web/src/lib.rs+2 −0
⋯ 25 unchanged lines
2626 pub mod auth;
2727 pub mod git_http;
2828 pub mod pages;
29+pub mod secrets;
2930 pub mod todomd;
3031 pub mod ui;
3132
⋯ 11 unchanged lines
4344 router,
4445 app.config.http.attachment_max_mb.saturating_mul(1 << 20),
4546 ); // uploaded image attachments
47+ router = secrets::routes(router); // sealed per-repo secrets + unlock API
4648 router = git_http::routes(router); // smart-HTTP git endpoints
4749 router
4850 // Derives the per-request CSRF token so the layout can attach it to
⋯ 22 unchanged lines
addedcrates/anvil-web/src/secrets.rs+721 −0
1+//! Repository secrets: the settings UI (which encrypts in the browser) and
2+//! the JSON API the CLI uses to read envelopes, store them, and unlock a
3+//! repository for CI.
4+//!
5+//! Plaintext never reaches these handlers. The browser seals a value to the
6+//! owner's ssh-ed25519 keys with WebCrypto before posting, and the CLI does the
7+//! same locally; the server only ever sees `anvil-secret-v1` envelopes. The one
8+//! exception is [`unlock`], where a client that *has* decrypted the values
9+//! hands them over to be held in RAM for CI (see [`anvil_core::secrets::Vault`]
10+//! and `docs/secrets.md`).
11+
12+use anvil_core::{
13+ App,
14+ Repository,
15+ User,
16+ access,
17+ repos,
18+ secrets::{
19+ self,
20+ Envelope,
21+ },
22+ ssh_keys,
23+ users,
24+};
25+use axum::{
26+ Json,
27+ Router,
28+ extract::{
29+ Path,
30+ State,
31+ },
32+ http::{
33+ HeaderMap,
34+ StatusCode,
35+ },
36+ response::{
37+ IntoResponse,
38+ Redirect,
39+ Response,
40+ },
41+ routing::{
42+ get,
43+ post,
44+ },
45+};
46+use maud::{
47+ Markup,
48+ PreEscaped,
49+ html,
50+};
51+use serde::{
52+ Deserialize,
53+ Serialize,
54+};
55+
56+use crate::{
57+ auth::{
58+ Csrf,
59+ CurrentUser,
60+ basic_auth_user,
61+ verify_csrf,
62+ },
63+ ui::{
64+ csrf_input,
65+ fmt_relative,
66+ },
67+};
68+
69+pub fn routes(router: Router<App>) -> Router<App> {
70+ router
71+ .route(
72+ "/{owner}/{repo}/-/api/secrets",
73+ get(list_secrets).post(put_secret),
74+ )
75+ .route(
76+ "/{owner}/{repo}/-/api/secrets/{name}",
77+ axum::routing::delete(delete_secret),
78+ )
79+ .route("/{owner}/{repo}/-/api/secrets/unlock", post(unlock))
80+ .route("/{owner}/{repo}/-/api/secrets/lock", post(lock))
81+ // Plain form posts from the settings page (no JSON, no plaintext).
82+ .route("/{owner}/{repo}/-/secrets/{name}/delete", post(ui_delete))
83+ .route("/{owner}/{repo}/-/secrets/lock", post(ui_lock))
84+}
85+
86+// --- request plumbing ------------------------------------------------------
87+
88+/// Resolve the repository and check write access, accepting either a signed-in
89+/// session (with a CSRF token, as the browser sends) or HTTP Basic credentials
90+/// (as the CLI sends). Browsers never attach Basic credentials on their own, so
91+/// the Basic path needs no CSRF defence; the session path always does.
92+async fn authorize(
93+ app: &App,
94+ session_user: Option<User>,
95+ csrf: &Csrf,
96+ headers: &HeaderMap,
97+ owner: &str,
98+ repo: &str,
99+) -> Result<Repository, Response> {
100+ let authorization = headers
101+ .get(axum::http::header::AUTHORIZATION)
102+ .and_then(|v| v.to_str().ok());
103+ let user = match authorization {
104+ Some(header) if header.to_ascii_lowercase().starts_with("basic ") => {
105+ basic_auth_user(app, Some(header)).await
106+ }
107+ _ => {
108+ let submitted = headers
109+ .get("x-csrf-token")
110+ .and_then(|v| v.to_str().ok())
111+ .unwrap_or_default();
112+ verify_csrf(csrf, submitted)?;
113+ session_user
114+ }
115+ };
116+ let Some(user) = user else {
117+ return Err((StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response());
118+ };
119+ let meta = resolve(app, owner, repo).await?;
120+ if !access::can_write(&meta, Some(&user)) {
121+ return Err((StatusCode::NOT_FOUND, "no such repository").into_response());
122+ }
123+ Ok(meta)
124+}
125+
126+async fn resolve(app: &App, owner: &str, repo: &str) -> Result<Repository, Response> {
127+ let user = users::find_by_username(&app.db, owner)
128+ .await
129+ .map_err(server_error)?;
130+ let meta = match user {
131+ Some(u) => repos::find(&app.db, u.id, repo)
132+ .await
133+ .map_err(server_error)?,
134+ None => None,
135+ };
136+ meta.ok_or_else(|| (StatusCode::NOT_FOUND, "no such repository").into_response())
137+}
138+
139+fn server_error(e: impl std::fmt::Display) -> Response {
140+ tracing::error!("secrets: {e}");
141+ (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
142+}
143+
144+fn bad_request(e: impl std::fmt::Display) -> Response {
145+ (StatusCode::BAD_REQUEST, e.to_string()).into_response()
146+}
147+
148+// --- JSON API --------------------------------------------------------------
149+
150+#[derive(Serialize)]
151+struct SecretsResponse {
152+ repo: String,
153+ /// Unix time the current unlock expires, or 0 when sealed.
154+ unlocked_until: i64,
155+ /// The ssh-ed25519 keys secrets must be sealed to, i.e. the owner's.
156+ recipients: Vec<RecipientJson>,
157+ secrets: Vec<SecretJson>,
158+}
159+
160+#[derive(Serialize)]
161+struct RecipientJson {
162+ fingerprint: String,
163+ /// The OpenSSH public-key line, so a client can seal without re-fetching.
164+ key: String,
165+}
166+
167+#[derive(Serialize)]
168+struct SecretJson {
169+ name: String,
170+ envelope: serde_json::Value,
171+ recipients: Vec<String>,
172+ updated_at: i64,
173+}
174+
175+/// `GET /{owner}/{repo}/-/api/secrets` — the sealed envelopes plus the current
176+/// recipient set. Readable only by someone who could write them anyway; the
177+/// envelopes are useless without a private key regardless.
178+async fn list_secrets(
179+ State(app): State<App>,
180+ CurrentUser(user): CurrentUser,
181+ csrf: Csrf,
182+ Path((owner, repo)): Path<(String, String)>,
183+ headers: HeaderMap,
184+) -> Response {
185+ let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await {
186+ Ok(m) => m,
187+ Err(resp) => return resp,
188+ };
189+ let recipients = match recipients_for(&app, &meta).await {
190+ Ok(r) => r,
191+ Err(resp) => return resp,
192+ };
193+ let stored = match secrets::list(&app.db, meta.id).await {
194+ Ok(s) => s,
195+ Err(e) => return server_error(e).into_response(),
196+ };
197+ let secrets_json = stored
198+ .into_iter()
199+ .map(|s| SecretJson {
200+ envelope: serde_json::from_str(&s.envelope).unwrap_or(serde_json::Value::Null),
201+ recipients: split_fingerprints(&s.recipients),
202+ name: s.name,
203+ updated_at: s.updated_at,
204+ })
205+ .collect();
206+ Json(SecretsResponse {
207+ repo: format!("{owner}/{repo}"),
208+ unlocked_until: app
209+ .vault
210+ .status(meta.id)
211+ .map(|s| s.expires_at)
212+ .unwrap_or_default(),
213+ recipients: recipients
214+ .into_iter()
215+ .map(|(recipient, line)| RecipientJson {
216+ fingerprint: recipient.fingerprint,
217+ key: line,
218+ })
219+ .collect(),
220+ secrets: secrets_json,
221+ })
222+ .into_response()
223+}
224+
225+#[derive(Deserialize)]
226+struct PutSecret {
227+ name: String,
228+ envelope: serde_json::Value,
229+}
230+
231+/// `POST /{owner}/{repo}/-/api/secrets` — store a sealed envelope under a name,
232+/// replacing any previous value. The body is ciphertext; the server checks only
233+/// its shape.
234+async fn put_secret(
235+ State(app): State<App>,
236+ CurrentUser(user): CurrentUser,
237+ csrf: Csrf,
238+ Path((owner, repo)): Path<(String, String)>,
239+ headers: HeaderMap,
240+ Json(body): Json<PutSecret>,
241+) -> Response {
242+ let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await {
243+ Ok(m) => m,
244+ Err(resp) => return resp,
245+ };
246+ if !secrets::valid_name(&body.name) {
247+ return bad_request("secret names are A–Z, 0–9 and _, and cannot start with a digit");
248+ }
249+ let json = match serde_json::to_string(&body.envelope) {
250+ Ok(j) => j,
251+ Err(e) => return bad_request(e),
252+ };
253+ let envelope = match Envelope::parse(&json) {
254+ Ok(e) => e,
255+ Err(e) => return bad_request(e),
256+ };
257+ // A secret nobody can open is a footgun, not a feature: require it to be
258+ // sealed to at least one key that is still registered.
259+ let current = match recipients_for(&app, &meta).await {
260+ Ok(r) => r,
261+ Err(resp) => return resp,
262+ };
263+ let sealed_to = envelope.recipient_fingerprints();
264+ if !current
265+ .iter()
266+ .any(|(r, _)| sealed_to.contains(&r.fingerprint))
267+ {
268+ return bad_request("envelope is not sealed to any registered ssh key");
269+ }
270+ match secrets::put(&app.db, meta.id, &body.name, &envelope).await {
271+ Ok(()) => StatusCode::NO_CONTENT.into_response(),
272+ Err(e) => bad_request(e),
273+ }
274+}
275+
276+/// `DELETE /{owner}/{repo}/-/api/secrets/{name}`.
277+async fn delete_secret(
278+ State(app): State<App>,
279+ CurrentUser(user): CurrentUser,
280+ csrf: Csrf,
281+ Path((owner, repo, name)): Path<(String, String, String)>,
282+ headers: HeaderMap,
283+) -> Response {
284+ let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await {
285+ Ok(m) => m,
286+ Err(resp) => return resp,
287+ };
288+ match secrets::delete(&app.db, meta.id, &name).await {
289+ Ok(()) => StatusCode::NO_CONTENT.into_response(),
290+ Err(e) => server_error(e),
291+ }
292+}
293+
294+#[derive(Deserialize)]
295+struct UnlockBody {
296+ values: std::collections::BTreeMap<String, String>,
297+ #[serde(default)]
298+ ttl_secs: i64,
299+}
300+
301+#[derive(Serialize)]
302+struct UnlockResponse {
303+ unlocked_until: i64,
304+ count: usize,
305+}
306+
307+/// `POST /{owner}/{repo}/-/api/secrets/unlock` — hand the server decrypted
308+/// values to hold in memory for CI until they expire.
309+///
310+/// This is the *only* endpoint that sees plaintext, and the client must have
311+/// opened the envelopes itself to call it. Nothing is written to disk.
312+async fn unlock(
313+ State(app): State<App>,
314+ CurrentUser(user): CurrentUser,
315+ csrf: Csrf,
316+ Path((owner, repo)): Path<(String, String)>,
317+ headers: HeaderMap,
318+ Json(body): Json<UnlockBody>,
319+) -> Response {
320+ let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await {
321+ Ok(m) => m,
322+ Err(resp) => return resp,
323+ };
324+ for name in body.values.keys() {
325+ if !secrets::valid_name(name) {
326+ return bad_request(format!("invalid secret name `{name}`"));
327+ }
328+ }
329+ let count = body.values.len();
330+ let ttl = if body.ttl_secs > 0 {
331+ body.ttl_secs
332+ } else {
333+ 8 * 60 * 60
334+ };
335+ let unlocked_until = app.vault.unlock(meta.id, body.values, ttl);
336+ tracing::info!("secrets: {owner}/{repo} unlocked with {count} value(s) until {unlocked_until}");
337+ Json(UnlockResponse {
338+ unlocked_until,
339+ count,
340+ })
341+ .into_response()
342+}
343+
344+/// `POST /{owner}/{repo}/-/api/secrets/lock` — forget the values now.
345+async fn lock(
346+ State(app): State<App>,
347+ CurrentUser(user): CurrentUser,
348+ csrf: Csrf,
349+ Path((owner, repo)): Path<(String, String)>,
350+ headers: HeaderMap,
351+) -> Response {
352+ let meta = match authorize(&app, user, &csrf, &headers, &owner, &repo).await {
353+ Ok(m) => m,
354+ Err(resp) => return resp,
355+ };
356+ app.vault.lock(meta.id);
357+ StatusCode::NO_CONTENT.into_response()
358+}
359+
360+// --- form posts from the settings page -------------------------------------
361+
362+async fn ui_delete(
363+ State(app): State<App>,
364+ CurrentUser(user): CurrentUser,
365+ csrf: Csrf,
366+ Path((owner, repo, name)): Path<(String, String, String)>,
367+ axum::Form(form): axum::Form<crate::auth::CsrfForm>,
368+) -> Response {
369+ let meta = match ui_authorize(&app, user, &csrf, &form.csrf, &owner, &repo).await {
370+ Ok(m) => m,
371+ Err(resp) => return resp,
372+ };
373+ if let Err(e) = secrets::delete(&app.db, meta.id, &name).await {
374+ return server_error(e);
375+ }
376+ Redirect::to(&format!("/{owner}/{repo}/settings")).into_response()
377+}
378+
379+async fn ui_lock(
380+ State(app): State<App>,
381+ CurrentUser(user): CurrentUser,
382+ csrf: Csrf,
383+ Path((owner, repo)): Path<(String, String)>,
384+ axum::Form(form): axum::Form<crate::auth::CsrfForm>,
385+) -> Response {
386+ let meta = match ui_authorize(&app, user, &csrf, &form.csrf, &owner, &repo).await {
387+ Ok(m) => m,
388+ Err(resp) => return resp,
389+ };
390+ app.vault.lock(meta.id);
391+ Redirect::to(&format!("/{owner}/{repo}/settings")).into_response()
392+}
393+
394+async fn ui_authorize(
395+ app: &App,
396+ user: Option<User>,
397+ csrf: &Csrf,
398+ submitted: &str,
399+ owner: &str,
400+ repo: &str,
401+) -> Result<Repository, Response> {
402+ verify_csrf(csrf, submitted)?;
403+ let meta = resolve(app, owner, repo).await?;
404+ if !access::can_write(&meta, user.as_ref()) {
405+ return Err((StatusCode::NOT_FOUND, "no such repository").into_response());
406+ }
407+ Ok(meta)
408+}
409+
410+// --- shared helpers --------------------------------------------------------
411+
412+fn split_fingerprints(csv: &str) -> Vec<String> {
413+ csv.split(',')
414+ .filter(|s| !s.is_empty())
415+ .map(str::to_string)
416+ .collect()
417+}
418+
419+/// The repository owner's ssh-ed25519 keys, as (recipient, OpenSSH line).
420+/// Other key types are skipped: they cannot do X25519 key agreement.
421+async fn recipients_for(
422+ app: &App,
423+ meta: &Repository,
424+) -> Result<Vec<(secrets::Recipient, String)>, Response> {
425+ let keys = ssh_keys::list_by_user(&app.db, meta.owner_id)
426+ .await
427+ .map_err(server_error)?;
428+ Ok(keys
429+ .into_iter()
430+ .filter_map(|k| {
431+ secrets::Recipient::from_openssh(&k.content)
432+ .ok()
433+ .map(|r| (r, k.content))
434+ })
435+ .collect())
436+}
437+
438+/// Coarse countdown phrasing ("3 hours"), since [`fmt_relative`] only ever
439+/// looks backwards.
440+fn fmt_duration(secs: i64) -> String {
441+ let plural = |n: i64, unit: &str| {
442+ if n == 1 {
443+ format!("1 {unit}")
444+ } else {
445+ format!("{n} {unit}s")
446+ }
447+ };
448+ match secs {
449+ s if s <= 0 => "moments".to_string(),
450+ s if s < 60 => plural(s, "second"),
451+ s if s < 3600 => plural(s / 60, "minute"),
452+ s if s < 86_400 => plural(s / 3600, "hour"),
453+ s => plural(s / 86_400, "day"),
454+ }
455+}
456+
457+/// The secrets section of a repository's settings page.
458+pub async fn settings_section(app: &App, owner: &str, repo: &str, meta: &Repository) -> Markup {
459+ let recipients = recipients_for(app, meta).await.unwrap_or_default();
460+ let stored = secrets::list(&app.db, meta.id).await.unwrap_or_default();
461+ let status = app.vault.status(meta.id);
462+ let csrf = crate::auth::current_csrf();
463+
464+ let recipients_json = serde_json::to_string(
465+ &recipients
466+ .iter()
467+ .map(|(r, line)| serde_json::json!({ "fingerprint": r.fingerprint, "key": line }))
468+ .collect::<Vec<_>>(),
469+ )
470+ .unwrap_or_else(|_| "[]".to_string());
471+ let current: Vec<&str> = recipients
472+ .iter()
473+ .map(|(r, _)| r.fingerprint.as_str())
474+ .collect();
475+
476+ html! {
477+ h2 style="margin-top:28px" { "Secrets" }
478+ p.muted style="font-size:13px" {
479+ "Encrypted in your browser to your ssh-ed25519 keys before they are sent. "
480+ "anvil stores only the ciphertext and cannot read it — not here, not in a backup. "
481+ "To let CI use them, run "
482+ code { "anvild secret unlock " (owner) "/" (repo) }
483+ " from a machine holding one of those keys."
484+ }
485+
486+ @if let Some(status) = status {
487+ p.secret-unlocked {
488+ "Unlocked for CI — " (status.count) " value(s), expires in "
489+ (fmt_duration(status.expires_at - anvil_core::secrets::now_secs())) "."
490+ form method="post" action=(format!("/{owner}/{repo}/-/secrets/lock")) style="display:inline;margin-left:8px" {
491+ (csrf_input(&csrf))
492+ button.btn.btn-secondary type="submit" { "Lock now" }
493+ }
494+ }
495+ } @else {
496+ p.muted style="font-size:13px" { "Sealed: CI runs that declare secrets will fail until you unlock." }
497+ }
498+
499+ @if stored.is_empty() {
500+ p.muted { "No secrets yet." }
501+ } @else {
502+ div.box {
503+ @for s in &stored {
504+ div.row {
505+ span {
506+ code { (s.name) }
507+ @let sealed_to = split_fingerprints(&s.recipients);
508+ @let missing = current.iter().filter(|fp| !sealed_to.iter().any(|s| s == **fp)).count();
509+ @if missing > 0 {
510+ span.secret-stale title="Sealed before these keys were added" {
511+ (missing) " key(s) cannot open this — rekey"
512+ }
513+ }
514+ }
515+ span.muted style="margin-left:auto;font-size:13px" {
516+ "updated " (fmt_relative(s.updated_at))
517+ }
518+ form method="post" style="margin-left:12px"
519+ action=(format!("/{owner}/{repo}/-/secrets/{}/delete", s.name)) {
520+ (csrf_input(&csrf))
521+ button.btn.btn-secondary type="submit" { "Delete" }
522+ }
523+ }
524+ }
525+ }
526+ }
527+
528+ @if recipients.is_empty() {
529+ p.secret-warn {
530+ "No ssh-ed25519 key registered, so there is nothing to encrypt to. "
531+ a href="/-/settings" { "Add one" } " first."
532+ }
533+ } @else {
534+ // Deliberately not a <form>: with no form element there is no
535+ // default submission path that could ever put a plaintext value in
536+ // a request the browser builds by itself.
537+ div #secrets-form.stack
538+ data-repo=(format!("{owner}/{repo}"))
539+ data-endpoint=(format!("/{owner}/{repo}/-/api/secrets"))
540+ data-csrf=(csrf)
541+ style="margin-top:16px" {
542+ script #secret-recipients type="application/json" { (PreEscaped(recipients_json)) }
543+ p {
544+ label { "Name" br; input #secret-name type="text" placeholder="DEPLOY_TOKEN" autocomplete="off"; }
545+ }
546+ p {
547+ label { "Value" br; textarea #secret-value rows="3" autocomplete="off" spellcheck="false" {} }
548+ br;
549+ span.muted style="font-size:12px" {
550+ "Sealed to " (recipients.len()) " key(s) in this browser. The value never leaves the page in the clear."
551+ }
552+ }
553+ p {
554+ button.btn #secret-save type="button" { "Encrypt and save" }
555+ span #secret-status.muted style="margin-left:10px;font-size:13px" {}
556+ }
557+ }
558+ script { (PreEscaped(SEAL_JS)) }
559+ script { (PreEscaped(FORM_JS)) }
560+ }
561+ }
562+}
563+
564+/// Browser-side sealing, exposed as `anvilSealSecret(repo, name, value,
565+/// recipients)`.
566+///
567+/// Mirrors [`anvil_core::secrets::seal`] exactly — same derivation, same
568+/// associated data, same field encoding — so the CLI can open what the browser
569+/// wrote and vice versa. `tests/js_interop.rs` runs this very string under node
570+/// and opens the result in Rust, which is what keeps the two halves honest.
571+///
572+/// Every primitive is WebCrypto's; nothing here implements a cipher by hand.
573+/// The one piece of arithmetic is the Edwards → Montgomery map of the
574+/// recipient's public key, for which WebCrypto has no API.
575+pub const SEAL_JS: &str = r#"
576+globalThis.anvilSealSecret = (function () {
577+ var te = new TextEncoder();
578+
579+ function b64(bytes) {
580+ var s = '';
581+ for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
582+ return btoa(s);
583+ }
584+
585+ // An OpenSSH public-key line holds a base64 blob of length-prefixed fields:
586+ // the algorithm name, then the 32-byte Ed25519 point.
587+ function ed25519FromLine(line) {
588+ var blob = Uint8Array.from(atob(line.trim().split(/\s+/)[1]), function (c) { return c.charCodeAt(0); });
589+ var off = 0;
590+ function field() {
591+ var n = (blob[off] << 24) | (blob[off + 1] << 16) | (blob[off + 2] << 8) | blob[off + 3];
592+ off += 4;
593+ var out = blob.slice(off, off + n);
594+ off += n;
595+ return out;
596+ }
597+ if (new TextDecoder().decode(field()) !== 'ssh-ed25519') throw new Error('not an ssh-ed25519 key');
598+ var key = field();
599+ if (key.length !== 32) throw new Error('malformed ed25519 key');
600+ return key;
601+ }
602+
603+ // u = (1 + y) / (1 - y) mod 2^255-19: the birational map from the Edwards
604+ // curve Ed25519 signs on to the Montgomery curve X25519 agrees on.
605+ var P = (1n << 255n) - 19n;
606+ function inverse(a) {
607+ var result = 1n, base = ((a % P) + P) % P, e = P - 2n;
608+ while (e > 0n) {
609+ if (e & 1n) result = (result * base) % P;
610+ base = (base * base) % P;
611+ e >>= 1n;
612+ }
613+ return result;
614+ }
615+ function toMontgomery(ed) {
616+ var b = Uint8Array.from(ed);
617+ b[31] &= 0x7f; // drop the sign bit; only y matters
618+ var y = 0n;
619+ for (var i = 31; i >= 0; i--) y = (y << 8n) | BigInt(b[i]);
620+ var den = ((1n - y) % P + P) % P;
621+ if (den === 0n) throw new Error('degenerate key');
622+ var u = ((1n + y) % P) * inverse(den) % P;
623+ var out = new Uint8Array(32);
624+ for (var j = 0; j < 32; j++) { out[j] = Number(u & 0xffn); u >>= 8n; }
625+ return out;
626+ }
627+
628+ async function aesEncrypt(key, nonce, aad, data) {
629+ var k = await crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt']);
630+ return new Uint8Array(await crypto.subtle.encrypt(
631+ { name: 'AES-GCM', iv: nonce, additionalData: aad }, k, data));
632+ }
633+
634+ return async function sealSecret(repo, name, value, recipients) {
635+ var fileKey = crypto.getRandomValues(new Uint8Array(32));
636+ var nonce = crypto.getRandomValues(new Uint8Array(12));
637+ var aad = te.encode('anvil-secret-v1\n' + repo + '\n' + name);
638+ var ct = await aesEncrypt(fileKey, nonce, aad, te.encode(value));
639+
640+ var stanzas = [];
641+ for (var i = 0; i < recipients.length; i++) {
642+ var r = recipients[i];
643+ var u = toMontgomery(ed25519FromLine(r.key));
644+ var pub = await crypto.subtle.importKey('raw', u, { name: 'X25519' }, false, []);
645+ var eph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
646+ var epk = new Uint8Array(await crypto.subtle.exportKey('raw', eph.publicKey));
647+ var shared = new Uint8Array(await crypto.subtle.deriveBits(
648+ { name: 'X25519', public: pub }, eph.privateKey, 256));
649+ var salt = new Uint8Array(64);
650+ salt.set(epk, 0);
651+ salt.set(u, 32);
652+ var ikm = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveBits']);
653+ var okm = new Uint8Array(await crypto.subtle.deriveBits(
654+ { name: 'HKDF', hash: 'SHA-256', salt: salt, info: te.encode('anvil-secret-v1 wrap') },
655+ ikm, 256));
656+ var wrapNonce = crypto.getRandomValues(new Uint8Array(12));
657+ var wrapped = await aesEncrypt(okm, wrapNonce, te.encode(r.fingerprint), fileKey);
658+ var wrap = new Uint8Array(12 + wrapped.length);
659+ wrap.set(wrapNonce, 0);
660+ wrap.set(wrapped, 12);
661+ stanzas.push({ fp: r.fingerprint, epk: b64(epk), wrap: b64(wrap) });
662+ }
663+ return { v: 1, alg: 'x25519-hkdf-sha256+aes256gcm', recipients: stanzas, nonce: b64(nonce), ct: b64(ct) };
664+ };
665+})();
666+"#;
667+
668+/// Wires the settings form to [`SEAL_JS`]: validate, seal, POST the envelope.
669+/// The plaintext lives in one textarea and is cleared as soon as the ciphertext
670+/// is on its way.
671+const FORM_JS: &str = r#"
672+(function () {
673+ var root = document.getElementById('secrets-form');
674+ if (!root) return;
675+ var nameEl = document.getElementById('secret-name');
676+ var valueEl = document.getElementById('secret-value');
677+ var button = document.getElementById('secret-save');
678+ var statusEl = document.getElementById('secret-status');
679+ var recipients = JSON.parse(document.getElementById('secret-recipients').textContent);
680+
681+ function fail(message) {
682+ statusEl.textContent = message;
683+ statusEl.style.color = '#cf222e';
684+ button.disabled = false;
685+ }
686+
687+ button.addEventListener('click', async function () {
688+ var name = nameEl.value.trim();
689+ var value = valueEl.value;
690+ statusEl.style.color = '';
691+ if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) return fail('Name must be A-Z, 0-9 and _, not starting with a digit.');
692+ if (!value) return fail('Value is empty.');
693+ if (!crypto.subtle || !window.BigInt) return fail('This browser cannot encrypt here; use `anvild secret set`.');
694+
695+ button.disabled = true;
696+ statusEl.textContent = 'Encrypting…';
697+ var envelope;
698+ try {
699+ envelope = await anvilSealSecret(root.dataset.repo, name, value, recipients);
700+ } catch (e) {
701+ // Most likely cause: a browser without WebCrypto X25519.
702+ return fail('Encryption failed (' + e.message + '). Use `anvild secret set` instead.');
703+ }
704+ statusEl.textContent = 'Saving…';
705+ try {
706+ var res = await fetch(root.dataset.endpoint, {
707+ method: 'POST',
708+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf },
709+ body: JSON.stringify({ name: name, envelope: envelope }),
710+ });
711+ if (!res.ok) return fail('Server rejected it: ' + (await res.text()));
712+ } catch (e) {
713+ return fail('Could not reach the server: ' + e.message);
714+ }
715+ // Clear the plaintext out of the DOM before the page goes away.
716+ valueEl.value = '';
717+ nameEl.value = '';
718+ location.reload();
719+ });
720+})();
721+"#;
modifiedcrates/anvil-web/src/ui.rs+9 −1
⋯ 225 unchanged lines
226226 .kanban .card .card-details img { max-width:100%; height:auto; border-radius:4px; margin:2px 0; }
227227 .kanban .card .card-details > :last-child { margin-bottom:0; }
228228 .kanban .card .title img { max-width:100%; height:auto; border-radius:4px; }
229+/* Repo secrets (docs/secrets.md): sealed values, plus the CI unlock banner. */
230+.secret-unlocked { background:var(--success-bg); color:var(--success); border-radius:6px; padding:8px 12px; font-size:13px; }
231+.secret-warn { background:var(--warning-bg); color:var(--warning); border-radius:6px; padding:8px 12px; font-size:13px; }
232+.secret-stale { margin-left:10px; font-size:12px; color:var(--warning); }
233+#secrets-form textarea { width:100%; font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
229234 .todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; }
230235 /* Repo home: the board leads the page, clipped to a fixed-height teaser that
231236 expands in place. A checkbox drives it, not <details>, because a closed
⋯ 795 unchanged lines
10271032 Ok(m) => m,
10281033 Err(resp) => return resp,
10291034 };
1030- settings_page(user.as_ref(), &owner, &repo, &meta, None, &csrf.0).into_response()
1035+ let secrets = crate::secrets::settings_section(&app, &owner, &repo, &meta).await;
1036+ settings_page(user.as_ref(), &owner, &repo, &meta, secrets, None, &csrf.0).into_response()
10311037 }
10321038
10331039 /// `POST /{owner}/{repo}/settings` — update description / visibility.
⋯ 30 unchanged lines
10641070 owner: &str,
10651071 repo: &str,
10661072 meta: &Repository,
1073+ secrets: Markup,
10671074 error: Option<&str>,
10681075 csrf: &str,
10691076 ) -> Markup {
⋯ 22 unchanged lines
10921099 }
10931100 p { button.btn type="submit" { "Save changes" } }
10941101 }
1102+ (secrets)
10951103 },
10961104 )
10971105 }
⋯ 1706 unchanged lines
addedcrates/anvil-web/tests/js_interop.rs+187 −0
1+//! The browser and the CLI must produce and consume the same envelopes, and
2+//! the only way to know that is to run both halves.
3+//!
4+//! This test executes the *actual* `SEAL_JS` string served to browsers under
5+//! node's WebCrypto, then opens the resulting envelope with the Rust
6+//! implementation the CLI uses. A drift in either direction — a changed HKDF
7+//! salt, a different associated-data string, a byte-order slip in the
8+//! Edwards → Montgomery map — fails here rather than in production.
9+//!
10+//! node is a test fixture only: nothing in the server or the CLI depends on it.
11+
12+use anvil_core::secrets::{
13+ Envelope,
14+ Identity,
15+ Recipient,
16+ body_aad,
17+ seal,
18+};
19+use ssh_key::{
20+ PrivateKey,
21+ private::Ed25519Keypair,
22+};
23+
24+/// Generate a throwaway ssh-ed25519 key.
25+fn keypair() -> (PrivateKey, String) {
26+ let mut seed = [0u8; 32];
27+ getrandom(&mut seed);
28+ let key = PrivateKey::from(Ed25519Keypair::from_seed(&seed));
29+ let line = key.public_key().to_openssh().unwrap();
30+ (key, line)
31+}
32+
33+fn getrandom(buf: &mut [u8]) {
34+ use argon2::password_hash::rand_core::{
35+ OsRng,
36+ RngCore,
37+ };
38+ OsRng.fill_bytes(buf);
39+}
40+
41+fn node_available() -> bool {
42+ std::process::Command::new("node")
43+ .arg("--version")
44+ .output()
45+ .map(|o| o.status.success())
46+ .unwrap_or(false)
47+}
48+
49+/// Run `SEAL_JS` under node and return the envelope it produces.
50+fn seal_in_node(repo: &str, name: &str, value: &str, recipients: &serde_json::Value) -> String {
51+ let dir = tempfile::tempdir().unwrap();
52+ let script = dir.path().join("seal.mjs");
53+ std::fs::write(
54+ &script,
55+ format!(
56+ "{seal}\n\
57+ const envelope = await anvilSealSecret({repo}, {name}, {value}, {recipients});\n\
58+ process.stdout.write(JSON.stringify(envelope));\n",
59+ seal = anvil_web::secrets::SEAL_JS,
60+ repo = serde_json::to_string(repo).unwrap(),
61+ name = serde_json::to_string(name).unwrap(),
62+ value = serde_json::to_string(value).unwrap(),
63+ recipients = recipients,
64+ ),
65+ )
66+ .unwrap();
67+
68+ let output = std::process::Command::new("node")
69+ .arg(&script)
70+ .output()
71+ .expect("running node");
72+ assert!(
73+ output.status.success(),
74+ "node failed: {}",
75+ String::from_utf8_lossy(&output.stderr)
76+ );
77+ String::from_utf8(output.stdout).unwrap()
78+}
79+
80+#[test]
81+fn rust_opens_what_the_browser_seals() {
82+ if !node_available() {
83+ eprintln!("skipping: node is not installed");
84+ return;
85+ }
86+ let (a_key, a_line) = keypair();
87+ let (b_key, b_line) = keypair();
88+ let recipients = serde_json::json!([
89+ { "fingerprint": Recipient::from_openssh(&a_line).unwrap().fingerprint, "key": a_line },
90+ { "fingerprint": Recipient::from_openssh(&b_line).unwrap().fingerprint, "key": b_line },
91+ ]);
92+
93+ let value = "s3cr3t-värde-🔐"; // non-ASCII: the encoders must agree too
94+ let json = seal_in_node("collin/anvil", "DEPLOY_TOKEN", value, &recipients);
95+ let envelope = Envelope::parse(&json).expect("browser envelope parses");
96+ let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
97+
98+ // Every registered key opens it, which is the promise the UI makes.
99+ for key in [&a_key, &b_key] {
100+ let identity = Identity::from_private_key(key).unwrap();
101+ let opened = envelope.open(&aad, &identity).expect("opens with this key");
102+ assert_eq!(String::from_utf8(opened).unwrap(), value);
103+ }
104+}
105+
106+#[test]
107+fn the_browsers_associated_data_binds_name_and_repo() {
108+ if !node_available() {
109+ eprintln!("skipping: node is not installed");
110+ return;
111+ }
112+ let (key, line) = keypair();
113+ let recipients = serde_json::json!([
114+ { "fingerprint": Recipient::from_openssh(&line).unwrap().fingerprint, "key": line },
115+ ]);
116+ let json = seal_in_node("collin/anvil", "TOKEN", "hunter2", &recipients);
117+ let envelope = Envelope::parse(&json).unwrap();
118+ let identity = Identity::from_private_key(&key).unwrap();
119+
120+ assert!(
121+ envelope
122+ .open(&body_aad("collin", "anvil", "TOKEN"), &identity)
123+ .is_ok()
124+ );
125+ assert!(
126+ envelope
127+ .open(&body_aad("collin", "anvil", "OTHER"), &identity)
128+ .is_err()
129+ );
130+ assert!(
131+ envelope
132+ .open(&body_aad("mallory", "anvil", "TOKEN"), &identity)
133+ .is_err()
134+ );
135+}
136+
137+/// The reverse direction: an envelope the CLI wrote must be shaped exactly like
138+/// the browser's, so a value set from the terminal shows up as readable in the
139+/// UI's key-coverage display (and re-seals cleanly).
140+#[test]
141+fn browser_and_cli_envelopes_have_the_same_shape() {
142+ if !node_available() {
143+ eprintln!("skipping: node is not installed");
144+ return;
145+ }
146+ let (_, line) = keypair();
147+ let recipient = Recipient::from_openssh(&line).unwrap();
148+ let recipients = serde_json::json!([
149+ { "fingerprint": recipient.fingerprint, "key": line },
150+ ]);
151+ let from_browser: serde_json::Value = serde_json::from_str(&seal_in_node(
152+ "collin/anvil",
153+ "TOKEN",
154+ "hunter2",
155+ &recipients,
156+ ))
157+ .unwrap();
158+ let from_cli = serde_json::to_value(
159+ seal(
160+ b"hunter2",
161+ &body_aad("collin", "anvil", "TOKEN"),
162+ &[recipient],
163+ )
164+ .unwrap(),
165+ )
166+ .unwrap();
167+
168+ let shape = |v: &serde_json::Value| {
169+ let object = v.as_object().unwrap();
170+ let mut keys: Vec<String> = object.keys().cloned().collect();
171+ keys.sort();
172+ let stanza = v["recipients"][0].as_object().unwrap();
173+ let mut stanza_keys: Vec<String> = stanza.keys().cloned().collect();
174+ stanza_keys.sort();
175+ (
176+ keys,
177+ stanza_keys,
178+ v["v"].clone(),
179+ v["alg"].clone(),
180+ // Field lengths are fixed by the format; base64 lengths follow.
181+ v["nonce"].as_str().unwrap().len(),
182+ v["recipients"][0]["epk"].as_str().unwrap().len(),
183+ v["recipients"][0]["wrap"].as_str().unwrap().len(),
184+ )
185+ };
186+ assert_eq!(shape(&from_browser), shape(&from_cli));
187+}
addeddocs/secrets.md+147 −0
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.
modifieddocs/untrusted-mode.md+8 −0
⋯ 34 unchanged lines
3535 - an **image allowlist** (`ci.allowed_images`) — empty allows any image, which
3636 is fine single-tenant; set it before letting strangers push.
3737
38+**Secrets in a pipeline.** A run can request repository secrets (see
39+[secrets.md](secrets.md)), which arrive as environment variables inside that
40+same container. Anyone who can push to the repo can therefore read every secret
41+it declares, by editing the pipeline; log masking stops accidents, not intent.
42+The compensating control is that anvil cannot decrypt them at all unless the
43+owner has unlocked the repo, so the exposure window is bounded by the unlock
44+TTL rather than being permanent.
45+
3846 **Deliberately not done:** read-only rootfs (the workspace lives in the
3947 container filesystem precisely so no volume is ever attached; builds also
4048 write `$HOME` caches), and egress *filtering* (network is all-or-nothing).
⋯ 69 unchanged lines