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::RepoSecret,
56};
57
58/// Algorithm identifier carried in every envelope.
59pub const ALG: &str = "x25519-hkdf-sha256+aes256gcm";
60
61/// HKDF `info` string binding derived wrap keys to this scheme.
62const WRAP_INFO: &[u8] = b"anvil-secret-v1 wrap";
63
64/// Cap on a secret's plaintext. Environment variables, not blobs.
65pub const MAX_VALUE_BYTES: usize = 64 * 1024;
66
67/// Cap on a stored envelope: the value plus per-recipient overhead, base64'd,
68/// with room for a generous number of keys.
69pub const MAX_ENVELOPE_BYTES: usize = 256 * 1024;
70
71fn b64() -> base64::engine::general_purpose::GeneralPurpose {
72 base64::engine::general_purpose::STANDARD
73}
74
75fn decode_b64(what: &str, s: &str) -> Result<Vec<u8>> {
76 b64()
77 .decode(s)
78 .map_err(|e| Error::Invalid(format!("secret envelope: bad base64 in {what}: {e}")))
79}
80
81fn decode_array<const N: usize>(what: &str, s: &str) -> Result<[u8; N]> {
82 let bytes = decode_b64(what, s)?;
83 <[u8; N]>::try_from(bytes.as_slice())
84 .map_err(|_| Error::Invalid(format!("secret envelope: {what} must be {N} bytes")))
85}
86
87/// A sealed secret value: the encrypted body plus one wrapped file key per
88/// recipient. Serialized as JSON, which is what both the browser and the CLI
89/// hand to the server.
90#[derive(Clone, Debug, Deserialize, Serialize)]
91pub struct Envelope {
92 pub v: u32,
93 pub alg: String,
94 pub recipients: Vec<Stanza>,
95 /// Base64 12-byte AES-GCM nonce for the body.
96 pub nonce: String,
97 /// Base64 AES-GCM ciphertext ‖ tag of the value.
98 pub ct: String,
99}
100
101/// One recipient's wrapped copy of the file key.
102#[derive(Clone, Debug, Deserialize, Serialize)]
103pub struct Stanza {
104 /// The recipient key's canonical SSH fingerprint (`SHA256:…`).
105 pub fp: String,
106 /// Base64 32-byte ephemeral X25519 public key.
107 pub epk: String,
108 /// Base64 12-byte nonce ‖ AES-GCM ciphertext of the 32-byte file key.
109 pub wrap: String,
110}
111
112impl Envelope {
113 /// Parse and structurally validate an envelope received from a client.
114 pub fn parse(json: &str) -> Result<Self> {
115 if json.len() > MAX_ENVELOPE_BYTES {
116 return Err(Error::Invalid("secret envelope too large".into()));
117 }
118 let env: Envelope = serde_json::from_str(json)
119 .map_err(|e| Error::Invalid(format!("secret envelope: {e}")))?;
120 env.validate()?;
121 Ok(env)
122 }
123
124 /// Check the parts the *server* can check: version, algorithm, and that
125 /// every field decodes to the right length. It cannot check the
126 /// ciphertext — that is the whole point.
127 pub fn validate(&self) -> Result<()> {
128 if self.v != 1 || self.alg != ALG {
129 return Err(Error::Invalid(format!(
130 "secret envelope: unsupported version/algorithm ({}/{})",
131 self.v, self.alg
132 )));
133 }
134 if self.recipients.is_empty() {
135 return Err(Error::Invalid("secret envelope: no recipients".into()));
136 }
137 decode_array::<12>("nonce", &self.nonce)?;
138 if decode_b64("ct", &self.ct)?.len() < 16 {
139 return Err(Error::Invalid("secret envelope: body too short".into()));
140 }
141 for r in &self.recipients {
142 if !r.fp.starts_with("SHA256:") {
143 return Err(Error::Invalid(
144 "secret envelope: recipient fingerprint must be SHA256:…".into(),
145 ));
146 }
147 decode_array::<32>("epk", &r.epk)?;
148 if decode_b64("wrap", &r.wrap)?.len() != 12 + 32 + 16 {
149 return Err(Error::Invalid("secret envelope: bad wrapped key".into()));
150 }
151 }
152 Ok(())
153 }
154
155 /// The fingerprints this envelope can be opened by, in order.
156 pub fn recipient_fingerprints(&self) -> Vec<String> {
157 self.recipients.iter().map(|r| r.fp.clone()).collect()
158 }
159
160 /// Decrypt with `identity`, which must be one of the recipients.
161 pub fn open(&self, aad: &[u8], identity: &Identity) -> Result<Vec<u8>> {
162 self.validate()?;
163 let stanza = self
164 .recipients
165 .iter()
166 .find(|r| r.fp == identity.fingerprint)
167 .ok_or_else(|| {
168 Error::Invalid(format!(
169 "secret is not sealed to {} — rekey it first",
170 identity.fingerprint
171 ))
172 })?;
173
174 let epk = decode_array::<32>("epk", &stanza.epk)?;
175 let shared = x25519(&identity.secret, &epk);
176 if shared.iter().all(|b| *b == 0) {
177 return Err(Error::Invalid(
178 "secret envelope: degenerate key exchange".into(),
179 ));
180 }
181 let mut salt = [0u8; 64];
182 salt[..32].copy_from_slice(&epk);
183 salt[32..].copy_from_slice(&identity.public);
184 let wrap_key = hkdf_sha256(&shared, &salt, WRAP_INFO);
185
186 let wrap = decode_b64("wrap", &stanza.wrap)?;
187 let wrap_nonce = <[u8; 12]>::try_from(&wrap[..12])
188 .map_err(|_| Error::Invalid("secret envelope: bad wrap nonce".into()))?;
189 let file_key = aes_open(&wrap_key, &wrap_nonce, &wrap[12..], stanza.fp.as_bytes())
190 .map_err(|_| Error::Invalid("secret envelope: wrapped key did not open".into()))?;
191 let file_key = <[u8; 32]>::try_from(file_key.as_slice())
192 .map_err(|_| Error::Invalid("secret envelope: bad file key".into()))?;
193
194 let nonce = decode_array::<12>("nonce", &self.nonce)?;
195 let ct = decode_b64("ct", &self.ct)?;
196 aes_open(&file_key, &nonce, &ct, aad)
197 .map_err(|_| Error::Invalid("secret envelope: body did not open".into()))
198 }
199}
200
201/// A key a secret can be sealed *to*: an ssh-ed25519 public key mapped onto
202/// Curve25519.
203#[derive(Clone, Debug)]
204pub struct Recipient {
205 pub fingerprint: String,
206 pub x25519: [u8; 32],
207}
208
209impl Recipient {
210 /// Build a recipient from a registered OpenSSH public-key line. Only
211 /// `ssh-ed25519` keys can receive secrets: RSA would need a second
212 /// scheme, and `*-sk` (FIDO) keys cannot do key agreement at all.
213 pub fn from_openssh(line: &str) -> Result<Self> {
214 let key = ssh_key::PublicKey::from_openssh(line.trim())
215 .map_err(|e| Error::Invalid(format!("invalid ssh public key: {e}")))?;
216 let ed = key.key_data().ed25519().ok_or_else(|| {
217 Error::Invalid(format!(
218 "{} keys cannot receive secrets — register an ssh-ed25519 key",
219 key.algorithm().as_str()
220 ))
221 })?;
222 Ok(Self {
223 fingerprint: key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
224 x25519: ed25519_public_to_x25519(&ed.0)?,
225 })
226 }
227}
228
229/// The private half: what the CLI holds to open envelopes.
230#[derive(Clone)]
231pub struct Identity {
232 pub fingerprint: String,
233 secret: [u8; 32],
234 public: [u8; 32],
235}
236
237impl std::fmt::Debug for Identity {
238 /// Never render the secret scalar.
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 f.debug_struct("Identity")
241 .field("fingerprint", &self.fingerprint)
242 .finish_non_exhaustive()
243 }
244}
245
246impl Identity {
247 /// Derive an identity from a decrypted OpenSSH private key.
248 pub fn from_private_key(key: &ssh_key::PrivateKey) -> Result<Self> {
249 let ed = key.key_data().ed25519().ok_or_else(|| {
250 Error::Invalid(format!(
251 "{} private keys cannot open secrets — use an ssh-ed25519 key",
252 key.algorithm().as_str()
253 ))
254 })?;
255 let secret = ed25519_seed_to_x25519(ed.private.as_ref());
256 Ok(Self {
257 fingerprint: key
258 .public_key()
259 .fingerprint(ssh_key::HashAlg::Sha256)
260 .to_string(),
261 public: ed25519_public_to_x25519(&ed.public.0)?,
262 secret,
263 })
264 }
265}
266
267/// Seal `plaintext` to every recipient. Mirrors `sealSecret()` in the
268/// browser's `secrets.js` byte for byte — the interop test in
269/// `tests/js_interop.rs` opens what that code produces.
270pub fn seal(plaintext: &[u8], aad: &[u8], recipients: &[Recipient]) -> Result<Envelope> {
271 if plaintext.len() > MAX_VALUE_BYTES {
272 return Err(Error::Invalid(format!(
273 "secret is larger than {MAX_VALUE_BYTES} bytes"
274 )));
275 }
276 if recipients.is_empty() {
277 return Err(Error::Invalid(
278 "no ssh-ed25519 keys to seal to — register one first".into(),
279 ));
280 }
281 let file_key: [u8; 32] = random_bytes();
282 let nonce: [u8; 12] = random_bytes();
283 let ct = aes_seal(&file_key, &nonce, plaintext, aad)?;
284
285 let mut stanzas = Vec::with_capacity(recipients.len());
286 for r in recipients {
287 let esk: [u8; 32] = random_bytes();
288 let epk = x25519(&esk, &X25519_BASEPOINT);
289 let shared = x25519(&esk, &r.x25519);
290 if shared.iter().all(|b| *b == 0) {
291 return Err(Error::Invalid(format!(
292 "recipient {} has a degenerate public key",
293 r.fingerprint
294 )));
295 }
296 let mut salt = [0u8; 64];
297 salt[..32].copy_from_slice(&epk);
298 salt[32..].copy_from_slice(&r.x25519);
299 let wrap_key = hkdf_sha256(&shared, &salt, WRAP_INFO);
300 let wrap_nonce: [u8; 12] = random_bytes();
301 let mut wrap = wrap_nonce.to_vec();
302 wrap.extend_from_slice(&aes_seal(
303 &wrap_key,
304 &wrap_nonce,
305 &file_key,
306 r.fingerprint.as_bytes(),
307 )?);
308 stanzas.push(Stanza {
309 fp: r.fingerprint.clone(),
310 epk: b64().encode(epk),
311 wrap: b64().encode(wrap),
312 });
313 }
314 Ok(Envelope {
315 v: 1,
316 alg: ALG.to_string(),
317 recipients: stanzas,
318 nonce: b64().encode(nonce),
319 ct: b64().encode(ct),
320 })
321}
322
323/// Associated data bound into a sealed body: the scheme, the repository, and
324/// the variable name. Re-pointing a stolen envelope at another repo or another
325/// variable name therefore fails to open.
326pub fn body_aad(owner: &str, repo: &str, name: &str) -> Vec<u8> {
327 format!("anvil-secret-v1\n{owner}/{repo}\n{name}").into_bytes()
328}
329
330/// Whether `name` is usable as a shell environment variable: uppercase,
331/// digits, and underscores, not starting with a digit.
332pub fn valid_name(name: &str) -> bool {
333 !name.is_empty()
334 && name.len() <= 64
335 && !name.starts_with(|c: char| c.is_ascii_digit())
336 && name
337 .chars()
338 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
339}
340
341// --- primitives ------------------------------------------------------------
342
343fn random_bytes<const N: usize>() -> [u8; N] {
344 use argon2::password_hash::rand_core::{
345 OsRng,
346 RngCore,
347 };
348 let mut bytes = [0u8; N];
349 OsRng.fill_bytes(&mut bytes);
350 bytes
351}
352
353/// HKDF-SHA256 (RFC 5869) for a single 32-byte output — extract, then one
354/// expand block. Written out rather than pulled in as a dependency: the `hkdf`
355/// crate tracks a newer `sha2`/`digest` generation than the rest of the tree.
356fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8]) -> [u8; 32] {
357 use hmac::{
358 Hmac,
359 Mac,
360 };
361 type H = Hmac<sha2::Sha256>;
362
363 let mut extract = H::new_from_slice(salt).expect("HMAC accepts any key length");
364 extract.update(ikm);
365 let prk = extract.finalize().into_bytes();
366
367 let mut expand = H::new_from_slice(&prk).expect("HMAC accepts any key length");
368 expand.update(info);
369 expand.update(&[0x01]);
370 expand.finalize().into_bytes().into()
371}
372
373fn aes_seal(key: &[u8; 32], nonce: &[u8; 12], msg: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
374 let cipher = Aes256Gcm::new(key.into());
375 cipher
376 .encrypt(nonce.into(), Payload { msg, aad })
377 .map_err(|_| Error::Invalid("sealing secret failed".into()))
378}
379
380fn aes_open(
381 key: &[u8; 32],
382 nonce: &[u8; 12],
383 ct: &[u8],
384 aad: &[u8],
385) -> std::result::Result<Vec<u8>, ()> {
386 let cipher = Aes256Gcm::new(key.into());
387 cipher
388 .decrypt(nonce.into(), Payload { msg: ct, aad })
389 .map_err(|_| ())
390}
391
392/// The Curve25519 base point in Montgomery form (u = 9).
393const X25519_BASEPOINT: [u8; 32] = {
394 let mut u = [0u8; 32];
395 u[0] = 9;
396 u
397};
398
399/// X25519 scalar multiplication: clamp the scalar, multiply the u-coordinate.
400fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> [u8; 32] {
401 curve25519_dalek::montgomery::MontgomeryPoint(*point)
402 .mul_clamped(*scalar)
403 .to_bytes()
404}
405
406/// Map an Ed25519 public key (compressed Edwards `y`) to its X25519
407/// (Montgomery `u`) counterpart.
408fn ed25519_public_to_x25519(public: &[u8; 32]) -> Result<[u8; 32]> {
409 curve25519_dalek::edwards::CompressedEdwardsY(*public)
410 .decompress()
411 .map(|p| p.to_montgomery().to_bytes())
412 .ok_or_else(|| Error::Invalid("ssh-ed25519 key is not a valid curve point".into()))
413}
414
415/// Map an Ed25519 seed to the X25519 secret scalar: SHA-512, keep the low
416/// half, clamp — the standard derivation OpenSSH keys share with age.
417fn ed25519_seed_to_x25519(seed: &[u8]) -> [u8; 32] {
418 let digest = Sha512::digest(seed);
419 let mut scalar = [0u8; 32];
420 scalar.copy_from_slice(&digest[..32]);
421 scalar[0] &= 248;
422 scalar[31] &= 127;
423 scalar[31] |= 64;
424 scalar
425}
426
427// --- persistence -----------------------------------------------------------
428
429/// List a repository's secrets, oldest first. Envelopes are opaque here.
430pub async fn list(db: &toasty::Db, repo_id: i64) -> Result<Vec<RepoSecret>> {
431 let mut conn = db.clone();
432 let mut secrets = RepoSecret::filter(RepoSecret::fields().repo_id().eq(repo_id))
433 .exec(&mut conn)
434 .await?;
435 secrets.sort_by(|a, b| a.name.cmp(&b.name));
436 Ok(secrets)
437}
438
439/// Look one up by name within a repository.
440pub async fn find(db: &toasty::Db, repo_id: i64, name: &str) -> Result<Option<RepoSecret>> {
441 Ok(list(db, repo_id)
442 .await?
443 .into_iter()
444 .find(|s| s.name == name))
445}
446
447/// Create or replace a secret. `envelope` must already have been parsed with
448/// [`Envelope::parse`]; its recipient fingerprints are denormalized onto the
449/// row so the UI can flag secrets that a newly added key cannot open.
450pub async fn put(db: &toasty::Db, repo_id: i64, name: &str, envelope: &Envelope) -> Result<()> {
451 if !valid_name(name) {
452 return Err(Error::Invalid(
453 "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(),
454 ));
455 }
456 let json = serde_json::to_string(envelope)
457 .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?;
458 let recipients = envelope.recipient_fingerprints().join(",");
459 let now = crate::now();
460 let mut conn = db.clone();
461 match find(db, repo_id, name).await? {
462 Some(mut existing) => {
463 existing
464 .update()
465 .envelope(json)
466 .recipients(recipients)
467 .updated_at(now)
468 .exec(&mut conn)
469 .await?;
470 }
471 None => {
472 toasty::create!(RepoSecret {
473 repo_id: repo_id,
474 name: name,
475 envelope: json,
476 recipients: recipients,
477 created_at: now,
478 updated_at: now,
479 })
480 .exec(&mut conn)
481 .await?;
482 }
483 }
484 Ok(())
485}
486
487/// Delete a secret by name. No-op if it does not exist.
488pub async fn delete(db: &toasty::Db, repo_id: i64, name: &str) -> Result<()> {
489 if let Some(secret) = find(db, repo_id, name).await? {
490 let mut conn = db.clone();
491 secret.delete().exec(&mut conn).await?;
492 }
493 Ok(())
494}
495
496/// Delete every secret of a repository (used when the repo goes away).
497pub async fn delete_all(db: &toasty::Db, repo_id: i64) -> Result<()> {
498 for secret in list(db, repo_id).await? {
499 let mut conn = db.clone();
500 secret.delete().exec(&mut conn).await?;
501 }
502 Ok(())
503}
504
505// --- the unlock vault ------------------------------------------------------
506
507/// Plaintext secrets for unlocked repositories, held in memory only.
508///
509/// A repository is *sealed* until someone with a recipient ssh key runs
510/// `anvild secret unlock`, which opens the envelopes locally and posts the
511/// values here. They live in this map and nowhere else: no file, no database
512/// row, no log. A restart re-seals every repository, and each entry expires on
513/// its own TTL. CI reads from here (see `anvil-ci`), which is the one place
514/// anvil handles plaintext at all.
515#[derive(Clone, Default)]
516pub struct Vault {
517 inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<i64, Unlocked>>>,
518}
519
520struct Unlocked {
521 values: std::collections::BTreeMap<String, String>,
522 expires_at: i64,
523}
524
525impl Drop for Unlocked {
526 /// Overwrite the plaintext when an entry expires or is replaced, so it
527 /// does not linger in freed heap pages.
528 fn drop(&mut self) {
529 for value in self.values.values_mut() {
530 // SAFETY-adjacent: writing over the bytes in place. `String`'s
531 // buffer is the only copy we made.
532 unsafe { value.as_bytes_mut() }.fill(0);
533 }
534 }
535}
536
537/// What the UI shows about an unlocked repository.
538#[derive(Clone, Copy, Debug)]
539pub struct UnlockStatus {
540 pub expires_at: i64,
541 pub count: usize,
542}
543
544/// Current Unix time in seconds, so the web layer can render an unlock
545/// countdown against the same clock the vault expires on.
546pub fn now_secs() -> i64 {
547 crate::now()
548}
549
550/// Longest an unlock may last before it has to be renewed.
551pub const MAX_UNLOCK_SECS: i64 = 7 * 24 * 60 * 60;
552
553impl Vault {
554 /// Store `values` for `repo_id`, replacing any previous unlock. Returns
555 /// the expiry timestamp.
556 pub fn unlock(
557 &self,
558 repo_id: i64,
559 values: std::collections::BTreeMap<String, String>,
560 ttl_secs: i64,
561 ) -> i64 {
562 let ttl = ttl_secs.clamp(60, MAX_UNLOCK_SECS);
563 let expires_at = crate::now() + ttl;
564 let mut map = self.inner.lock().expect("vault mutex");
565 map.insert(repo_id, Unlocked { values, expires_at });
566 expires_at
567 }
568
569 /// Forget a repository's secrets immediately.
570 pub fn lock(&self, repo_id: i64) {
571 self.inner.lock().expect("vault mutex").remove(&repo_id);
572 }
573
574 /// Current unlock state, or `None` if sealed or expired.
575 pub fn status(&self, repo_id: i64) -> Option<UnlockStatus> {
576 let mut map = self.inner.lock().expect("vault mutex");
577 let entry = map.get(&repo_id)?;
578 if entry.expires_at <= crate::now() {
579 map.remove(&repo_id);
580 return None;
581 }
582 Some(UnlockStatus {
583 expires_at: entry.expires_at,
584 count: entry.values.len(),
585 })
586 }
587
588 /// Fetch the named secrets for a CI run. Returns the names that are not
589 /// available as the error, so the runner can say exactly what is missing.
590 pub fn take(
591 &self,
592 repo_id: i64,
593 names: &[String],
594 ) -> std::result::Result<Vec<(String, String)>, Vec<String>> {
595 let mut map = self.inner.lock().expect("vault mutex");
596 let Some(entry) = map.get(&repo_id) else {
597 return Err(names.to_vec());
598 };
599 if entry.expires_at <= crate::now() {
600 map.remove(&repo_id);
601 return Err(names.to_vec());
602 }
603 let mut found = Vec::with_capacity(names.len());
604 let mut missing = Vec::new();
605 for name in names {
606 match entry.values.get(name) {
607 Some(value) => found.push((name.clone(), value.clone())),
608 None => missing.push(name.clone()),
609 }
610 }
611 if missing.is_empty() {
612 Ok(found)
613 } else {
614 Err(missing)
615 }
616 }
617
618 /// Drop expired entries (called from the periodic sweep).
619 pub fn sweep(&self) {
620 let now = crate::now();
621 self.inner
622 .lock()
623 .expect("vault mutex")
624 .retain(|_, entry| entry.expires_at > now);
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use ssh_key::{
631 PrivateKey,
632 private::Ed25519Keypair,
633 };
634
635 use super::*;
636
637 fn keypair() -> (PrivateKey, Recipient) {
638 let key = PrivateKey::from(Ed25519Keypair::from_seed(&random_bytes()));
639 let line = key.public_key().to_openssh().unwrap();
640 let recipient = Recipient::from_openssh(&line).unwrap();
641 (key, recipient)
642 }
643
644 #[test]
645 fn seals_and_opens_for_every_recipient() {
646 let (a_key, a) = keypair();
647 let (b_key, b) = keypair();
648 let aad = body_aad("collin", "anvil", "DEPLOY_TOKEN");
649
650 let env = seal(b"hunter2", &aad, &[a.clone(), b.clone()]).unwrap();
651 for key in [&a_key, &b_key] {
652 let id = Identity::from_private_key(key).unwrap();
653 assert_eq!(env.open(&aad, &id).unwrap(), b"hunter2");
654 }
655 }
656
657 #[test]
658 fn a_key_that_is_not_a_recipient_cannot_open() {
659 let (_, a) = keypair();
660 let (outsider_key, _) = keypair();
661 let aad = body_aad("collin", "anvil", "TOKEN");
662 let env = seal(b"hunter2", &aad, &[a]).unwrap();
663 let outsider = Identity::from_private_key(&outsider_key).unwrap();
664 assert!(env.open(&aad, &outsider).is_err());
665 }
666
667 #[test]
668 fn associated_data_binds_the_name_and_repo() {
669 let (key, r) = keypair();
670 let id = Identity::from_private_key(&key).unwrap();
671 let env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap();
672 assert!(
673 env.open(&body_aad("collin", "anvil", "OTHER"), &id)
674 .is_err()
675 );
676 assert!(
677 env.open(&body_aad("mallory", "anvil", "TOKEN"), &id)
678 .is_err()
679 );
680 }
681
682 #[test]
683 fn tampering_with_the_body_is_detected() {
684 let (key, r) = keypair();
685 let id = Identity::from_private_key(&key).unwrap();
686 let aad = body_aad("collin", "anvil", "TOKEN");
687 let mut env = seal(b"hunter2", &aad, &[r]).unwrap();
688 let mut ct = b64().decode(&env.ct).unwrap();
689 ct[0] ^= 1;
690 env.ct = b64().encode(ct);
691 assert!(env.open(&aad, &id).is_err());
692 }
693
694 #[test]
695 fn envelopes_round_trip_through_json() {
696 let (key, r) = keypair();
697 let id = Identity::from_private_key(&key).unwrap();
698 let aad = body_aad("collin", "anvil", "TOKEN");
699 let json = serde_json::to_string(&seal(b"hunter2", &aad, &[r]).unwrap()).unwrap();
700 let parsed = Envelope::parse(&json).unwrap();
701 assert_eq!(parsed.open(&aad, &id).unwrap(), b"hunter2");
702 }
703
704 #[test]
705 fn rejects_malformed_envelopes() {
706 assert!(Envelope::parse("{}").is_err());
707 assert!(
708 Envelope::parse(r#"{"v":2,"alg":"x","recipients":[],"nonce":"","ct":""}"#).is_err()
709 );
710 }
711
712 #[test]
713 fn validates_names() {
714 assert!(valid_name("DEPLOY_TOKEN"));
715 assert!(valid_name("TOKEN2"));
716 assert!(!valid_name("2TOKEN"));
717 assert!(!valid_name("deploy_token"));
718 assert!(!valid_name("DEPLOY-TOKEN"));
719 assert!(!valid_name(""));
720 }
721}