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