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 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
291struct Client {
292 base: String,
293 username: String,
294 password: String,
295 http: reqwest::Client,
296}
297
298#[derive(Deserialize)]
299struct SecretsState {
300 unlocked_until: i64,
301 recipients: Vec<RecipientJson>,
302 secrets: Vec<SecretJson>,
303}
304
305#[derive(Deserialize)]
306struct RecipientJson {
307 fingerprint: String,
308 key: String,
309}
310
311#[derive(Deserialize)]
312struct SecretJson {
313 name: String,
314 envelope: serde_json::Value,
315 recipients: Vec<String>,
316}
317
318#[derive(Deserialize)]
319struct UnlockResponse {
320 unlocked_until: i64,
321 count: usize,
322}
323
324impl 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
336impl SecretJson {
337 fn parse(&self) -> Result<Envelope> {
338 Ok(Envelope::parse(&serde_json::to_string(&self.envelope)?)?)
339 }
340}
341
342impl 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
444async 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
455fn 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.
462fn 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
476fn 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)]
487mod 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}