anvilsign in

collin/anvil

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
32use aes_gcm::{
33 Aes256Gcm,
34 KeyInit,
35 aead::{
36 Aead,
37 Payload,
38 },
39};
40use base64::Engine;
41use serde::{
42 Deserialize,
43 Serialize,
44};
45use sha2::{
46 Digest,
47 Sha512,
48};
49
50use crate::{
51 error::{
52 Error,
53 Result,
54 },
55 models::{
56 RepoSecret,
57 UserSecret,
58 },
59};
60
61/// Algorithm identifier carried in every envelope.
62pub const ALG: &str = "x25519-hkdf-sha256+aes256gcm";
63
64/// HKDF `info` string binding derived wrap keys to this scheme.
65const WRAP_INFO: &[u8] = b"anvil-secret-v1 wrap";
66
67/// Cap on a secret's plaintext. Environment variables, not blobs.
68pub const MAX_VALUE_BYTES: usize = 64 * 1024;
69
70/// Cap on a stored envelope: the value plus per-recipient overhead, base64'd,
71/// with room for a generous number of keys.
72pub const MAX_ENVELOPE_BYTES: usize = 256 * 1024;
73
74fn b64() -> base64::engine::general_purpose::GeneralPurpose {
75 base64::engine::general_purpose::STANDARD
76}
77
78fn decode_b64(what: &str, s: &str) -> Result<Vec<u8>> {
79 b64()
80 .decode(s)
81 .map_err(|e| Error::Invalid(format!("secret envelope: bad base64 in {what}: {e}")))
82}
83
84fn decode_array<const N: usize>(what: &str, s: &str) -> Result<[u8; N]> {
85 let bytes = decode_b64(what, s)?;
86 <[u8; N]>::try_from(bytes.as_slice())
87 .map_err(|_| Error::Invalid(format!("secret envelope: {what} must be {N} bytes")))
88}
89
90/// A sealed secret value: the encrypted body plus one wrapped file key per
91/// recipient. Serialized as JSON, which is what both the browser and the CLI
92/// hand to the server.
93#[derive(Clone, Debug, Deserialize, Serialize)]
94pub struct Envelope {
95 pub v: u32,
96 pub alg: String,
97 pub recipients: Vec<Stanza>,
98 /// Base64 12-byte AES-GCM nonce for the body.
99 pub nonce: String,
100 /// Base64 AES-GCM ciphertext ‖ tag of the value.
101 pub ct: String,
102}
103
104/// One recipient's wrapped copy of the file key.
105#[derive(Clone, Debug, Deserialize, Serialize)]
106pub struct Stanza {
107 /// The recipient key's canonical SSH fingerprint (`SHA256:…`).
108 pub fp: String,
109 /// Base64 32-byte ephemeral X25519 public key.
110 pub epk: String,
111 /// Base64 12-byte nonce ‖ AES-GCM ciphertext of the 32-byte file key.
112 pub wrap: String,
113}
114
115impl Envelope {
116 /// Parse and structurally validate an envelope received from a client.
117 pub fn parse(json: &str) -> Result<Self> {
118 if json.len() > MAX_ENVELOPE_BYTES {
119 return Err(Error::Invalid("secret envelope too large".into()));
120 }
121 let env: Envelope = serde_json::from_str(json)
122 .map_err(|e| Error::Invalid(format!("secret envelope: {e}")))?;
123 env.validate()?;
124 Ok(env)
125 }
126
127 /// Check the parts the *server* can check: version, algorithm, and that
128 /// every field decodes to the right length. It cannot check the
129 /// ciphertext — that is the whole point.
130 pub fn validate(&self) -> Result<()> {
131 if self.v != 1 || self.alg != ALG {
132 return Err(Error::Invalid(format!(
133 "secret envelope: unsupported version/algorithm ({}/{})",
134 self.v, self.alg
135 )));
136 }
137 if self.recipients.is_empty() {
138 return Err(Error::Invalid("secret envelope: no recipients".into()));
139 }
140 decode_array::<12>("nonce", &self.nonce)?;
141 if decode_b64("ct", &self.ct)?.len() < 16 {
142 return Err(Error::Invalid("secret envelope: body too short".into()));
143 }
144 for r in &self.recipients {
145 if !r.fp.starts_with("SHA256:") {
146 return Err(Error::Invalid(
147 "secret envelope: recipient fingerprint must be SHA256:…".into(),
148 ));
149 }
150 decode_array::<32>("epk", &r.epk)?;
151 if decode_b64("wrap", &r.wrap)?.len() != 12 + 32 + 16 {
152 return Err(Error::Invalid("secret envelope: bad wrapped key".into()));
153 }
154 }
155 Ok(())
156 }
157
158 /// The fingerprints this envelope can be opened by, in order.
159 pub fn recipient_fingerprints(&self) -> Vec<String> {
160 self.recipients.iter().map(|r| r.fp.clone()).collect()
161 }
162
163 /// Decrypt with `identity`, which must be one of the recipients.
164 pub fn open(&self, aad: &[u8], identity: &Identity) -> Result<Vec<u8>> {
165 self.validate()?;
166 let stanza = self
167 .recipients
168 .iter()
169 .find(|r| r.fp == identity.fingerprint)
170 .ok_or_else(|| {
171 Error::Invalid(format!(
172 "secret is not sealed to {} — rekey it first",
173 identity.fingerprint
174 ))
175 })?;
176
177 let epk = decode_array::<32>("epk", &stanza.epk)?;
178 let shared = x25519(&identity.secret, &epk);
179 if shared.iter().all(|b| *b == 0) {
180 return Err(Error::Invalid(
181 "secret envelope: degenerate key exchange".into(),
182 ));
183 }
184 let mut salt = [0u8; 64];
185 salt[..32].copy_from_slice(&epk);
186 salt[32..].copy_from_slice(&identity.public);
187 let wrap_key = hkdf_sha256(&shared, &salt, WRAP_INFO);
188
189 let wrap = decode_b64("wrap", &stanza.wrap)?;
190 let wrap_nonce = <[u8; 12]>::try_from(&wrap[..12])
191 .map_err(|_| Error::Invalid("secret envelope: bad wrap nonce".into()))?;
192 let file_key = aes_open(&wrap_key, &wrap_nonce, &wrap[12..], stanza.fp.as_bytes())
193 .map_err(|_| Error::Invalid("secret envelope: wrapped key did not open".into()))?;
194 let file_key = <[u8; 32]>::try_from(file_key.as_slice())
195 .map_err(|_| Error::Invalid("secret envelope: bad file key".into()))?;
196
197 let nonce = decode_array::<12>("nonce", &self.nonce)?;
198 let ct = decode_b64("ct", &self.ct)?;
199 aes_open(&file_key, &nonce, &ct, aad)
200 .map_err(|_| Error::Invalid("secret envelope: body did not open".into()))
201 }
202}
203
204/// A key a secret can be sealed *to*: an ssh-ed25519 public key mapped onto
205/// Curve25519.
206#[derive(Clone, Debug)]
207pub struct Recipient {
208 pub fingerprint: String,
209 pub x25519: [u8; 32],
210}
211
212impl Recipient {
213 /// Build a recipient from a registered OpenSSH public-key line. Only
214 /// `ssh-ed25519` keys can receive secrets: RSA would need a second
215 /// scheme, and `*-sk` (FIDO) keys cannot do key agreement at all.
216 pub fn from_openssh(line: &str) -> Result<Self> {
217 let key = ssh_key::PublicKey::from_openssh(line.trim())
218 .map_err(|e| Error::Invalid(format!("invalid ssh public key: {e}")))?;
219 let ed = key.key_data().ed25519().ok_or_else(|| {
220 Error::Invalid(format!(
221 "{} keys cannot receive secrets — register an ssh-ed25519 key",
222 key.algorithm().as_str()
223 ))
224 })?;
225 Ok(Self {
226 fingerprint: key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
227 x25519: ed25519_public_to_x25519(&ed.0)?,
228 })
229 }
230}
231
232/// The private half: what the CLI holds to open envelopes.
233#[derive(Clone)]
234pub struct Identity {
235 pub fingerprint: String,
236 secret: [u8; 32],
237 public: [u8; 32],
238}
239
240impl std::fmt::Debug for Identity {
241 /// Never render the secret scalar.
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 f.debug_struct("Identity")
244 .field("fingerprint", &self.fingerprint)
245 .finish_non_exhaustive()
246 }
247}
248
249impl Identity {
250 /// Derive an identity from a decrypted OpenSSH private key.
251 pub fn from_private_key(key: &ssh_key::PrivateKey) -> Result<Self> {
252 let ed = key.key_data().ed25519().ok_or_else(|| {
253 Error::Invalid(format!(
254 "{} private keys cannot open secrets — use an ssh-ed25519 key",
255 key.algorithm().as_str()
256 ))
257 })?;
258 let secret = ed25519_seed_to_x25519(ed.private.as_ref());
259 Ok(Self {
260 fingerprint: key
261 .public_key()
262 .fingerprint(ssh_key::HashAlg::Sha256)
263 .to_string(),
264 public: ed25519_public_to_x25519(&ed.public.0)?,
265 secret,
266 })
267 }
268}
269
270/// Seal `plaintext` to every recipient. Mirrors `sealSecret()` in the
271/// browser's `secrets.js` byte for byte — the interop test in
272/// `tests/js_interop.rs` opens what that code produces.
273pub fn seal(plaintext: &[u8], aad: &[u8], recipients: &[Recipient]) -> Result<Envelope> {
274 if plaintext.len() > MAX_VALUE_BYTES {
275 return Err(Error::Invalid(format!(
276 "secret is larger than {MAX_VALUE_BYTES} bytes"
277 )));
278 }
279 if recipients.is_empty() {
280 return Err(Error::Invalid(
281 "no ssh-ed25519 keys to seal to — register one first".into(),
282 ));
283 }
284 let file_key: [u8; 32] = random_bytes();
285 let nonce: [u8; 12] = random_bytes();
286 let ct = aes_seal(&file_key, &nonce, plaintext, aad)?;
287
288 let mut stanzas = Vec::with_capacity(recipients.len());
289 for r in recipients {
290 let esk: [u8; 32] = random_bytes();
291 let epk = x25519(&esk, &X25519_BASEPOINT);
292 let shared = x25519(&esk, &r.x25519);
293 if shared.iter().all(|b| *b == 0) {
294 return Err(Error::Invalid(format!(
295 "recipient {} has a degenerate public key",
296 r.fingerprint
297 )));
298 }
299 let mut salt = [0u8; 64];
300 salt[..32].copy_from_slice(&epk);
301 salt[32..].copy_from_slice(&r.x25519);
302 let wrap_key = hkdf_sha256(&shared, &salt, WRAP_INFO);
303 let wrap_nonce: [u8; 12] = random_bytes();
304 let mut wrap = wrap_nonce.to_vec();
305 wrap.extend_from_slice(&aes_seal(
306 &wrap_key,
307 &wrap_nonce,
308 &file_key,
309 r.fingerprint.as_bytes(),
310 )?);
311 stanzas.push(Stanza {
312 fp: r.fingerprint.clone(),
313 epk: b64().encode(epk),
314 wrap: b64().encode(wrap),
315 });
316 }
317 Ok(Envelope {
318 v: 1,
319 alg: ALG.to_string(),
320 recipients: stanzas,
321 nonce: b64().encode(nonce),
322 ct: b64().encode(ct),
323 })
324}
325
326/// Associated data bound into a sealed body: the scheme, the repository, and
327/// the variable name. Re-pointing a stolen envelope at another repo or another
328/// variable name therefore fails to open.
329pub fn body_aad(owner: &str, repo: &str, name: &str) -> Vec<u8> {
330 format!("anvil-secret-v1\n{owner}/{repo}\n{name}").into_bytes()
331}
332
333/// Associated data for a [`UserSecret`](UserSecret): the
334/// scheme, the owning account, and the variable name. A user-secret envelope
335/// and a repo-secret envelope never open under each other's AAD, even if a
336/// name collides, because `user:{username}` can never equal `{owner}/{repo}`.
337pub fn user_aad(username: &str, name: &str) -> Vec<u8> {
338 format!("anvil-secret-v1\nuser:{username}\n{name}").into_bytes()
339}
340
341/// The three ways a [`UserSecret`](UserSecret) lands in a
342/// session container.
343pub mod kind {
344 /// Injected as an environment variable named after the secret.
345 pub const ENV: &str = "env";
346 /// Written whole as a file at the secret's `path`, under `$HOME`.
347 pub const FILE: &str = "file";
348 /// Merged into one field (`field`, a jq-style path) of the JSON file at
349 /// `path`, under `$HOME` — the rest of that file is left alone.
350 pub const JSON: &str = "json";
351}
352
353/// Whether `name` is usable as a shell environment variable: uppercase,
354/// digits, and underscores, not starting with a digit.
355pub fn valid_name(name: &str) -> bool {
356 !name.is_empty()
357 && name.len() <= 64
358 && !name.starts_with(|c: char| c.is_ascii_digit())
359 && name
360 .chars()
361 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
362}
363
364// --- primitives ------------------------------------------------------------
365
366fn random_bytes<const N: usize>() -> [u8; N] {
367 // `UnwrapErr(SysRng)` is the OS generator, panicking if it ever fails —
368 // what `OsRng.fill_bytes` did before rand 0.10 renamed and split the two.
369 use rand::{
370 Rng,
371 rand_core::UnwrapErr,
372 rngs::SysRng,
373 };
374 let mut bytes = [0u8; N];
375 UnwrapErr(SysRng).fill_bytes(&mut bytes);
376 bytes
377}
378
379/// HKDF-SHA256 (RFC 5869) for a single 32-byte output — extract, then one
380/// expand block. Written out rather than pulled in as a dependency: two HMAC
381/// calls are not worth one, and it was originally a `sha2`/`digest`
382/// generation ahead of the rest of the tree.
383fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8]) -> [u8; 32] {
384 use hmac::{
385 Hmac,
386 KeyInit,
387 Mac,
388 };
389 type H = Hmac<sha2::Sha256>;
390
391 let mut extract = H::new_from_slice(salt).expect("HMAC accepts any key length");
392 extract.update(ikm);
393 let prk = extract.finalize().into_bytes();
394
395 let mut expand = H::new_from_slice(&prk).expect("HMAC accepts any key length");
396 expand.update(info);
397 expand.update(&[0x01]);
398 expand.finalize().into_bytes().into()
399}
400
401fn aes_seal(key: &[u8; 32], nonce: &[u8; 12], msg: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
402 let cipher = Aes256Gcm::new(key.into());
403 cipher
404 .encrypt(nonce.into(), Payload { msg, aad })
405 .map_err(|_| Error::Invalid("sealing secret failed".into()))
406}
407
408fn aes_open(
409 key: &[u8; 32],
410 nonce: &[u8; 12],
411 ct: &[u8],
412 aad: &[u8],
413) -> std::result::Result<Vec<u8>, ()> {
414 let cipher = Aes256Gcm::new(key.into());
415 cipher
416 .decrypt(nonce.into(), Payload { msg: ct, aad })
417 .map_err(|_| ())
418}
419
420/// The Curve25519 base point in Montgomery form (u = 9).
421const X25519_BASEPOINT: [u8; 32] = {
422 let mut u = [0u8; 32];
423 u[0] = 9;
424 u
425};
426
427/// X25519 scalar multiplication: clamp the scalar, multiply the u-coordinate.
428fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> [u8; 32] {
429 curve25519_dalek::montgomery::MontgomeryPoint(*point)
430 .mul_clamped(*scalar)
431 .to_bytes()
432}
433
434/// Map an Ed25519 public key (compressed Edwards `y`) to its X25519
435/// (Montgomery `u`) counterpart.
436fn ed25519_public_to_x25519(public: &[u8; 32]) -> Result<[u8; 32]> {
437 curve25519_dalek::edwards::CompressedEdwardsY(*public)
438 .decompress()
439 .map(|p| p.to_montgomery().to_bytes())
440 .ok_or_else(|| Error::Invalid("ssh-ed25519 key is not a valid curve point".into()))
441}
442
443/// Map an Ed25519 seed to the X25519 secret scalar: SHA-512, keep the low
444/// half, clamp — the standard derivation OpenSSH keys share with age.
445fn ed25519_seed_to_x25519(seed: &[u8]) -> [u8; 32] {
446 let digest = Sha512::digest(seed);
447 let mut scalar = [0u8; 32];
448 scalar.copy_from_slice(&digest[..32]);
449 scalar[0] &= 248;
450 scalar[31] &= 127;
451 scalar[31] |= 64;
452 scalar
453}
454
455// --- json merge (the "json" kind) -------------------------------------------
456
457/// Every strict prefix of `field` that ends right before a top-level `.`
458/// (i.e. one outside `[...]` and quoted strings), shortest first. For
459/// `.oauthAccount.token` that's just `[".oauthAccount"]`; for `.a.b.c` it's
460/// `[".a", ".a.b"]`. Used to vivify each missing intermediate object before
461/// the final assignment — see the comment in [`json_merge`].
462fn path_prefixes(field: &str) -> Vec<&str> {
463 let mut prefixes = Vec::new();
464 let mut depth = 0i32;
465 let mut in_quotes = false;
466 for (i, b) in field.bytes().enumerate() {
467 match b {
468 b'"' => in_quotes = !in_quotes,
469 b'[' if !in_quotes => depth += 1,
470 b']' if !in_quotes => depth -= 1,
471 b'.' if !in_quotes && depth == 0 && i > 0 => prefixes.push(&field[..i]),
472 _ => {}
473 }
474 }
475 prefixes
476}
477
478/// Set `field` (a jq-style path, e.g. `.oauthAccount.token`) to `value`
479/// within `current` (a JSON document, or empty for "start from `{}`"),
480/// returning the whole document with that one field changed.
481///
482/// `value` is bound as a jq variable (`$__anvil_secret_value`) rather than
483/// interpolated into the filter text, so it is never parsed as jq syntax —
484/// only `field` is; it comes from the secret's own metadata (set by whoever
485/// created it), never from the decrypted plaintext.
486pub fn json_merge(current: &[u8], field: &str, value: &str) -> Result<Vec<u8>> {
487 use jaq_core::{
488 Compiler,
489 Ctx,
490 Vars,
491 data,
492 load::{
493 Arena,
494 File,
495 Loader,
496 },
497 unwrap_valr,
498 };
499 use jaq_json::Val;
500
501 let current = if current.is_empty() {
502 b"{}".as_slice()
503 } else {
504 current
505 };
506 let current = jaq_json::read::parse_single(current)
507 .map_err(|e| Error::Invalid(format!("json secret: existing file is not JSON: {e}")))?;
508
509 // Deliberately no jaq_std/jaq_json defs: `field = $value` is core jq
510 // path/assignment syntax, entirely handled by jaq_core, and never names a
511 // library filter. jaq_std's defs.jq is loaded as one unit — pulling it in
512 // for the few basics jaq_json's own defs lean on drags in every other
513 // definition too, including ones behind features (format/log/math/regex/
514 // time) this crate does not enable, which then fail to resolve even
515 // though nothing here calls them.
516 //
517 // Real jq auto-creates missing intermediate objects (`{} | .a.b = 1`
518 // gives `{"a":{"b":1}}`); jaq 3.1.1 does not — `setpath`/`=` error with
519 // "cannot use null as iterable" the moment a path walks through a
520 // missing key, confirmed against both jaq-core directly and the real
521 // `jaq` CLI binary. `//=` (default-if-null) does not have that bug, so
522 // each intermediate prefix of the path is vivified with one before the
523 // final assignment.
524 let mut program = String::new();
525 for prefix in path_prefixes(field) {
526 program.push('(');
527 program.push_str(prefix);
528 program.push_str(" //= {}) | ");
529 }
530 program.push_str(field);
531 program.push_str(" = $__anvil_secret_value");
532 let arena = Arena::default();
533 let modules = Loader::new(jaq_core::defs())
534 .load(
535 &arena,
536 File {
537 path: (),
538 code: program.as_str(),
539 },
540 )
541 .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?;
542
543 let funs = jaq_core::funs().chain(jaq_json::funs());
544 let filter = Compiler::default()
545 .with_funs(funs)
546 .with_global_vars(["$__anvil_secret_value"])
547 .compile(modules)
548 .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?;
549
550 let vars = Vars::new([Val::from(value.to_string())]);
551 let ctx = Ctx::<data::JustLut<Val>>::new(&filter.lut, vars);
552 let mut out = filter.id.run((ctx, current)).map(unwrap_valr);
553 let result = out
554 .next()
555 .ok_or_else(|| Error::Invalid("json secret: jq path produced no output".into()))?
556 .map_err(|e| Error::Invalid(format!("json secret: {e}")))?;
557
558 let mut buf = Vec::new();
559 let pp = jaq_json::write::Pp {
560 indent: Some(" ".to_string()),
561 ..Default::default()
562 };
563 jaq_json::write::write(&mut buf, &pp, 0, &result)
564 .map_err(|e| Error::Invalid(format!("json secret: serializing result: {e}")))?;
565 Ok(buf)
566}
567
568// --- persistence -----------------------------------------------------------
569
570/// List a repository's secrets, oldest first. Envelopes are opaque here.
571pub async fn list(db: &toasty::Db, repo_id: i64) -> Result<Vec<RepoSecret>> {
572 let mut conn = db.clone();
573 let mut secrets = RepoSecret::filter(RepoSecret::fields().repo_id().eq(repo_id))
574 .exec(&mut conn)
575 .await?;
576 secrets.sort_by(|a, b| a.name.cmp(&b.name));
577 Ok(secrets)
578}
579
580/// Look one up by name within a repository.
581pub async fn find(db: &toasty::Db, repo_id: i64, name: &str) -> Result<Option<RepoSecret>> {
582 Ok(list(db, repo_id)
583 .await?
584 .into_iter()
585 .find(|s| s.name == name))
586}
587
588/// Create or replace a secret. `envelope` must already have been parsed with
589/// [`Envelope::parse`]; its recipient fingerprints are denormalized onto the
590/// row so the UI can flag secrets that a newly added key cannot open.
591pub async fn put(db: &toasty::Db, repo_id: i64, name: &str, envelope: &Envelope) -> Result<()> {
592 if !valid_name(name) {
593 return Err(Error::Invalid(
594 "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
595 ));
596 }
597 let json = serde_json::to_string(envelope)
598 .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
599 let recipients = envelope.recipient_fingerprints().join(",");
600 let now = crate::now();
601 let mut conn = db.clone();
602 match find(db, repo_id, name).await? {
603 Some(mut existing) => {
604 existing
605 .update()
606 .envelope(json)
607 .recipients(recipients)
608 .updated_at(now)
609 .exec(&mut conn)
610 .await?;
611 }
612 None => {
613 toasty::create!(RepoSecret {
614 repo_id: repo_id,
615 name: name,
616 envelope: json,
617 recipients: recipients,
618 created_at: now,
619 updated_at: now,
620 })
621 .exec(&mut conn)
622 .await?;
623 }
624 }
625 Ok(())
626}
627
628/// Delete a secret by name. No-op if it does not exist.
629pub async fn delete(db: &toasty::Db, repo_id: i64, name: &str) -> Result<()> {
630 if let Some(secret) = find(db, repo_id, name).await? {
631 let mut conn = db.clone();
632 secret.delete().exec(&mut conn).await?;
633 }
634 Ok(())
635}
636
637/// Delete every secret of a repository (used when the repo goes away).
638pub async fn delete_all(db: &toasty::Db, repo_id: i64) -> Result<()> {
639 for secret in list(db, repo_id).await? {
640 let mut conn = db.clone();
641 secret.delete().exec(&mut conn).await?;
642 }
643 Ok(())
644}
645
646// --- user secrets ------------------------------------------------------------
647//
648// The same shape as the repository functions above, keyed by `user_id`
649// instead of `repo_id`. See [`UserSecret`].
650
651/// List an account's secrets, oldest first. Envelopes are opaque here.
652pub async fn list_for_user(db: &toasty::Db, user_id: i64) -> Result<Vec<UserSecret>> {
653 let mut conn = db.clone();
654 let mut secrets = UserSecret::filter(UserSecret::fields().user_id().eq(user_id))
655 .exec(&mut conn)
656 .await?;
657 secrets.sort_by(|a, b| a.name.cmp(&b.name));
658 Ok(secrets)
659}
660
661/// Look one up by name within an account.
662pub async fn find_for_user(
663 db: &toasty::Db,
664 user_id: i64,
665 name: &str,
666) -> Result<Option<UserSecret>> {
667 Ok(list_for_user(db, user_id)
668 .await?
669 .into_iter()
670 .find(|s| s.name == name))
671}
672
673/// Create or replace a user secret. `envelope` must already have been parsed
674/// with [`Envelope::parse`]. `dest_path` must be non-empty for `kind::FILE`/
675/// `kind::JSON` and empty for `kind::ENV`; `field` must be non-empty only for
676/// `kind::JSON`.
677#[allow(clippy::too_many_arguments)]
678pub async fn put_for_user(
679 db: &toasty::Db,
680 user_id: i64,
681 name: &str,
682 put_kind: &str,
683 dest_path: &str,
684 field: &str,
685 envelope: &Envelope,
686) -> Result<()> {
687 if !valid_name(name) {
688 return Err(Error::Invalid(
689 "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
690 ));
691 }
692 // Always relative to $HOME by construction — strip a leading "~/" or "/"
693 // so "~/.claude/x.json", "/.claude/x.json" and ".claude/x.json" all store
694 // (and later inject) the same way.
695 let dest_path = dest_path
696 .strip_prefix("~/")
697 .or_else(|| dest_path.strip_prefix('/'))
698 .unwrap_or(dest_path);
699 let has_path = !dest_path.is_empty();
700 let has_field = !field.is_empty();
701 match put_kind {
702 kind::ENV if has_path || has_field => {
703 return Err(Error::Invalid("env secrets take no path or field".into()));
704 }
705 kind::FILE if !has_path || has_field => {
706 return Err(Error::Invalid(
707 "file secrets need a path and take no field".into(),
708 ));
709 }
710 kind::JSON if !has_path || !has_field => {
711 return Err(Error::Invalid(
712 "json secrets need both a path and a field".into(),
713 ));
714 }
715 kind::ENV | kind::FILE | kind::JSON => {}
716 _ => return Err(Error::Invalid(format!("unknown secret kind `{put_kind}`"))),
717 }
718
719 let json = serde_json::to_string(envelope)
720 .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
721 let recipients = envelope.recipient_fingerprints().join(",");
722 let now = crate::now();
723 let mut conn = db.clone();
724 match find_for_user(db, user_id, name).await? {
725 Some(mut existing) => {
726 existing
727 .update()
728 .kind(put_kind)
729 .dest_path(dest_path)
730 .field(field)
731 .envelope(json)
732 .recipients(recipients)
733 .updated_at(now)
734 .exec(&mut conn)
735 .await?;
736 }
737 None => {
738 toasty::create!(UserSecret {
739 user_id: user_id,
740 name: name,
741 kind: put_kind,
742 dest_path: dest_path,
743 field: field,
744 envelope: json,
745 recipients: recipients,
746 created_at: now,
747 updated_at: now,
748 })
749 .exec(&mut conn)
750 .await?;
751 }
752 }
753 Ok(())
754}
755
756/// Delete a user secret by name. No-op if it does not exist.
757pub async fn delete_for_user(db: &toasty::Db, user_id: i64, name: &str) -> Result<()> {
758 if let Some(secret) = find_for_user(db, user_id, name).await? {
759 let mut conn = db.clone();
760 secret.delete().exec(&mut conn).await?;
761 }
762 Ok(())
763}
764
765/// Delete every secret of an account (for account deletion, once that path
766/// exists — mirrors [`delete_all`]).
767pub async fn delete_all_for_user(db: &toasty::Db, user_id: i64) -> Result<()> {
768 for secret in list_for_user(db, user_id).await? {
769 let mut conn = db.clone();
770 secret.delete().exec(&mut conn).await?;
771 }
772 Ok(())
773}
774
775// --- the unlock vault ------------------------------------------------------
776
777/// Plaintext secrets for unlocked repositories, held in memory only.
778///
779/// A repository is *sealed* until someone with a recipient ssh key runs
780/// `anvild secret unlock`, which opens the envelopes locally and posts the
781/// values here. They live in this map and nowhere else: no file, no database
782/// row, no log. A restart re-seals every repository, and each entry expires on
783/// its own TTL. CI reads from here (see `anvil-ci`), which is the one place
784/// anvil handles plaintext at all.
785#[derive(Clone, Default)]
786pub struct Vault {
787 inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<i64, Unlocked>>>,
788}
789
790struct Unlocked {
791 values: std::collections::BTreeMap<String, String>,
792 expires_at: i64,
793}
794
795impl Drop for Unlocked {
796 /// Overwrite the plaintext when an entry expires or is replaced, so it
797 /// does not linger in freed heap pages.
798 fn drop(&mut self) {
799 for value in self.values.values_mut() {
800 // SAFETY-adjacent: writing over the bytes in place. `String`'s
801 // buffer is the only copy we made.
802 unsafe { value.as_bytes_mut() }.fill(0);
803 }
804 }
805}
806
807/// What the UI shows about an unlocked repository.
808#[derive(Clone, Copy, Debug)]
809pub struct UnlockStatus {
810 pub expires_at: i64,
811 pub count: usize,
812}
813
814/// Current Unix time in seconds, so the web layer can render an unlock
815/// countdown against the same clock the vault expires on.
816pub fn now_secs() -> i64 {
817 crate::now()
818}
819
820/// Longest an unlock may last before it has to be renewed.
821pub const MAX_UNLOCK_SECS: i64 = 7 * 24 * 60 * 60;
822
823impl Vault {
824 /// Store `values` for `repo_id`, replacing any previous unlock. Returns
825 /// the expiry timestamp.
826 pub fn unlock(
827 &self,
828 repo_id: i64,
829 values: std::collections::BTreeMap<String, String>,
830 ttl_secs: i64,
831 ) -> i64 {
832 let ttl = ttl_secs.clamp(60, MAX_UNLOCK_SECS);
833 let expires_at = crate::now() + ttl;
834 let mut map = self.inner.lock().expect("vault mutex");
835 map.insert(repo_id, Unlocked { values, expires_at });
836 expires_at
837 }
838
839 /// Forget a repository's secrets immediately.
840 pub fn lock(&self, repo_id: i64) {
841 self.inner.lock().expect("vault mutex").remove(&repo_id);
842 }
843
844 /// Current unlock state, or `None` if sealed or expired.
845 pub fn status(&self, repo_id: i64) -> Option<UnlockStatus> {
846 let mut map = self.inner.lock().expect("vault mutex");
847 let entry = map.get(&repo_id)?;
848 if entry.expires_at <= crate::now() {
849 map.remove(&repo_id);
850 return None;
851 }
852 Some(UnlockStatus {
853 expires_at: entry.expires_at,
854 count: entry.values.len(),
855 })
856 }
857
858 /// Fetch the named secrets for a CI run. Returns the names that are not
859 /// available as the error, so the runner can say exactly what is missing.
860 ///
861 /// Asking for nothing always succeeds, sealed repository or not — the
862 /// overwhelmingly common pipeline declares no `secrets:` at all, and
863 /// failing it here would mean no repository could run CI until someone had
864 /// unlocked it for secrets it does not use.
865 pub fn take(
866 &self,
867 repo_id: i64,
868 names: &[String],
869 ) -> std::result::Result<Vec<(String, String)>, Vec<String>> {
870 if names.is_empty() {
871 return Ok(Vec::new());
872 }
873 let mut map = self.inner.lock().expect("vault mutex");
874 let Some(entry) = map.get(&repo_id) else {
875 return Err(names.to_vec());
876 };
877 if entry.expires_at <= crate::now() {
878 map.remove(&repo_id);
879 return Err(names.to_vec());
880 }
881 let mut found = Vec::with_capacity(names.len());
882 let mut missing = Vec::new();
883 for name in names {
884 match entry.values.get(name) {
885 Some(value) => found.push((name.clone(), value.clone())),
886 None => missing.push(name.clone()),
887 }
888 }
889 if missing.is_empty() {
890 Ok(found)
891 } else {
892 Err(missing)
893 }
894 }
895
896 /// Drop expired entries (called from the periodic sweep).
897 pub fn sweep(&self) {
898 let now = crate::now();
899 self.inner
900 .lock()
901 .expect("vault mutex")
902 .retain(|_, entry| entry.expires_at > now);
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use ssh_key::{
909 PrivateKey,
910 private::Ed25519Keypair,
911 };
912
913 use super::*;
914
915 #[test]
916 fn json_merge_sets_a_top_level_field_on_empty_input() {
917 let out = json_merge(b"", ".token", "hunter2").unwrap();
918 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
919 assert_eq!(v, serde_json::json!({"token": "hunter2"}));
920 }
921
922 #[test]
923 fn json_merge_creates_intermediate_objects() {
924 let out = json_merge(b"", ".oauthAccount.token", "hunter2").unwrap();
925 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
926 assert_eq!(v, serde_json::json!({"oauthAccount": {"token": "hunter2"}}));
927 }
928
929 #[test]
930 fn json_merge_preserves_sibling_fields() {
931 let existing = br#"{"theme":"auto","oauthAccount":{"other":1}}"#;
932 let out = json_merge(existing, ".oauthAccount.token", "hunter2").unwrap();
933 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
934 assert_eq!(
935 v,
936 serde_json::json!({"theme": "auto", "oauthAccount": {"other": 1, "token": "hunter2"}})
937 );
938 }
939
940 #[test]
941 fn json_merge_does_not_interpolate_the_value_as_jq_syntax() {
942 // A value that looks like a jq injection attempt must land as a
943 // literal string, not be evaluated.
944 let out = json_merge(b"", ".token", "\" | .pwned = true # ").unwrap();
945 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
946 assert_eq!(v, serde_json::json!({"token": "\" | .pwned = true # "}));
947 }
948
949 #[test]
950 fn json_merge_rejects_bad_paths() {
951 assert!(json_merge(b"{}", "not a jq path", "x").is_err());
952 }
953
954 fn keypair() -> (PrivateKey, Recipient) {
955 let key = PrivateKey::from(Ed25519Keypair::from_seed(&random_bytes()));
956 let line = key.public_key().to_openssh().unwrap();
957 let recipient = Recipient::from_openssh(&line).unwrap();
958 (key, recipient)
959 }
960
961 #[test]
962 fn seals_and_opens_for_every_recipient() {
963 let (a_key, a) = keypair();
964 let (b_key, b) = keypair();
965 let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
966
967 let env = seal(b"hunter2", &aad, &[a.clone(), b.clone()]).unwrap();
968 for key in [&a_key, &b_key] {
969 let id = Identity::from_private_key(key).unwrap();
970 assert_eq!(env.open(&aad, &id).unwrap(), b"hunter2");
971 }
972 }
973
974 #[test]
975 fn a_key_that_is_not_a_recipient_cannot_open() {
976 let (_, a) = keypair();
977 let (outsider_key, _) = keypair();
978 let aad = body_aad("collin", "anvil", "TOKEN");
979 let env = seal(b"hunter2", &aad, &[a]).unwrap();
980 let outsider = Identity::from_private_key(&outsider_key).unwrap();
981 assert!(env.open(&aad, &outsider).is_err());
982 }
983
984 #[test]
985 fn associated_data_binds_the_name_and_repo() {
986 let (key, r) = keypair();
987 let id = Identity::from_private_key(&key).unwrap();
988 let env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
989 assert!(
990 env.open(&body_aad("collin", "anvil", "OTHER"), &id)
991 .is_err()
992 );
993 assert!(
994 env.open(&body_aad("mallory", "anvil", "TOKEN"), &id)
995 .is_err()
996 );
997 }
998
999 #[test]
1000 fn user_aad_round_trips_and_never_opens_under_a_repo_aad() {
1001 let (key, r) = keypair();
1002 let id = Identity::from_private_key(&key).unwrap();
1003 let env = seal(
1004 b"hunter2",
1005 &user_aad("collin", "TOKEN"),
1006 std::slice::from_ref(&r),
1007 )
1008 .unwrap();
1009 assert_eq!(
1010 env.open(&user_aad("collin", "TOKEN"), &id).unwrap(),
1011 b"hunter2"
1012 );
1013 // No repo name can ever collide with "user:{username}": the AAD
1014 // schemes are namespace-disjoint by construction.
1015 assert!(
1016 env.open(&body_aad("collin", "TOKEN", "TOKEN"), &id)
1017 .is_err()
1018 );
1019
1020 // And the reverse: a repo secret cannot be opened as a user secret.
1021 let repo_env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
1022 assert!(repo_env.open(&user_aad("collin", "TOKEN"), &id).is_err());
1023 }
1024
1025 #[test]
1026 fn vault_take_is_a_per_call_allowlist() {
1027 let vault = Vault::default();
1028 let mut values = std::collections::BTreeMap::new();
1029 values.insert("A".to_string(), "1".to_string());
1030 values.insert("B".to_string(), "2".to_string());
1031 vault.unlock(1, values, 3600);
1032
1033 // Asking for a subset returns exactly that subset.
1034 let got = vault.take(1, &["A".to_string()]).unwrap();
1035 assert_eq!(got, vec![("A".to_string(), "1".to_string())]);
1036
1037 // Asking for a name that was never unlocked reports it missing,
1038 // even though other names for the same key are available.
1039 let missing = vault
1040 .take(1, &["A".to_string(), "C".to_string()])
1041 .unwrap_err();
1042 assert_eq!(missing, vec!["C".to_string()]);
1043
1044 // A key with nothing unlocked reports every requested name missing.
1045 let missing = vault.take(2, &["A".to_string()]).unwrap_err();
1046 assert_eq!(missing, vec!["A".to_string()]);
1047
1048 // But a pipeline that declares no secrets is satisfiable by a sealed
1049 // repository, which is the case nearly every pipeline is in: CI must
1050 // not require an unlock for secrets it never asked for.
1051 assert!(vault.take(2, &[]).unwrap().is_empty());
1052 }
1053
1054 #[test]
1055 fn tampering_with_the_body_is_detected() {
1056 let (key, r) = keypair();
1057 let id = Identity::from_private_key(&key).unwrap();
1058 let aad = body_aad("collin", "anvil", "TOKEN");
1059 let mut env = seal(b"hunter2", &aad, &[r]).unwrap();
1060 let mut ct = b64().decode(&env.ct).unwrap();
1061 ct[0] ^= 1;
1062 env.ct = b64().encode(ct);
1063 assert!(env.open(&aad, &id).is_err());
1064 }
1065
1066 #[test]
1067 fn envelopes_round_trip_through_json() {
1068 let (key, r) = keypair();
1069 let id = Identity::from_private_key(&key).unwrap();
1070 let aad = body_aad("collin", "anvil", "TOKEN");
1071 let json = serde_json::to_string(&seal(b"hunter2", &aad, &[r]).unwrap()).unwrap();
1072 let parsed = Envelope::parse(&json).unwrap();
1073 assert_eq!(parsed.open(&aad, &id).unwrap(), b"hunter2");
1074 }
1075
1076 #[test]
1077 fn rejects_malformed_envelopes() {
1078 assert!(Envelope::parse("{}").is_err());
1079 assert!(
1080 Envelope::parse(r#"{"v":2,"alg":"x","recipients":[],"nonce":"","ct":""}"#).is_err()
1081 );
1082 }
1083
1084 #[test]
1085 fn validates_names() {
1086 assert!(valid_name("DEPLOY_TOKEN"));
1087 assert!(valid_name("TOKEN2"));
1088 assert!(!valid_name("2TOKEN"));
1089 assert!(!valid_name("deploy_token"));
1090 assert!(!valid_name("DEPLOY-TOKEN"));
1091 assert!(!valid_name(""));
1092 }
1093}