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 use argon2::password_hash::rand_core::{
368 OsRng,
369 RngCore,
370 };
371 let mut bytes = [0u8; N];
372 OsRng.fill_bytes(&mut bytes);
373 bytes
374}
375
376/// HKDF-SHA256 (RFC 5869) for a single 32-byte output — extract, then one
377/// expand block. Written out rather than pulled in as a dependency: two HMAC
378/// calls are not worth one, and it was originally a `sha2`/`digest`
379/// generation ahead of the rest of the tree.
380fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8]) -> [u8; 32] {
381 use hmac::{
382 Hmac,
383 KeyInit,
384 Mac,
385 };
386 type H = Hmac<sha2::Sha256>;
387
388 let mut extract = H::new_from_slice(salt).expect("HMAC accepts any key length");
389 extract.update(ikm);
390 let prk = extract.finalize().into_bytes();
391
392 let mut expand = H::new_from_slice(&prk).expect("HMAC accepts any key length");
393 expand.update(info);
394 expand.update(&[0x01]);
395 expand.finalize().into_bytes().into()
396}
397
398fn aes_seal(key: &[u8; 32], nonce: &[u8; 12], msg: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
399 let cipher = Aes256Gcm::new(key.into());
400 cipher
401 .encrypt(nonce.into(), Payload { msg, aad })
402 .map_err(|_| Error::Invalid("sealing secret failed".into()))
403}
404
405fn aes_open(
406 key: &[u8; 32],
407 nonce: &[u8; 12],
408 ct: &[u8],
409 aad: &[u8],
410) -> std::result::Result<Vec<u8>, ()> {
411 let cipher = Aes256Gcm::new(key.into());
412 cipher
413 .decrypt(nonce.into(), Payload { msg: ct, aad })
414 .map_err(|_| ())
415}
416
417/// The Curve25519 base point in Montgomery form (u = 9).
418const X25519_BASEPOINT: [u8; 32] = {
419 let mut u = [0u8; 32];
420 u[0] = 9;
421 u
422};
423
424/// X25519 scalar multiplication: clamp the scalar, multiply the u-coordinate.
425fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> [u8; 32] {
426 curve25519_dalek::montgomery::MontgomeryPoint(*point)
427 .mul_clamped(*scalar)
428 .to_bytes()
429}
430
431/// Map an Ed25519 public key (compressed Edwards `y`) to its X25519
432/// (Montgomery `u`) counterpart.
433fn ed25519_public_to_x25519(public: &[u8; 32]) -> Result<[u8; 32]> {
434 curve25519_dalek::edwards::CompressedEdwardsY(*public)
435 .decompress()
436 .map(|p| p.to_montgomery().to_bytes())
437 .ok_or_else(|| Error::Invalid("ssh-ed25519 key is not a valid curve point".into()))
438}
439
440/// Map an Ed25519 seed to the X25519 secret scalar: SHA-512, keep the low
441/// half, clamp — the standard derivation OpenSSH keys share with age.
442fn ed25519_seed_to_x25519(seed: &[u8]) -> [u8; 32] {
443 let digest = Sha512::digest(seed);
444 let mut scalar = [0u8; 32];
445 scalar.copy_from_slice(&digest[..32]);
446 scalar[0] &= 248;
447 scalar[31] &= 127;
448 scalar[31] |= 64;
449 scalar
450}
451
452// --- json merge (the "json" kind) -------------------------------------------
453
454/// Every strict prefix of `field` that ends right before a top-level `.`
455/// (i.e. one outside `[...]` and quoted strings), shortest first. For
456/// `.oauthAccount.token` that's just `[".oauthAccount"]`; for `.a.b.c` it's
457/// `[".a", ".a.b"]`. Used to vivify each missing intermediate object before
458/// the final assignment — see the comment in [`json_merge`].
459fn path_prefixes(field: &str) -> Vec<&str> {
460 let mut prefixes = Vec::new();
461 let mut depth = 0i32;
462 let mut in_quotes = false;
463 for (i, b) in field.bytes().enumerate() {
464 match b {
465 b'"' => in_quotes = !in_quotes,
466 b'[' if !in_quotes => depth += 1,
467 b']' if !in_quotes => depth -= 1,
468 b'.' if !in_quotes && depth == 0 && i > 0 => prefixes.push(&field[..i]),
469 _ => {}
470 }
471 }
472 prefixes
473}
474
475/// Set `field` (a jq-style path, e.g. `.oauthAccount.token`) to `value`
476/// within `current` (a JSON document, or empty for "start from `{}`"),
477/// returning the whole document with that one field changed.
478///
479/// `value` is bound as a jq variable (`$__anvil_secret_value`) rather than
480/// interpolated into the filter text, so it is never parsed as jq syntax —
481/// only `field` is; it comes from the secret's own metadata (set by whoever
482/// created it), never from the decrypted plaintext.
483pub fn json_merge(current: &[u8], field: &str, value: &str) -> Result<Vec<u8>> {
484 use jaq_core::{
485 Compiler,
486 Ctx,
487 Vars,
488 data,
489 load::{
490 Arena,
491 File,
492 Loader,
493 },
494 unwrap_valr,
495 };
496 use jaq_json::Val;
497
498 let current = if current.is_empty() {
499 b"{}".as_slice()
500 } else {
501 current
502 };
503 let current = jaq_json::read::parse_single(current)
504 .map_err(|e| Error::Invalid(format!("json secret: existing file is not JSON: {e}")))?;
505
506 // Deliberately no jaq_std/jaq_json defs: `field = $value` is core jq
507 // path/assignment syntax, entirely handled by jaq_core, and never names a
508 // library filter. jaq_std's defs.jq is loaded as one unit — pulling it in
509 // for the few basics jaq_json's own defs lean on drags in every other
510 // definition too, including ones behind features (format/log/math/regex/
511 // time) this crate does not enable, which then fail to resolve even
512 // though nothing here calls them.
513 //
514 // Real jq auto-creates missing intermediate objects (`{} | .a.b = 1`
515 // gives `{"a":{"b":1}}`); jaq 3.1.1 does not — `setpath`/`=` error with
516 // "cannot use null as iterable" the moment a path walks through a
517 // missing key, confirmed against both jaq-core directly and the real
518 // `jaq` CLI binary. `//=` (default-if-null) does not have that bug, so
519 // each intermediate prefix of the path is vivified with one before the
520 // final assignment.
521 let mut program = String::new();
522 for prefix in path_prefixes(field) {
523 program.push('(');
524 program.push_str(prefix);
525 program.push_str(" //= {}) | ");
526 }
527 program.push_str(field);
528 program.push_str(" = $__anvil_secret_value");
529 let arena = Arena::default();
530 let modules = Loader::new(jaq_core::defs())
531 .load(
532 &arena,
533 File {
534 path: (),
535 code: program.as_str(),
536 },
537 )
538 .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?;
539
540 let funs = jaq_core::funs().chain(jaq_json::funs());
541 let filter = Compiler::default()
542 .with_funs(funs)
543 .with_global_vars(["$__anvil_secret_value"])
544 .compile(modules)
545 .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?;
546
547 let vars = Vars::new([Val::from(value.to_string())]);
548 let ctx = Ctx::<data::JustLut<Val>>::new(&filter.lut, vars);
549 let mut out = filter.id.run((ctx, current)).map(unwrap_valr);
550 let result = out
551 .next()
552 .ok_or_else(|| Error::Invalid("json secret: jq path produced no output".into()))?
553 .map_err(|e| Error::Invalid(format!("json secret: {e}")))?;
554
555 let mut buf = Vec::new();
556 let pp = jaq_json::write::Pp {
557 indent: Some(" ".to_string()),
558 ..Default::default()
559 };
560 jaq_json::write::write(&mut buf, &pp, 0, &result)
561 .map_err(|e| Error::Invalid(format!("json secret: serializing result: {e}")))?;
562 Ok(buf)
563}
564
565// --- persistence -----------------------------------------------------------
566
567/// List a repository's secrets, oldest first. Envelopes are opaque here.
568pub async fn list(db: &toasty::Db, repo_id: i64) -> Result<Vec<RepoSecret>> {
569 let mut conn = db.clone();
570 let mut secrets = RepoSecret::filter(RepoSecret::fields().repo_id().eq(repo_id))
571 .exec(&mut conn)
572 .await?;
573 secrets.sort_by(|a, b| a.name.cmp(&b.name));
574 Ok(secrets)
575}
576
577/// Look one up by name within a repository.
578pub async fn find(db: &toasty::Db, repo_id: i64, name: &str) -> Result<Option<RepoSecret>> {
579 Ok(list(db, repo_id)
580 .await?
581 .into_iter()
582 .find(|s| s.name == name))
583}
584
585/// Create or replace a secret. `envelope` must already have been parsed with
586/// [`Envelope::parse`]; its recipient fingerprints are denormalized onto the
587/// row so the UI can flag secrets that a newly added key cannot open.
588pub async fn put(db: &toasty::Db, repo_id: i64, name: &str, envelope: &Envelope) -> Result<()> {
589 if !valid_name(name) {
590 return Err(Error::Invalid(
591 "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
592 ));
593 }
594 let json = serde_json::to_string(envelope)
595 .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
596 let recipients = envelope.recipient_fingerprints().join(",");
597 let now = crate::now();
598 let mut conn = db.clone();
599 match find(db, repo_id, name).await? {
600 Some(mut existing) => {
601 existing
602 .update()
603 .envelope(json)
604 .recipients(recipients)
605 .updated_at(now)
606 .exec(&mut conn)
607 .await?;
608 }
609 None => {
610 toasty::create!(RepoSecret {
611 repo_id: repo_id,
612 name: name,
613 envelope: json,
614 recipients: recipients,
615 created_at: now,
616 updated_at: now,
617 })
618 .exec(&mut conn)
619 .await?;
620 }
621 }
622 Ok(())
623}
624
625/// Delete a secret by name. No-op if it does not exist.
626pub async fn delete(db: &toasty::Db, repo_id: i64, name: &str) -> Result<()> {
627 if let Some(secret) = find(db, repo_id, name).await? {
628 let mut conn = db.clone();
629 secret.delete().exec(&mut conn).await?;
630 }
631 Ok(())
632}
633
634/// Delete every secret of a repository (used when the repo goes away).
635pub async fn delete_all(db: &toasty::Db, repo_id: i64) -> Result<()> {
636 for secret in list(db, repo_id).await? {
637 let mut conn = db.clone();
638 secret.delete().exec(&mut conn).await?;
639 }
640 Ok(())
641}
642
643// --- user secrets ------------------------------------------------------------
644//
645// The same shape as the repository functions above, keyed by `user_id`
646// instead of `repo_id`. See [`UserSecret`].
647
648/// List an account's secrets, oldest first. Envelopes are opaque here.
649pub async fn list_for_user(db: &toasty::Db, user_id: i64) -> Result<Vec<UserSecret>> {
650 let mut conn = db.clone();
651 let mut secrets = UserSecret::filter(UserSecret::fields().user_id().eq(user_id))
652 .exec(&mut conn)
653 .await?;
654 secrets.sort_by(|a, b| a.name.cmp(&b.name));
655 Ok(secrets)
656}
657
658/// Look one up by name within an account.
659pub async fn find_for_user(
660 db: &toasty::Db,
661 user_id: i64,
662 name: &str,
663) -> Result<Option<UserSecret>> {
664 Ok(list_for_user(db, user_id)
665 .await?
666 .into_iter()
667 .find(|s| s.name == name))
668}
669
670/// Create or replace a user secret. `envelope` must already have been parsed
671/// with [`Envelope::parse`]. `dest_path` must be non-empty for `kind::FILE`/
672/// `kind::JSON` and empty for `kind::ENV`; `field` must be non-empty only for
673/// `kind::JSON`.
674#[allow(clippy::too_many_arguments)]
675pub async fn put_for_user(
676 db: &toasty::Db,
677 user_id: i64,
678 name: &str,
679 put_kind: &str,
680 dest_path: &str,
681 field: &str,
682 envelope: &Envelope,
683) -> Result<()> {
684 if !valid_name(name) {
685 return Err(Error::Invalid(
686 "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
687 ));
688 }
689 // Always relative to $HOME by construction — strip a leading "~/" or "/"
690 // so "~/.claude/x.json", "/.claude/x.json" and ".claude/x.json" all store
691 // (and later inject) the same way.
692 let dest_path = dest_path
693 .strip_prefix("~/")
694 .or_else(|| dest_path.strip_prefix('/'))
695 .unwrap_or(dest_path);
696 let has_path = !dest_path.is_empty();
697 let has_field = !field.is_empty();
698 match put_kind {
699 kind::ENV if has_path || has_field => {
700 return Err(Error::Invalid("env secrets take no path or field".into()));
701 }
702 kind::FILE if !has_path || has_field => {
703 return Err(Error::Invalid(
704 "file secrets need a path and take no field".into(),
705 ));
706 }
707 kind::JSON if !has_path || !has_field => {
708 return Err(Error::Invalid(
709 "json secrets need both a path and a field".into(),
710 ));
711 }
712 kind::ENV | kind::FILE | kind::JSON => {}
713 _ => return Err(Error::Invalid(format!("unknown secret kind `{put_kind}`"))),
714 }
715
716 let json = serde_json::to_string(envelope)
717 .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
718 let recipients = envelope.recipient_fingerprints().join(",");
719 let now = crate::now();
720 let mut conn = db.clone();
721 match find_for_user(db, user_id, name).await? {
722 Some(mut existing) => {
723 existing
724 .update()
725 .kind(put_kind)
726 .dest_path(dest_path)
727 .field(field)
728 .envelope(json)
729 .recipients(recipients)
730 .updated_at(now)
731 .exec(&mut conn)
732 .await?;
733 }
734 None => {
735 toasty::create!(UserSecret {
736 user_id: user_id,
737 name: name,
738 kind: put_kind,
739 dest_path: dest_path,
740 field: field,
741 envelope: json,
742 recipients: recipients,
743 created_at: now,
744 updated_at: now,
745 })
746 .exec(&mut conn)
747 .await?;
748 }
749 }
750 Ok(())
751}
752
753/// Delete a user secret by name. No-op if it does not exist.
754pub async fn delete_for_user(db: &toasty::Db, user_id: i64, name: &str) -> Result<()> {
755 if let Some(secret) = find_for_user(db, user_id, name).await? {
756 let mut conn = db.clone();
757 secret.delete().exec(&mut conn).await?;
758 }
759 Ok(())
760}
761
762/// Delete every secret of an account (for account deletion, once that path
763/// exists — mirrors [`delete_all`]).
764pub async fn delete_all_for_user(db: &toasty::Db, user_id: i64) -> Result<()> {
765 for secret in list_for_user(db, user_id).await? {
766 let mut conn = db.clone();
767 secret.delete().exec(&mut conn).await?;
768 }
769 Ok(())
770}
771
772// --- the unlock vault ------------------------------------------------------
773
774/// Plaintext secrets for unlocked repositories, held in memory only.
775///
776/// A repository is *sealed* until someone with a recipient ssh key runs
777/// `anvild secret unlock`, which opens the envelopes locally and posts the
778/// values here. They live in this map and nowhere else: no file, no database
779/// row, no log. A restart re-seals every repository, and each entry expires on
780/// its own TTL. CI reads from here (see `anvil-ci`), which is the one place
781/// anvil handles plaintext at all.
782#[derive(Clone, Default)]
783pub struct Vault {
784 inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<i64, Unlocked>>>,
785}
786
787struct Unlocked {
788 values: std::collections::BTreeMap<String, String>,
789 expires_at: i64,
790}
791
792impl Drop for Unlocked {
793 /// Overwrite the plaintext when an entry expires or is replaced, so it
794 /// does not linger in freed heap pages.
795 fn drop(&mut self) {
796 for value in self.values.values_mut() {
797 // SAFETY-adjacent: writing over the bytes in place. `String`'s
798 // buffer is the only copy we made.
799 unsafe { value.as_bytes_mut() }.fill(0);
800 }
801 }
802}
803
804/// What the UI shows about an unlocked repository.
805#[derive(Clone, Copy, Debug)]
806pub struct UnlockStatus {
807 pub expires_at: i64,
808 pub count: usize,
809}
810
811/// Current Unix time in seconds, so the web layer can render an unlock
812/// countdown against the same clock the vault expires on.
813pub fn now_secs() -> i64 {
814 crate::now()
815}
816
817/// Longest an unlock may last before it has to be renewed.
818pub const MAX_UNLOCK_SECS: i64 = 7 * 24 * 60 * 60;
819
820impl Vault {
821 /// Store `values` for `repo_id`, replacing any previous unlock. Returns
822 /// the expiry timestamp.
823 pub fn unlock(
824 &self,
825 repo_id: i64,
826 values: std::collections::BTreeMap<String, String>,
827 ttl_secs: i64,
828 ) -> i64 {
829 let ttl = ttl_secs.clamp(60, MAX_UNLOCK_SECS);
830 let expires_at = crate::now() + ttl;
831 let mut map = self.inner.lock().expect("vault mutex");
832 map.insert(repo_id, Unlocked { values, expires_at });
833 expires_at
834 }
835
836 /// Forget a repository's secrets immediately.
837 pub fn lock(&self, repo_id: i64) {
838 self.inner.lock().expect("vault mutex").remove(&repo_id);
839 }
840
841 /// Current unlock state, or `None` if sealed or expired.
842 pub fn status(&self, repo_id: i64) -> Option<UnlockStatus> {
843 let mut map = self.inner.lock().expect("vault mutex");
844 let entry = map.get(&repo_id)?;
845 if entry.expires_at <= crate::now() {
846 map.remove(&repo_id);
847 return None;
848 }
849 Some(UnlockStatus {
850 expires_at: entry.expires_at,
851 count: entry.values.len(),
852 })
853 }
854
855 /// Fetch the named secrets for a CI run. Returns the names that are not
856 /// available as the error, so the runner can say exactly what is missing.
857 ///
858 /// Asking for nothing always succeeds, sealed repository or not — the
859 /// overwhelmingly common pipeline declares no `secrets:` at all, and
860 /// failing it here would mean no repository could run CI until someone had
861 /// unlocked it for secrets it does not use.
862 pub fn take(
863 &self,
864 repo_id: i64,
865 names: &[String],
866 ) -> std::result::Result<Vec<(String, String)>, Vec<String>> {
867 if names.is_empty() {
868 return Ok(Vec::new());
869 }
870 let mut map = self.inner.lock().expect("vault mutex");
871 let Some(entry) = map.get(&repo_id) else {
872 return Err(names.to_vec());
873 };
874 if entry.expires_at <= crate::now() {
875 map.remove(&repo_id);
876 return Err(names.to_vec());
877 }
878 let mut found = Vec::with_capacity(names.len());
879 let mut missing = Vec::new();
880 for name in names {
881 match entry.values.get(name) {
882 Some(value) => found.push((name.clone(), value.clone())),
883 None => missing.push(name.clone()),
884 }
885 }
886 if missing.is_empty() {
887 Ok(found)
888 } else {
889 Err(missing)
890 }
891 }
892
893 /// Drop expired entries (called from the periodic sweep).
894 pub fn sweep(&self) {
895 let now = crate::now();
896 self.inner
897 .lock()
898 .expect("vault mutex")
899 .retain(|_, entry| entry.expires_at > now);
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use ssh_key::{
906 PrivateKey,
907 private::Ed25519Keypair,
908 };
909
910 use super::*;
911
912 #[test]
913 fn json_merge_sets_a_top_level_field_on_empty_input() {
914 let out = json_merge(b"", ".token", "hunter2").unwrap();
915 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
916 assert_eq!(v, serde_json::json!({"token": "hunter2"}));
917 }
918
919 #[test]
920 fn json_merge_creates_intermediate_objects() {
921 let out = json_merge(b"", ".oauthAccount.token", "hunter2").unwrap();
922 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
923 assert_eq!(v, serde_json::json!({"oauthAccount": {"token": "hunter2"}}));
924 }
925
926 #[test]
927 fn json_merge_preserves_sibling_fields() {
928 let existing = br#"{"theme":"auto","oauthAccount":{"other":1}}"#;
929 let out = json_merge(existing, ".oauthAccount.token", "hunter2").unwrap();
930 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
931 assert_eq!(
932 v,
933 serde_json::json!({"theme": "auto", "oauthAccount": {"other": 1, "token": "hunter2"}})
934 );
935 }
936
937 #[test]
938 fn json_merge_does_not_interpolate_the_value_as_jq_syntax() {
939 // A value that looks like a jq injection attempt must land as a
940 // literal string, not be evaluated.
941 let out = json_merge(b"", ".token", "\" | .pwned = true # ").unwrap();
942 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
943 assert_eq!(v, serde_json::json!({"token": "\" | .pwned = true # "}));
944 }
945
946 #[test]
947 fn json_merge_rejects_bad_paths() {
948 assert!(json_merge(b"{}", "not a jq path", "x").is_err());
949 }
950
951 fn keypair() -> (PrivateKey, Recipient) {
952 let key = PrivateKey::from(Ed25519Keypair::from_seed(&random_bytes()));
953 let line = key.public_key().to_openssh().unwrap();
954 let recipient = Recipient::from_openssh(&line).unwrap();
955 (key, recipient)
956 }
957
958 #[test]
959 fn seals_and_opens_for_every_recipient() {
960 let (a_key, a) = keypair();
961 let (b_key, b) = keypair();
962 let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
963
964 let env = seal(b"hunter2", &aad, &[a.clone(), b.clone()]).unwrap();
965 for key in [&a_key, &b_key] {
966 let id = Identity::from_private_key(key).unwrap();
967 assert_eq!(env.open(&aad, &id).unwrap(), b"hunter2");
968 }
969 }
970
971 #[test]
972 fn a_key_that_is_not_a_recipient_cannot_open() {
973 let (_, a) = keypair();
974 let (outsider_key, _) = keypair();
975 let aad = body_aad("collin", "anvil", "TOKEN");
976 let env = seal(b"hunter2", &aad, &[a]).unwrap();
977 let outsider = Identity::from_private_key(&outsider_key).unwrap();
978 assert!(env.open(&aad, &outsider).is_err());
979 }
980
981 #[test]
982 fn associated_data_binds_the_name_and_repo() {
983 let (key, r) = keypair();
984 let id = Identity::from_private_key(&key).unwrap();
985 let env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
986 assert!(
987 env.open(&body_aad("collin", "anvil", "OTHER"), &id)
988 .is_err()
989 );
990 assert!(
991 env.open(&body_aad("mallory", "anvil", "TOKEN"), &id)
992 .is_err()
993 );
994 }
995
996 #[test]
997 fn user_aad_round_trips_and_never_opens_under_a_repo_aad() {
998 let (key, r) = keypair();
999 let id = Identity::from_private_key(&key).unwrap();
1000 let env = seal(
1001 b"hunter2",
1002 &user_aad("collin", "TOKEN"),
1003 std::slice::from_ref(&r),
1004 )
1005 .unwrap();
1006 assert_eq!(
1007 env.open(&user_aad("collin", "TOKEN"), &id).unwrap(),
1008 b"hunter2"
1009 );
1010 // No repo name can ever collide with "user:{username}": the AAD
1011 // schemes are namespace-disjoint by construction.
1012 assert!(
1013 env.open(&body_aad("collin", "TOKEN", "TOKEN"), &id)
1014 .is_err()
1015 );
1016
1017 // And the reverse: a repo secret cannot be opened as a user secret.
1018 let repo_env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
1019 assert!(repo_env.open(&user_aad("collin", "TOKEN"), &id).is_err());
1020 }
1021
1022 #[test]
1023 fn vault_take_is_a_per_call_allowlist() {
1024 let vault = Vault::default();
1025 let mut values = std::collections::BTreeMap::new();
1026 values.insert("A".to_string(), "1".to_string());
1027 values.insert("B".to_string(), "2".to_string());
1028 vault.unlock(1, values, 3600);
1029
1030 // Asking for a subset returns exactly that subset.
1031 let got = vault.take(1, &["A".to_string()]).unwrap();
1032 assert_eq!(got, vec![("A".to_string(), "1".to_string())]);
1033
1034 // Asking for a name that was never unlocked reports it missing,
1035 // even though other names for the same key are available.
1036 let missing = vault
1037 .take(1, &["A".to_string(), "C".to_string()])
1038 .unwrap_err();
1039 assert_eq!(missing, vec!["C".to_string()]);
1040
1041 // A key with nothing unlocked reports every requested name missing.
1042 let missing = vault.take(2, &["A".to_string()]).unwrap_err();
1043 assert_eq!(missing, vec!["A".to_string()]);
1044
1045 // But a pipeline that declares no secrets is satisfiable by a sealed
1046 // repository, which is the case nearly every pipeline is in: CI must
1047 // not require an unlock for secrets it never asked for.
1048 assert!(vault.take(2, &[]).unwrap().is_empty());
1049 }
1050
1051 #[test]
1052 fn tampering_with_the_body_is_detected() {
1053 let (key, r) = keypair();
1054 let id = Identity::from_private_key(&key).unwrap();
1055 let aad = body_aad("collin", "anvil", "TOKEN");
1056 let mut env = seal(b"hunter2", &aad, &[r]).unwrap();
1057 let mut ct = b64().decode(&env.ct).unwrap();
1058 ct[0] ^= 1;
1059 env.ct = b64().encode(ct);
1060 assert!(env.open(&aad, &id).is_err());
1061 }
1062
1063 #[test]
1064 fn envelopes_round_trip_through_json() {
1065 let (key, r) = keypair();
1066 let id = Identity::from_private_key(&key).unwrap();
1067 let aad = body_aad("collin", "anvil", "TOKEN");
1068 let json = serde_json::to_string(&seal(b"hunter2", &aad, &[r]).unwrap()).unwrap();
1069 let parsed = Envelope::parse(&json).unwrap();
1070 assert_eq!(parsed.open(&aad, &id).unwrap(), b"hunter2");
1071 }
1072
1073 #[test]
1074 fn rejects_malformed_envelopes() {
1075 assert!(Envelope::parse("{}").is_err());
1076 assert!(
1077 Envelope::parse(r#"{"v":2,"alg":"x","recipients":[],"nonce":"","ct":""}"#).is_err()
1078 );
1079 }
1080
1081 #[test]
1082 fn validates_names() {
1083 assert!(valid_name("DEPLOY_TOKEN"));
1084 assert!(valid_name("TOKEN2"));
1085 assert!(!valid_name("2TOKEN"));
1086 assert!(!valid_name("deploy_token"));
1087 assert!(!valid_name("DEPLOY-TOKEN"));
1088 assert!(!valid_name(""));
1089 }
1090}