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. Which is why jaq-std is not a
516 // dependency of this crate — it is only in the tree because jaq-json
517 // requires it.
518 //
519 // Real jq auto-creates missing intermediate objects (`{} | .a.b = 1`
520 // gives `{"a":{"b":1}}`); jaq 3.1.1 does not — `setpath`/`=` error with
521 // "cannot use null as iterable" the moment a path walks through a
522 // missing key, confirmed against both jaq-core directly and the real
523 // `jaq` CLI binary. `//=` (default-if-null) does not have that bug, so
524 // each intermediate prefix of the path is vivified with one before the
525 // final assignment.
526 let mut program = String::new();
527 for prefix in path_prefixes(field) {
528 program.push('(');
529 program.push_str(prefix);
530 program.push_str(" //= {}) | ");
531 }
532 program.push_str(field);
533 program.push_str(" = $__anvil_secret_value");
534 let arena = Arena::default();
535 let modules = Loader::new(jaq_core::defs())
536 .load(
537 &arena,
538 File {
539 path: (),
540 code: program.as_str(),
541 },
542 )
543 .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?;
544
545 let funs = jaq_core::funs().chain(jaq_json::funs());
546 let filter = Compiler::default()
547 .with_funs(funs)
548 .with_global_vars(["$__anvil_secret_value"])
549 .compile(modules)
550 .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?;
551
552 let vars = Vars::new([Val::from(value.to_string())]);
553 let ctx = Ctx::<data::JustLut<Val>>::new(&filter.lut, vars);
554 let mut out = filter.id.run((ctx, current)).map(unwrap_valr);
555 let result = out
556 .next()
557 .ok_or_else(|| Error::Invalid("json secret: jq path produced no output".into()))?
558 .map_err(|e| Error::Invalid(format!("json secret: {e}")))?;
559
560 let mut buf = Vec::new();
561 let pp = jaq_json::write::Pp {
562 indent: Some(" ".to_string()),
563 ..Default::default()
564 };
565 jaq_json::write::write(&mut buf, &pp, 0, &result)
566 .map_err(|e| Error::Invalid(format!("json secret: serializing result: {e}")))?;
567 Ok(buf)
568}
569
570// --- persistence -----------------------------------------------------------
571
572/// List a repository's secrets, oldest first. Envelopes are opaque here.
573pub async fn list(db: &toasty::Db, repo_id: i64) -> Result<Vec<RepoSecret>> {
574 let mut conn = db.clone();
575 let mut secrets = RepoSecret::filter(RepoSecret::fields().repo_id().eq(repo_id))
576 .exec(&mut conn)
577 .await?;
578 secrets.sort_by(|a, b| a.name.cmp(&b.name));
579 Ok(secrets)
580}
581
582/// Look one up by name within a repository.
583pub async fn find(db: &toasty::Db, repo_id: i64, name: &str) -> Result<Option<RepoSecret>> {
584 Ok(list(db, repo_id)
585 .await?
586 .into_iter()
587 .find(|s| s.name == name))
588}
589
590/// Create or replace a secret. `envelope` must already have been parsed with
591/// [`Envelope::parse`]; its recipient fingerprints are denormalized onto the
592/// row so the UI can flag secrets that a newly added key cannot open.
593pub async fn put(db: &toasty::Db, repo_id: i64, name: &str, envelope: &Envelope) -> Result<()> {
594 if !valid_name(name) {
595 return Err(Error::Invalid(
596 "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
597 ));
598 }
599 let json = serde_json::to_string(envelope)
600 .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
601 let recipients = envelope.recipient_fingerprints().join(",");
602 let now = crate::now();
603 let mut conn = db.clone();
604 match find(db, repo_id, name).await? {
605 Some(mut existing) => {
606 existing
607 .update()
608 .envelope(json)
609 .recipients(recipients)
610 .updated_at(now)
611 .exec(&mut conn)
612 .await?;
613 }
614 None => {
615 toasty::create!(RepoSecret {
616 repo_id: repo_id,
617 name: name,
618 envelope: json,
619 recipients: recipients,
620 created_at: now,
621 updated_at: now,
622 })
623 .exec(&mut conn)
624 .await?;
625 }
626 }
627 Ok(())
628}
629
630/// Delete a secret by name. No-op if it does not exist.
631pub async fn delete(db: &toasty::Db, repo_id: i64, name: &str) -> Result<()> {
632 if let Some(secret) = find(db, repo_id, name).await? {
633 let mut conn = db.clone();
634 secret.delete().exec(&mut conn).await?;
635 }
636 Ok(())
637}
638
639/// Delete every secret of a repository (used when the repo goes away).
640pub async fn delete_all(db: &toasty::Db, repo_id: i64) -> Result<()> {
641 for secret in list(db, repo_id).await? {
642 let mut conn = db.clone();
643 secret.delete().exec(&mut conn).await?;
644 }
645 Ok(())
646}
647
648// --- user secrets ------------------------------------------------------------
649//
650// The same shape as the repository functions above, keyed by `user_id`
651// instead of `repo_id`. See [`UserSecret`].
652
653/// List an account's secrets, oldest first. Envelopes are opaque here.
654pub async fn list_for_user(db: &toasty::Db, user_id: i64) -> Result<Vec<UserSecret>> {
655 let mut conn = db.clone();
656 let mut secrets = UserSecret::filter(UserSecret::fields().user_id().eq(user_id))
657 .exec(&mut conn)
658 .await?;
659 secrets.sort_by(|a, b| a.name.cmp(&b.name));
660 Ok(secrets)
661}
662
663/// Look one up by name within an account.
664pub async fn find_for_user(
665 db: &toasty::Db,
666 user_id: i64,
667 name: &str,
668) -> Result<Option<UserSecret>> {
669 Ok(list_for_user(db, user_id)
670 .await?
671 .into_iter()
672 .find(|s| s.name == name))
673}
674
675/// Create or replace a user secret. `envelope` must already have been parsed
676/// with [`Envelope::parse`]. `dest_path` must be non-empty for `kind::FILE`/
677/// `kind::JSON` and empty for `kind::ENV`; `field` must be non-empty only for
678/// `kind::JSON`.
679#[allow(clippy::too_many_arguments)]
680pub async fn put_for_user(
681 db: &toasty::Db,
682 user_id: i64,
683 name: &str,
684 put_kind: &str,
685 dest_path: &str,
686 field: &str,
687 envelope: &Envelope,
688) -> Result<()> {
689 if !valid_name(name) {
690 return Err(Error::Invalid(
691 "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
692 ));
693 }
694 // Always relative to $HOME by construction — strip a leading "~/" or "/"
695 // so "~/.claude/x.json", "/.claude/x.json" and ".claude/x.json" all store
696 // (and later inject) the same way.
697 let dest_path = dest_path
698 .strip_prefix("~/")
699 .or_else(|| dest_path.strip_prefix('/'))
700 .unwrap_or(dest_path);
701 let has_path = !dest_path.is_empty();
702 let has_field = !field.is_empty();
703 match put_kind {
704 kind::ENV if has_path || has_field => {
705 return Err(Error::Invalid("env secrets take no path or field".into()));
706 }
707 kind::FILE if !has_path || has_field => {
708 return Err(Error::Invalid(
709 "file secrets need a path and take no field".into(),
710 ));
711 }
712 kind::JSON if !has_path || !has_field => {
713 return Err(Error::Invalid(
714 "json secrets need both a path and a field".into(),
715 ));
716 }
717 kind::ENV | kind::FILE | kind::JSON => {}
718 _ => return Err(Error::Invalid(format!("unknown secret kind `{put_kind}`"))),
719 }
720
721 let json = serde_json::to_string(envelope)
722 .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
723 let recipients = envelope.recipient_fingerprints().join(",");
724 let now = crate::now();
725 let mut conn = db.clone();
726 match find_for_user(db, user_id, name).await? {
727 Some(mut existing) => {
728 existing
729 .update()
730 .kind(put_kind)
731 .dest_path(dest_path)
732 .field(field)
733 .envelope(json)
734 .recipients(recipients)
735 .updated_at(now)
736 .exec(&mut conn)
737 .await?;
738 }
739 None => {
740 toasty::create!(UserSecret {
741 user_id: user_id,
742 name: name,
743 kind: put_kind,
744 dest_path: dest_path,
745 field: field,
746 envelope: json,
747 recipients: recipients,
748 created_at: now,
749 updated_at: now,
750 })
751 .exec(&mut conn)
752 .await?;
753 }
754 }
755 Ok(())
756}
757
758/// Delete a user secret by name. No-op if it does not exist.
759pub async fn delete_for_user(db: &toasty::Db, user_id: i64, name: &str) -> Result<()> {
760 if let Some(secret) = find_for_user(db, user_id, name).await? {
761 let mut conn = db.clone();
762 secret.delete().exec(&mut conn).await?;
763 }
764 Ok(())
765}
766
767/// Delete every secret of an account (for account deletion, once that path
768/// exists — mirrors [`delete_all`]).
769pub async fn delete_all_for_user(db: &toasty::Db, user_id: i64) -> Result<()> {
770 for secret in list_for_user(db, user_id).await? {
771 let mut conn = db.clone();
772 secret.delete().exec(&mut conn).await?;
773 }
774 Ok(())
775}
776
777// --- the unlock vault ------------------------------------------------------
778
779/// Plaintext secrets for unlocked repositories, held in memory only.
780///
781/// A repository is *sealed* until someone with a recipient ssh key runs
782/// `anvild secret unlock`, which opens the envelopes locally and posts the
783/// values here. They live in this map and nowhere else: no file, no database
784/// row, no log. A restart re-seals every repository, and each entry expires on
785/// its own TTL. CI reads from here (see `anvil-ci`), which is the one place
786/// anvil handles plaintext at all.
787#[derive(Clone, Default)]
788pub struct Vault {
789 inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<i64, Unlocked>>>,
790}
791
792struct Unlocked {
793 values: std::collections::BTreeMap<String, String>,
794 expires_at: i64,
795}
796
797impl Drop for Unlocked {
798 /// Overwrite the plaintext when an entry expires or is replaced, so it
799 /// does not linger in freed heap pages.
800 fn drop(&mut self) {
801 for value in self.values.values_mut() {
802 // SAFETY-adjacent: writing over the bytes in place. `String`'s
803 // buffer is the only copy we made.
804 unsafe { value.as_bytes_mut() }.fill(0);
805 }
806 }
807}
808
809/// What the UI shows about an unlocked repository.
810#[derive(Clone, Copy, Debug)]
811pub struct UnlockStatus {
812 pub expires_at: i64,
813 pub count: usize,
814}
815
816/// Current Unix time in seconds, so the web layer can render an unlock
817/// countdown against the same clock the vault expires on.
818pub fn now_secs() -> i64 {
819 crate::now()
820}
821
822/// Longest an unlock may last before it has to be renewed.
823pub const MAX_UNLOCK_SECS: i64 = 7 * 24 * 60 * 60;
824
825impl Vault {
826 /// Store `values` for `repo_id`, replacing any previous unlock. Returns
827 /// the expiry timestamp.
828 pub fn unlock(
829 &self,
830 repo_id: i64,
831 values: std::collections::BTreeMap<String, String>,
832 ttl_secs: i64,
833 ) -> i64 {
834 let ttl = ttl_secs.clamp(60, MAX_UNLOCK_SECS);
835 let expires_at = crate::now() + ttl;
836 let mut map = self.inner.lock().expect("vault mutex");
837 map.insert(repo_id, Unlocked { values, expires_at });
838 expires_at
839 }
840
841 /// Forget a repository's secrets immediately.
842 pub fn lock(&self, repo_id: i64) {
843 self.inner.lock().expect("vault mutex").remove(&repo_id);
844 }
845
846 /// Current unlock state, or `None` if sealed or expired.
847 pub fn status(&self, repo_id: i64) -> Option<UnlockStatus> {
848 let mut map = self.inner.lock().expect("vault mutex");
849 let entry = map.get(&repo_id)?;
850 if entry.expires_at <= crate::now() {
851 map.remove(&repo_id);
852 return None;
853 }
854 Some(UnlockStatus {
855 expires_at: entry.expires_at,
856 count: entry.values.len(),
857 })
858 }
859
860 /// Fetch the named secrets for a CI run. Returns the names that are not
861 /// available as the error, so the runner can say exactly what is missing.
862 ///
863 /// Asking for nothing always succeeds, sealed repository or not — the
864 /// overwhelmingly common pipeline declares no `secrets:` at all, and
865 /// failing it here would mean no repository could run CI until someone had
866 /// unlocked it for secrets it does not use.
867 pub fn take(
868 &self,
869 repo_id: i64,
870 names: &[String],
871 ) -> std::result::Result<Vec<(String, String)>, Vec<String>> {
872 if names.is_empty() {
873 return Ok(Vec::new());
874 }
875 let mut map = self.inner.lock().expect("vault mutex");
876 let Some(entry) = map.get(&repo_id) else {
877 return Err(names.to_vec());
878 };
879 if entry.expires_at <= crate::now() {
880 map.remove(&repo_id);
881 return Err(names.to_vec());
882 }
883 let mut found = Vec::with_capacity(names.len());
884 let mut missing = Vec::new();
885 for name in names {
886 match entry.values.get(name) {
887 Some(value) => found.push((name.clone(), value.clone())),
888 None => missing.push(name.clone()),
889 }
890 }
891 if missing.is_empty() {
892 Ok(found)
893 } else {
894 Err(missing)
895 }
896 }
897
898 /// Drop expired entries (called from the periodic sweep).
899 pub fn sweep(&self) {
900 let now = crate::now();
901 self.inner
902 .lock()
903 .expect("vault mutex")
904 .retain(|_, entry| entry.expires_at > now);
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use ssh_key::{
911 PrivateKey,
912 private::Ed25519Keypair,
913 };
914
915 use super::*;
916
917 #[test]
918 fn json_merge_sets_a_top_level_field_on_empty_input() {
919 let out = json_merge(b"", ".token", "hunter2").unwrap();
920 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
921 assert_eq!(v, serde_json::json!({"token": "hunter2"}));
922 }
923
924 #[test]
925 fn json_merge_creates_intermediate_objects() {
926 let out = json_merge(b"", ".oauthAccount.token", "hunter2").unwrap();
927 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
928 assert_eq!(v, serde_json::json!({"oauthAccount": {"token": "hunter2"}}));
929 }
930
931 #[test]
932 fn json_merge_preserves_sibling_fields() {
933 let existing = br#"{"theme":"auto","oauthAccount":{"other":1}}"#;
934 let out = json_merge(existing, ".oauthAccount.token", "hunter2").unwrap();
935 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
936 assert_eq!(
937 v,
938 serde_json::json!({"theme": "auto", "oauthAccount": {"other": 1, "token": "hunter2"}})
939 );
940 }
941
942 #[test]
943 fn json_merge_does_not_interpolate_the_value_as_jq_syntax() {
944 // A value that looks like a jq injection attempt must land as a
945 // literal string, not be evaluated.
946 let out = json_merge(b"", ".token", "\" | .pwned = true # ").unwrap();
947 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
948 assert_eq!(v, serde_json::json!({"token": "\" | .pwned = true # "}));
949 }
950
951 #[test]
952 fn json_merge_rejects_bad_paths() {
953 assert!(json_merge(b"{}", "not a jq path", "x").is_err());
954 }
955
956 fn keypair() -> (PrivateKey, Recipient) {
957 let key = PrivateKey::from(Ed25519Keypair::from_seed(&random_bytes()));
958 let line = key.public_key().to_openssh().unwrap();
959 let recipient = Recipient::from_openssh(&line).unwrap();
960 (key, recipient)
961 }
962
963 #[test]
964 fn seals_and_opens_for_every_recipient() {
965 let (a_key, a) = keypair();
966 let (b_key, b) = keypair();
967 let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
968
969 let env = seal(b"hunter2", &aad, &[a.clone(), b.clone()]).unwrap();
970 for key in [&a_key, &b_key] {
971 let id = Identity::from_private_key(key).unwrap();
972 assert_eq!(env.open(&aad, &id).unwrap(), b"hunter2");
973 }
974 }
975
976 #[test]
977 fn a_key_that_is_not_a_recipient_cannot_open() {
978 let (_, a) = keypair();
979 let (outsider_key, _) = keypair();
980 let aad = body_aad("collin", "anvil", "TOKEN");
981 let env = seal(b"hunter2", &aad, &[a]).unwrap();
982 let outsider = Identity::from_private_key(&outsider_key).unwrap();
983 assert!(env.open(&aad, &outsider).is_err());
984 }
985
986 #[test]
987 fn associated_data_binds_the_name_and_repo() {
988 let (key, r) = keypair();
989 let id = Identity::from_private_key(&key).unwrap();
990 let env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
991 assert!(
992 env.open(&body_aad("collin", "anvil", "OTHER"), &id)
993 .is_err()
994 );
995 assert!(
996 env.open(&body_aad("mallory", "anvil", "TOKEN"), &id)
997 .is_err()
998 );
999 }
1000
1001 #[test]
1002 fn user_aad_round_trips_and_never_opens_under_a_repo_aad() {
1003 let (key, r) = keypair();
1004 let id = Identity::from_private_key(&key).unwrap();
1005 let env = seal(
1006 b"hunter2",
1007 &user_aad("collin", "TOKEN"),
1008 std::slice::from_ref(&r),
1009 )
1010 .unwrap();
1011 assert_eq!(
1012 env.open(&user_aad("collin", "TOKEN"), &id).unwrap(),
1013 b"hunter2"
1014 );
1015 // No repo name can ever collide with "user:{username}": the AAD
1016 // schemes are namespace-disjoint by construction.
1017 assert!(
1018 env.open(&body_aad("collin", "TOKEN", "TOKEN"), &id)
1019 .is_err()
1020 );
1021
1022 // And the reverse: a repo secret cannot be opened as a user secret.
1023 let repo_env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
1024 assert!(repo_env.open(&user_aad("collin", "TOKEN"), &id).is_err());
1025 }
1026
1027 #[test]
1028 fn vault_take_is_a_per_call_allowlist() {
1029 let vault = Vault::default();
1030 let mut values = std::collections::BTreeMap::new();
1031 values.insert("A".to_string(), "1".to_string());
1032 values.insert("B".to_string(), "2".to_string());
1033 vault.unlock(1, values, 3600);
1034
1035 // Asking for a subset returns exactly that subset.
1036 let got = vault.take(1, &["A".to_string()]).unwrap();
1037 assert_eq!(got, vec![("A".to_string(), "1".to_string())]);
1038
1039 // Asking for a name that was never unlocked reports it missing,
1040 // even though other names for the same key are available.
1041 let missing = vault
1042 .take(1, &["A".to_string(), "C".to_string()])
1043 .unwrap_err();
1044 assert_eq!(missing, vec!["C".to_string()]);
1045
1046 // A key with nothing unlocked reports every requested name missing.
1047 let missing = vault.take(2, &["A".to_string()]).unwrap_err();
1048 assert_eq!(missing, vec!["A".to_string()]);
1049
1050 // But a pipeline that declares no secrets is satisfiable by a sealed
1051 // repository, which is the case nearly every pipeline is in: CI must
1052 // not require an unlock for secrets it never asked for.
1053 assert!(vault.take(2, &[]).unwrap().is_empty());
1054 }
1055
1056 #[test]
1057 fn tampering_with_the_body_is_detected() {
1058 let (key, r) = keypair();
1059 let id = Identity::from_private_key(&key).unwrap();
1060 let aad = body_aad("collin", "anvil", "TOKEN");
1061 let mut env = seal(b"hunter2", &aad, &[r]).unwrap();
1062 let mut ct = b64().decode(&env.ct).unwrap();
1063 ct[0] ^= 1;
1064 env.ct = b64().encode(ct);
1065 assert!(env.open(&aad, &id).is_err());
1066 }
1067
1068 #[test]
1069 fn envelopes_round_trip_through_json() {
1070 let (key, r) = keypair();
1071 let id = Identity::from_private_key(&key).unwrap();
1072 let aad = body_aad("collin", "anvil", "TOKEN");
1073 let json = serde_json::to_string(&seal(b"hunter2", &aad, &[r]).unwrap()).unwrap();
1074 let parsed = Envelope::parse(&json).unwrap();
1075 assert_eq!(parsed.open(&aad, &id).unwrap(), b"hunter2");
1076 }
1077
1078 #[test]
1079 fn rejects_malformed_envelopes() {
1080 assert!(Envelope::parse("{}").is_err());
1081 assert!(
1082 Envelope::parse(r#"{"v":2,"alg":"x","recipients":[],"nonce":"","ct":""}"#).is_err()
1083 );
1084 }
1085
1086 #[test]
1087 fn validates_names() {
1088 assert!(valid_name("DEPLOY_TOKEN"));
1089 assert!(valid_name("TOKEN2"));
1090 assert!(!valid_name("2TOKEN"));
1091 assert!(!valid_name("deploy_token"));
1092 assert!(!valid_name("DEPLOY-TOKEN"));
1093 assert!(!valid_name(""));
1094 }
1095}