anvilsign in

collin/anvil

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
8use std::{
9 collections::BTreeMap,
10 io::{
11 IsTerminal,
12 Read,
13 },
14 path::PathBuf,
15};
16
17use anvil_core::secrets::{
18 Envelope,
19 Identity,
20 Recipient,
21 body_aad,
22 seal,
23};
24use anyhow::{
25 Context,
26 Result,
27 anyhow,
28 bail,
29};
30use clap::Subcommand;
31use serde::Deserialize;
32
33#[derive(Subcommand)]
34pub 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)]
72pub 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
86pub 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
109async 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
144async 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
190async 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
205async 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
233async 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
264fn 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 // ssh-agent is no help here: the agent protocol only signs, and opening
280 // an envelope needs the scalar itself for key agreement. So the key file
281 // has to be decrypted in this process.
282 let passphrase = match std::env::var("ANVIL_KEY_PASSPHRASE") {
283 Ok(passphrase) => passphrase,
284 Err(_) => prompt_passphrase(&path)?,
285 };
286 key.decrypt(passphrase)
287 .with_context(|| format!("decrypting {} (wrong passphrase?)", path.display()))?
288 } else {
289 key
290 };
291 Ok(Identity::from_private_key(&key)?)
292}
293
294/// Ask for the key's passphrase, explaining the way out when there is no
295/// terminal to ask on (a cron job, a pipeline, an agent's shell).
296fn prompt_passphrase(path: &std::path::Path) -> Result<String> {
297 rpassword::prompt_password(format!("passphrase for {}: ", path.display())).map_err(|e| {
298 anyhow!(
299 "{} is passphrase-protected and there is no terminal to prompt on ({e}). \
300 Set ANVIL_KEY_PASSPHRASE, or point --identity at an unencrypted key.",
301 path.display()
302 )
303 })
304}
305
306// --- HTTP client -----------------------------------------------------------
307
308struct Client {
309 base: String,
310 username: String,
311 password: String,
312 http: reqwest::Client,
313}
314
315#[derive(Deserialize)]
316struct SecretsState {
317 unlocked_until: i64,
318 recipients: Vec<RecipientJson>,
319 secrets: Vec<SecretJson>,
320}
321
322#[derive(Deserialize)]
323struct RecipientJson {
324 fingerprint: String,
325 key: String,
326}
327
328#[derive(Deserialize)]
329struct SecretJson {
330 name: String,
331 envelope: serde_json::Value,
332 recipients: Vec<String>,
333}
334
335#[derive(Deserialize)]
336struct UnlockResponse {
337 unlocked_until: i64,
338 count: usize,
339}
340
341impl SecretsState {
342 fn recipient_keys(&self) -> Result<Vec<Recipient>> {
343 if self.recipients.is_empty() {
344 bail!("the repository owner has no ssh-ed25519 key registered — add one first");
345 }
346 self.recipients
347 .iter()
348 .map(|r| Recipient::from_openssh(&r.key).map_err(Into::into))
349 .collect()
350 }
351}
352
353impl SecretJson {
354 fn parse(&self) -> Result<Envelope> {
355 Ok(Envelope::parse(&serde_json::to_string(&self.envelope)?)?)
356 }
357}
358
359impl Client {
360 fn new(opts: &SecretOpts, config_base_url: &str) -> Result<Self> {
361 // HTTPS needs a crypto provider installed; the build deliberately has
362 // only ring (see the workspace manifest).
363 let _ = rustls::crypto::ring::default_provider().install_default();
364
365 let base = opts
366 .server
367 .clone()
368 .or_else(|| std::env::var("ANVIL_SERVER").ok())
369 .unwrap_or_else(|| config_base_url.to_string());
370 if base.is_empty() {
371 bail!("no server URL; pass --server or set ANVIL_SERVER");
372 }
373 let username = opts
374 .username
375 .clone()
376 .or_else(|| std::env::var("ANVIL_USER").ok())
377 .ok_or_else(|| anyhow!("no username; pass --as or set ANVIL_USER"))?;
378 let password = match std::env::var("ANVIL_PASSWORD") {
379 Ok(p) => p,
380 Err(_) => rpassword::prompt_password(format!("anvil password for {username}: "))?,
381 };
382 Ok(Self {
383 base: base.trim_end_matches('/').to_string(),
384 username,
385 password,
386 http: reqwest::Client::new(),
387 })
388 }
389
390 fn url(&self, repo: &str, suffix: &str) -> String {
391 format!("{}/{repo}/-/api/secrets{suffix}", self.base)
392 }
393
394 async fn fetch(&self, repo: &str) -> Result<SecretsState> {
395 split_repo(repo)?;
396 let response = self
397 .http
398 .get(self.url(repo, ""))
399 .basic_auth(&self.username, Some(&self.password))
400 .send()
401 .await
402 .context("contacting anvil")?;
403 check(response).await?.json().await.context("reading reply")
404 }
405
406 async fn put(&self, repo: &str, name: &str, envelope: &Envelope) -> Result<()> {
407 let response = self
408 .http
409 .post(self.url(repo, ""))
410 .basic_auth(&self.username, Some(&self.password))
411 .json(&serde_json::json!({ "name": name, "envelope": envelope }))
412 .send()
413 .await
414 .context("contacting anvil")?;
415 check(response).await?;
416 Ok(())
417 }
418
419 async fn delete(&self, repo: &str, name: &str) -> Result<()> {
420 let response = self
421 .http
422 .delete(self.url(repo, &format!("/{name}")))
423 .basic_auth(&self.username, Some(&self.password))
424 .send()
425 .await
426 .context("contacting anvil")?;
427 check(response).await?;
428 Ok(())
429 }
430
431 async fn unlock(
432 &self,
433 repo: &str,
434 values: BTreeMap<String, String>,
435 ttl_secs: i64,
436 ) -> Result<UnlockResponse> {
437 let response = self
438 .http
439 .post(self.url(repo, "/unlock"))
440 .basic_auth(&self.username, Some(&self.password))
441 .json(&serde_json::json!({ "values": values, "ttl_secs": ttl_secs }))
442 .send()
443 .await
444 .context("contacting anvil")?;
445 check(response).await?.json().await.context("reading reply")
446 }
447
448 async fn lock(&self, repo: &str) -> Result<()> {
449 let response = self
450 .http
451 .post(self.url(repo, "/lock"))
452 .basic_auth(&self.username, Some(&self.password))
453 .send()
454 .await
455 .context("contacting anvil")?;
456 check(response).await?;
457 Ok(())
458 }
459}
460
461async fn check(response: reqwest::Response) -> Result<reqwest::Response> {
462 if response.status().is_success() {
463 return Ok(response);
464 }
465 let status = response.status();
466 let body = response.text().await.unwrap_or_default();
467 bail!("anvil returned {status}: {}", body.trim())
468}
469
470// --- small helpers ---------------------------------------------------------
471
472fn split_repo(repo: &str) -> Result<(&str, &str)> {
473 repo.split_once('/')
474 .filter(|(o, n)| !o.is_empty() && !n.is_empty() && !n.contains('/'))
475 .ok_or_else(|| anyhow!("expected a repository as `owner/name`, got `{repo}`"))
476}
477
478/// Parse `30m` / `8h` / `7d` (bare digits are seconds) into seconds.
479fn parse_ttl(ttl: &str) -> Result<i64> {
480 let (digits, multiplier) = match ttl.chars().last() {
481 Some('s') => (&ttl[..ttl.len() - 1], 1),
482 Some('m') => (&ttl[..ttl.len() - 1], 60),
483 Some('h') => (&ttl[..ttl.len() - 1], 3600),
484 Some('d') => (&ttl[..ttl.len() - 1], 86400),
485 _ => (ttl, 1),
486 };
487 let n: i64 = digits
488 .parse()
489 .with_context(|| format!("bad --ttl `{ttl}` (try 45m, 8h, 7d)"))?;
490 Ok(n * multiplier)
491}
492
493fn fmt_time(unix: i64) -> String {
494 time::OffsetDateTime::from_unix_timestamp(unix)
495 .ok()
496 .and_then(|t| {
497 t.format(&time::format_description::well_known::Rfc3339)
498 .ok()
499 })
500 .unwrap_or_else(|| unix.to_string())
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn parses_ttls() {
509 assert_eq!(parse_ttl("45m").unwrap(), 2700);
510 assert_eq!(parse_ttl("8h").unwrap(), 28800);
511 assert_eq!(parse_ttl("7d").unwrap(), 604800);
512 assert_eq!(parse_ttl("90").unwrap(), 90);
513 assert!(parse_ttl("soon").is_err());
514 }
515
516 #[test]
517 fn splits_repository_references() {
518 assert_eq!(split_repo("collin/anvil").unwrap(), ("collin", "anvil"));
519 assert!(split_repo("anvil").is_err());
520 assert!(split_repo("collin/anvil/extra").is_err());
521 assert!(split_repo("/anvil").is_err());
522 }
523}