| 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 | } |