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 | |||
| 146 | 146 | "anvil-web", | |
| 147 | 147 | "anyhow", | |
| 148 | 148 | "clap", | |
| 149 | + | "reqwest", | |
| 150 | + | "rpassword", | |
| 151 | + | "rustls", | |
| 152 | + | "serde", | |
| 153 | + | "serde_json", | |
| 154 | + | "ssh-key", | |
| 155 | + | "time", | |
| 149 | 156 | "tokio", | |
| 150 | 157 | "tracing", | |
| 151 | 158 | "tracing-subscriber", | |
| ⋯ 3 unchanged lines | |||
| 155 | 162 | name = "anvil-core" | |
| 156 | 163 | version = "0.0.0" | |
| 157 | 164 | dependencies = [ | |
| 165 | + | "aes-gcm", | |
| 158 | 166 | "argon2 0.5.3", | |
| 159 | 167 | "async-trait", | |
| 168 | + | "base64", | |
| 169 | + | "curve25519-dalek", | |
| 160 | 170 | "gix", | |
| 161 | 171 | "hmac 0.12.1", | |
| 162 | 172 | "pulldown-cmark", | |
| ⋯ 45 unchanged lines | |||
| 208 | 218 | dependencies = [ | |
| 209 | 219 | "anvil-core", | |
| 210 | 220 | "anvil-git", | |
| 221 | + | "argon2 0.5.3", | |
| 211 | 222 | "axum", | |
| 212 | 223 | "axum-extra", | |
| 213 | 224 | "base64", | |
| ⋯ 3 unchanged lines | |||
| 217 | 228 | "serde", | |
| 218 | 229 | "serde_json", | |
| 219 | 230 | "similar", | |
| 231 | + | "ssh-key", | |
| 220 | 232 | "syntect", | |
| 233 | + | "tempfile", | |
| 221 | 234 | "time", | |
| 222 | 235 | "tokio", | |
| 223 | 236 | "tokio-util", | |
| ⋯ 3272 unchanged lines | |||
| 3496 | 3509 | ] | |
| 3497 | 3510 | ||
| 3498 | 3511 | [[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]] | |
| 3499 | 3523 | name = "rsa" | |
| 3500 | 3524 | version = "0.10.0-rc.18" | |
| 3501 | 3525 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 23 unchanged lines | |||
| 3525 | 3549 | ] | |
| 3526 | 3550 | ||
| 3527 | 3551 | [[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]] | |
| 3528 | 3562 | name = "rusqlite" | |
| 3529 | 3563 | version = "0.39.0" | |
| 3530 | 3564 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 1516 unchanged lines | |||
| 5047 | 5081 | ||
| 5048 | 5082 | [[package]] | |
| 5049 | 5083 | 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" | |
| 5050 | 5093 | version = "0.61.2" | |
| 5051 | 5094 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5052 | 5095 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| ⋯ 299 unchanged lines | |||
modifiedCargo.toml+13 −0
| ⋯ 21 unchanged lines | |||
| 22 | 22 | ||
| 23 | 23 | anyhow = "1" | |
| 24 | 24 | 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" | |
| 25 | 36 | async-trait = "0.1" | |
| 26 | 37 | axum = "0.8" | |
| 27 | 38 | axum-extra = { version = "0.10", features = ["cookie"] } | |
| ⋯ 23 unchanged lines | |||
| 51 | 62 | # Used directly only for idempotent schema shims on existing databases; the | |
| 52 | 63 | # version tracks what toasty-driver-sqlite already pulls in. | |
| 53 | 64 | rusqlite = "0.39" | |
| 65 | + | # `anvild secret` prompts for an ssh key passphrase / an account password. | |
| 66 | + | rpassword = "7" | |
| 54 | 67 | serde = { version = "1", features = ["derive"] } | |
| 55 | 68 | serde_json = "1" | |
| 56 | 69 | serde_yaml = "0.9" | |
| ⋯ 25 unchanged lines | |||
modifiedREADME.md+8 −1
| ⋯ 24 unchanged lines | |||
| 25 | 25 | cargo run -- serve # start the server | |
| 26 | 26 | ``` | |
| 27 | 27 | ||
| 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 | |||
| 29 | 29 | from README.md on a periodic scan, cache the attachment hash, and display in | |
| 30 | 30 | repo listings for visual browsing | |
| 31 | 31 | - [ ] 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 | |||
| 140 | 140 | .join(run.repo_id.to_string()) | |
| 141 | 141 | .join(format!(".collecting-{run_id}")); | |
| 142 | 142 | ||
| 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 | + | ||
| 143 | 173 | 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 { | |
| 145 | 175 | Ok((0, collected)) => (ci::status::SUCCESS, collected), | |
| 146 | 176 | Ok((code, collected)) => { | |
| 147 | 177 | log.push_str(&format!("\n[exited with status {code}]\n")); | |
| ⋯ 44 unchanged lines | |||
| 192 | 222 | } | |
| 193 | 223 | let _ = std::fs::remove_dir_all(&scratch); // no-op when renamed away | |
| 194 | 224 | ||
| 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); | |
| 195 | 229 | ci::append_log(&app.db, run_id, &log).await.ok(); | |
| 196 | 230 | ci::finish(&app.db, run_id, status).await.ok(); | |
| 197 | 231 | tracing::info!("ci: run {run_id} {status}"); | |
| ⋯ 136 unchanged lines | |||
| 334 | 368 | /// `no-new-privileges` is set unconditionally; pids/memory/cpu caps, the | |
| 335 | 369 | /// wall-clock timeout, network access, the container user, and the image | |
| 336 | 370 | /// 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 | + | ||
| 337 | 383 | async fn execute( | |
| 338 | 384 | pipeline: &Pipeline, | |
| 339 | 385 | tar: Vec<u8>, | |
| 340 | 386 | log: &mut String, | |
| 341 | 387 | cfg: &CiConfig, | |
| 342 | 388 | scratch: &Path, | |
| 389 | + | env: &[(String, String)], | |
| 343 | 390 | ) -> Result<(i64, Vec<Collected>), String> { | |
| 344 | 391 | if !cfg.image_allowed(&pipeline.image) { | |
| 345 | 392 | return Err(format!( | |
| ⋯ 65 unchanged lines | |||
| 411 | 458 | let config = Config { | |
| 412 | 459 | image: Some(pipeline.image.clone()), | |
| 413 | 460 | 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()), | |
| 414 | 462 | working_dir: Some(WORKDIR.to_string()), | |
| 415 | 463 | user: (!cfg.run_as.is_empty()).then(|| cfg.run_as.clone()), | |
| 416 | 464 | host_config: Some(host_config), | |
| ⋯ 423 unchanged lines | |||
modifiedcrates/anvil-cli/Cargo.toml+9 −0
| ⋯ 19 unchanged lines | |||
| 20 | 20 | tokio.workspace = true | |
| 21 | 21 | clap.workspace = true | |
| 22 | 22 | 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"] } | |
| 23 | 32 | tracing.workspace = true | |
| 24 | 33 | tracing-subscriber.workspace = true | |
modifiedcrates/anvil-cli/src/main.rs+21 −0
| ⋯ 16 unchanged lines | |||
| 17 | 17 | Subcommand, | |
| 18 | 18 | }; | |
| 19 | 19 | ||
| 20 | + | mod secret; | |
| 21 | + | ||
| 20 | 22 | #[derive(Parser)] | |
| 21 | 23 | #[command(name = "anvild", version, about = "anvil git forge")] | |
| 22 | 24 | struct Cli { | |
| ⋯ 25 unchanged lines | |||
| 48 | 50 | #[command(subcommand)] | |
| 49 | 51 | command: RepoCommand, | |
| 50 | 52 | }, | |
| 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 | + | }, | |
| 51 | 64 | } | |
| 52 | 65 | ||
| 53 | 66 | #[derive(Subcommand)] | |
| ⋯ 82 unchanged lines | |||
| 136 | 149 | Command::Migrate => migrate(config).await, | |
| 137 | 150 | Command::User { command } => user(config, command).await, | |
| 138 | 151 | Command::Repo { command } => repo(config, command).await, | |
| 152 | + | Command::Secret { command, opts } => { | |
| 153 | + | secret::run(command, &opts, &config.http.base_url).await | |
| 154 | + | } | |
| 139 | 155 | } | |
| 140 | 156 | } | |
| 141 | 157 | ||
| ⋯ 23 unchanged lines | |||
| 165 | 181 | Box::new(anvil_core::periodic::DiskUsageCacheJob) | |
| 166 | 182 | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 167 | 183 | ), | |
| 184 | + | ( | |
| 185 | + | std::time::Duration::from_secs(300), | |
| 186 | + | Box::new(anvil_core::periodic::SecretVaultSweepJob) | |
| 187 | + | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 188 | + | ), | |
| 168 | 189 | ]; | |
| 169 | 190 | anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await; | |
| 170 | 191 | ||
| ⋯ 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 | |||
| 13 | 13 | toasty.workspace = true | |
| 14 | 14 | rusqlite.workspace = true | |
| 15 | 15 | argon2.workspace = true | |
| 16 | + | aes-gcm.workspace = true | |
| 17 | + | curve25519-dalek.workspace = true | |
| 18 | + | base64.workspace = true | |
| 16 | 19 | hmac.workspace = true | |
| 17 | 20 | sha2.workspace = true | |
| 18 | 21 | ssh-key.workspace = true | |
| ⋯ 9 unchanged lines | |||
| 28 | 31 | [dev-dependencies] | |
| 29 | 32 | tokio = { workspace = true } | |
| 30 | 33 | 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 | |||
| 36 | 36 | pub steps: Vec<Step>, | |
| 37 | 37 | #[serde(default)] | |
| 38 | 38 | 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>, | |
| 39 | 45 | } | |
| 40 | 46 | ||
| 41 | 47 | /// A declared artifact: a path in the workspace to collect after the steps | |
| ⋯ 84 unchanged lines | |||
| 126 | 132 | } | |
| 127 | 133 | } | |
| 128 | 134 | } | |
| 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 | + | } | |
| 129 | 142 | Ok(pipeline) | |
| 130 | 143 | } | |
| 131 | 144 | ||
| ⋯ 363 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+15 −1
| ⋯ 11 unchanged lines | |||
| 12 | 12 | CiRun, | |
| 13 | 13 | Issue, | |
| 14 | 14 | IssueComment, | |
| 15 | + | RepoSecret, | |
| 15 | 16 | Repository, | |
| 16 | 17 | Session, | |
| 17 | 18 | SshKey, | |
| ⋯ 27 unchanged lines | |||
| 45 | 46 | IssueComment, | |
| 46 | 47 | Attachment, | |
| 47 | 48 | ApiToken, | |
| 48 | - | AdminCache | |
| 49 | + | AdminCache, | |
| 50 | + | RepoSecret | |
| 49 | 51 | )) | |
| 50 | 52 | .connect(&url) | |
| 51 | 53 | .await?; | |
| ⋯ 24 unchanged lines | |||
| 76 | 78 | r#"CREATE INDEX IF NOT EXISTS "index_api_tokens_by_user_id" ON "api_tokens" ("user_id")"#, | |
| 77 | 79 | r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_api_tokens_by_token_hash" ON "api_tokens" ("token_hash")"#, | |
| 78 | 80 | 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")"#, | |
| 79 | 83 | ]; | |
| 80 | 84 | ||
| 81 | 85 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 43 unchanged lines | |||
| 125 | 129 | "scopes" TEXT NOT NULL, | |
| 126 | 130 | "created_at" BIGINT NOT NULL )"#; | |
| 127 | 131 | ||
| 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 | + | ||
| 128 | 141 | const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" ( | |
| 129 | 142 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 130 | 143 | "key" TEXT NOT NULL, | |
| ⋯ 65 unchanged lines | |||
| 196 | 209 | "issue_comments", | |
| 197 | 210 | "attachments", | |
| 198 | 211 | "api_tokens", | |
| 212 | + | "repo_secrets", | |
| 199 | 213 | ]; | |
| 200 | 214 | ||
| 201 | 215 | /// Every schema object (table + indexes) for `table`, normalized. | |
| ⋯ 118 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+6 −0
| ⋯ 18 unchanged lines | |||
| 19 | 19 | pub mod periodic; | |
| 20 | 20 | pub mod preview_images; | |
| 21 | 21 | pub mod repos; | |
| 22 | + | pub mod secrets; | |
| 22 | 23 | pub mod sessions; | |
| 23 | 24 | pub mod ssh_keys; | |
| 24 | 25 | pub mod storage; | |
| ⋯ 12 unchanged lines | |||
| 37 | 38 | CiRun, | |
| 38 | 39 | Issue, | |
| 39 | 40 | IssueComment, | |
| 41 | + | RepoSecret, | |
| 40 | 42 | Repository, | |
| 41 | 43 | Session, | |
| 42 | 44 | SshKey, | |
| ⋯ 19 unchanged lines | |||
| 62 | 64 | /// Notifies the CI runner of newly-enqueued run ids. `None` until the runner | |
| 63 | 65 | /// is started (e.g. CLI commands don't run CI). Use [`App::notify_ci`]. | |
| 64 | 66 | 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, | |
| 65 | 70 | /// Server-wide secret keying CSRF tokens. Persisted in the data dir so | |
| 66 | 71 | /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap. | |
| 67 | 72 | csrf_secret: std::sync::Arc<[u8; 32]>, | |
| ⋯ 13 unchanged lines | |||
| 81 | 86 | config, | |
| 82 | 87 | db, | |
| 83 | 88 | ci_tx: None, | |
| 89 | + | vault: secrets::Vault::default(), | |
| 84 | 90 | csrf_secret, | |
| 85 | 91 | }) | |
| 86 | 92 | } | |
| ⋯ 52 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+24 −0
| ⋯ 214 unchanged lines | |||
| 215 | 215 | pub created_at: i64, | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 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 | + | ||
| 218 | 242 | /// Cached admin metrics computed periodically (e.g., disk usage snapshot). | |
| 219 | 243 | #[derive(Clone, Debug, toasty::Model)] | |
| 220 | 244 | pub struct AdminCache { | |
| ⋯ 10 unchanged lines | |||
modifiedcrates/anvil-core/src/periodic.rs+19 −0
| ⋯ 162 unchanged lines | |||
| 163 | 163 | }); | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 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 | + | ||
| 166 | 185 | /// Try to extract the first image URL from a repository's README file. | |
| 167 | 186 | /// Scans the repository for a README file, reads it, and returns the first image URL found. | |
| 168 | 187 | 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 | |||
| 25 | 25 | similar.workspace = true | |
| 26 | 26 | syntect.workspace = true | |
| 27 | 27 | 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 | |||
| 26 | 26 | pub mod auth; | |
| 27 | 27 | pub mod git_http; | |
| 28 | 28 | pub mod pages; | |
| 29 | + | pub mod secrets; | |
| 29 | 30 | pub mod todomd; | |
| 30 | 31 | pub mod ui; | |
| 31 | 32 | ||
| ⋯ 11 unchanged lines | |||
| 43 | 44 | router, | |
| 44 | 45 | app.config.http.attachment_max_mb.saturating_mul(1 << 20), | |
| 45 | 46 | ); // uploaded image attachments | |
| 47 | + | router = secrets::routes(router); // sealed per-repo secrets + unlock API | |
| 46 | 48 | router = git_http::routes(router); // smart-HTTP git endpoints | |
| 47 | 49 | router | |
| 48 | 50 | // 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 | |||
| 226 | 226 | .kanban .card .card-details img { max-width:100%; height:auto; border-radius:4px; margin:2px 0; } | |
| 227 | 227 | .kanban .card .card-details > :last-child { margin-bottom:0; } | |
| 228 | 228 | .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; } | |
| 229 | 234 | .todo-board-head { font-size:13px; font-weight:600; margin:20px 0 10px; } | |
| 230 | 235 | /* Repo home: the board leads the page, clipped to a fixed-height teaser that | |
| 231 | 236 | expands in place. A checkbox drives it, not <details>, because a closed | |
| ⋯ 795 unchanged lines | |||
| 1027 | 1032 | Ok(m) => m, | |
| 1028 | 1033 | Err(resp) => return resp, | |
| 1029 | 1034 | }; | |
| 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() | |
| 1031 | 1037 | } | |
| 1032 | 1038 | ||
| 1033 | 1039 | /// `POST /{owner}/{repo}/settings` — update description / visibility. | |
| ⋯ 30 unchanged lines | |||
| 1064 | 1070 | owner: &str, | |
| 1065 | 1071 | repo: &str, | |
| 1066 | 1072 | meta: &Repository, | |
| 1073 | + | secrets: Markup, | |
| 1067 | 1074 | error: Option<&str>, | |
| 1068 | 1075 | csrf: &str, | |
| 1069 | 1076 | ) -> Markup { | |
| ⋯ 22 unchanged lines | |||
| 1092 | 1099 | } | |
| 1093 | 1100 | p { button.btn type="submit" { "Save changes" } } | |
| 1094 | 1101 | } | |
| 1102 | + | (secrets) | |
| 1095 | 1103 | }, | |
| 1096 | 1104 | ) | |
| 1097 | 1105 | } | |
| ⋯ 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 | |||
| 35 | 35 | - an **image allowlist** (`ci.allowed_images`) — empty allows any image, which | |
| 36 | 36 | is fine single-tenant; set it before letting strangers push. | |
| 37 | 37 | ||
| 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 | + | ||
| 38 | 46 | **Deliberately not done:** read-only rootfs (the workspace lives in the | |
| 39 | 47 | container filesystem precisely so no volume is ever attached; builds also | |
| 40 | 48 | write `$HOME` caches), and egress *filtering* (network is all-or-nothing). | |
| ⋯ 69 unchanged lines | |||