1use anyhow::{anyhow, bail, Context as _, Result};
28use base64::engine::general_purpose::URL_SAFE_NO_PAD;
29use base64::Engine;
30use p256::SecretKey;
31use ring::digest::{digest, SHA256};
32use serde_json::{json, Value};
33use std::fs;
34use std::io::Write as _;
35use std::path::Path;
36
37use super::crypto::{Aead, Codec};
38
39const ALG: &str = "ES256";
42
43fn member<'a>(jwk: &'a Value, name: &str) -> Result<&'a str> {
45 jwk.get(name)
46 .and_then(Value::as_str)
47 .ok_or_else(|| anyhow!("JWK is missing the required `{name}` member"))
48}
49
50fn thumbprint_of_members(jwk: &Value) -> Result<String> {
60 let (crv, kty, x, y) = (
61 member(jwk, "crv")?,
62 member(jwk, "kty")?,
63 member(jwk, "x")?,
64 member(jwk, "y")?,
65 );
66 if kty != "EC" {
67 bail!("unsupported JWK key type {kty:?}; expected EC");
68 }
69 if crv != "P-256" {
70 bail!("unsupported JWK curve {crv:?}; only P-256 is supported");
71 }
72 for (name, value) in [("x", x), ("y", y)] {
79 if value.is_empty()
80 || !value
81 .bytes()
82 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
83 {
84 bail!("JWK member `{name}` is not unpadded base64url");
85 }
86 }
87 let canonical = serde_json::to_string(&json!({"crv": crv, "kty": kty, "x": x, "y": y}))
93 .context("serializing the canonical JWK")?;
94 Ok(URL_SAFE_NO_PAD.encode(digest(&SHA256, canonical.as_bytes()).as_ref()))
95}
96
97pub struct SigningKey {
99 secret: SecretKey,
100 kid: String,
101}
102
103impl SigningKey {
104 pub fn generate(kid: &str) -> Self {
111 for _ in 0..8 {
112 let mut bytes = [0u8; 32];
113 getrandom::fill(&mut bytes)
114 .expect("OS CSPRNG unavailable; refusing to generate a signing key");
115 if let Ok(secret) = SecretKey::from_slice(&bytes) {
116 return Self {
117 secret,
118 kid: kid.to_string(),
119 };
120 }
121 }
122 unreachable!("8 consecutive invalid P-256 scalars is not physically plausible")
123 }
124
125 pub fn from_jwk_json(jwk_json: &str, kid: &str) -> Result<Self> {
128 let v: Value = serde_json::from_str(jwk_json).context("key file is not valid JSON")?;
129 if v.get("d").is_none() {
130 bail!("JWK has no `d` member; a signing key must be a private key");
131 }
132 let minimal = json!({
135 "kty": member(&v, "kty")?,
136 "crv": member(&v, "crv")?,
137 "x": member(&v, "x")?,
138 "y": member(&v, "y")?,
139 "d": member(&v, "d")?,
140 });
141 let secret = SecretKey::from_jwk_str(&minimal.to_string())
142 .map_err(|err| anyhow!("not a valid P-256 private JWK: {err}"))?;
143 let kid = v
146 .get("kid")
147 .and_then(Value::as_str)
148 .unwrap_or(kid)
149 .to_string();
150 Ok(Self { secret, kid })
151 }
152
153 pub fn to_jwk_json(&self) -> Result<String> {
156 let mut v: Value =
157 serde_json::to_value(self.secret.to_jwk()).context("serializing the private JWK")?;
158 let obj = v
159 .as_object_mut()
160 .ok_or_else(|| anyhow!("JWK did not serialize to an object"))?;
161 obj.insert("kid".into(), json!(self.kid));
162 obj.insert("alg".into(), json!(ALG));
163 obj.insert("key_ops".into(), json!(["sign"]));
172 serde_json::to_string(&v).context("rendering the private JWK")
173 }
174
175 pub fn public_jwk(&self) -> Result<Value> {
177 let mut v: Value = serde_json::to_value(self.secret.public_key().to_jwk())
178 .context("serializing the public JWK")?;
179 let obj = v
180 .as_object_mut()
181 .ok_or_else(|| anyhow!("JWK did not serialize to an object"))?;
182 obj.remove("d");
185 obj.insert("kid".into(), json!(self.kid));
186 obj.insert("alg".into(), json!(ALG));
187 obj.insert("use".into(), json!("sig"));
188 Ok(v)
189 }
190
191 pub fn jwks_document(&self) -> Result<Value> {
193 Ok(json!({ "keys": [self.public_jwk()?] }))
194 }
195
196 pub fn thumbprint(&self) -> Result<String> {
198 thumbprint_of_members(&self.public_jwk()?)
199 }
200
201 pub fn public_thumbprint_of(jwk_json: &str) -> Result<String> {
203 let v: Value = serde_json::from_str(jwk_json).context("not valid JSON")?;
204 thumbprint_of_members(&v)
205 }
206
207 pub fn kid(&self) -> &str {
208 &self.kid
209 }
210
211 pub(crate) fn secret(&self) -> &SecretKey {
215 &self.secret
216 }
217}
218
219struct Staged {
224 path: std::path::PathBuf,
225 keep: bool,
226}
227
228impl Staged {
229 fn write(near: &Path, contents: &str) -> Result<Self> {
239 let mut suffix = [0u8; 8];
240 getrandom::fill(&mut suffix).expect("OS CSPRNG unavailable; refusing to stage a key");
241 let mut name = near.as_os_str().to_os_string();
242 name.push(format!(
243 ".{}.{}.tmp",
244 std::process::id(),
245 suffix
246 .iter()
247 .map(|b| format!("{b:02x}"))
248 .collect::<String>()
249 ));
250 let path = std::path::PathBuf::from(name);
251
252 let mut opts = fs::OpenOptions::new();
253 opts.write(true).create_new(true);
254 #[cfg(unix)]
255 {
256 use std::os::unix::fs::OpenOptionsExt as _;
257 opts.mode(0o600);
258 }
259 let mut file = opts
260 .open(&path)
261 .with_context(|| format!("staging a key write at {}", path.display()))?;
262 let staged = Self { path, keep: false };
263
264 file.write_all(contents.as_bytes())
265 .context("writing the staged signing-key file")?;
266 file.sync_all().context("flushing the staged key file")?;
269 Ok(staged)
270 }
271
272 fn path(&self) -> &Path {
273 &self.path
274 }
275
276 fn keep(mut self) {
278 self.keep = true;
279 }
280}
281
282impl Drop for Staged {
283 fn drop(&mut self) {
284 if !self.keep {
285 let _ = fs::remove_file(&self.path);
286 }
287 }
288}
289
290fn write_new_owner_only(path: &Path, contents: &str) -> Result<bool> {
304 let staged = Staged::write(path, contents)?;
305 if let Err(err) = fs::hard_link(staged.path(), path) {
306 if err.kind() == std::io::ErrorKind::AlreadyExists {
318 return Ok(false);
319 }
320 return Err(err)
321 .with_context(|| format!("creating the signing-key file at {}", path.display()));
322 }
323 drop(staged);
326 sync_parent_dir(path);
327 Ok(true)
328}
329
330fn rewrite_owner_only(path: &Path, contents: &str) -> Result<()> {
337 let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
338 let staged = Staged::write(&target, contents)?;
339 fs::rename(staged.path(), &target)
340 .with_context(|| format!("replacing the signing-key file at {}", target.display()))?;
341 staged.keep();
343 sync_parent_dir(&target);
344 Ok(())
345}
346
347fn sync_parent_dir(path: &Path) {
350 if let Some(dir) = path.parent() {
351 if let Ok(handle) = fs::File::open(dir) {
352 let _ = handle.sync_all();
353 }
354 }
355}
356
357pub fn load_or_create(path: &Path, codec: &Codec, kid: &str) -> Result<SigningKey> {
367 let raw = match fs::read_to_string(path) {
370 Ok(raw) => Some(raw),
371 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
372 Err(err) => {
373 return Err(err)
374 .with_context(|| format!("reading the signing-key file at {}", path.display()))
375 }
376 };
377
378 if let Some(raw) = raw {
379 return adopt_existing(path, &raw, codec, kid);
380 }
381
382 let key = SigningKey::generate(kid);
383 if write_new_owner_only(path, &codec.encrypt(&key.to_jwk_json()?))? {
384 return Ok(key);
385 }
386
387 let raw = match fs::read_to_string(path) {
401 Ok(raw) => raw,
402 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
406 bail!(
407 "the signing-key path {} exists but cannot be read; it is most likely a \
408 dangling symlink, which must be removed or repointed by hand",
409 path.display()
410 )
411 }
412 Err(err) => {
413 return Err(err).with_context(|| {
414 format!(
415 "re-reading the signing-key file at {} after losing the creation race",
416 path.display()
417 )
418 })
419 }
420 };
421 adopt_existing(path, &raw, codec, kid)
422}
423
424fn adopt_existing(path: &Path, raw: &str, codec: &Codec, kid: &str) -> Result<SigningKey> {
430 let raw = raw.trim();
433 let plaintext = codec.maybe_decrypt(raw).with_context(|| {
434 format!(
435 "decrypting the signing-key file at {} -- refusing to generate a \
436 replacement, since that would rotate the client's identity",
437 path.display()
438 )
439 })?;
440 let key = SigningKey::from_jwk_json(&plaintext, kid)?;
441 if !Aead::is_ciphertext(raw) {
442 rewrite_owner_only(path, &codec.encrypt(&plaintext))?;
445 }
446 Ok(key)
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::oauth::crypto::{Aead, Codec};
453
454 const KEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
455 const KID: &str = "featherreader-oauth-1";
456
457 const JOSE_PRIVATE_JWK: &str = r#"{
467 "kty": "EC",
468 "x": "HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I",
469 "y": "wwvoKJJUKd57bdQ3f3GpVuW-0-1MI_FhMt86Q9M95Ig",
470 "crv": "P-256",
471 "d": "ltBp9dkK7xkLm9VXOd6CMiLdFRKWQwVrN0Vf8QwC3a4"
472 }"#;
473 const JOSE_THUMBPRINT: &str = "nfjQX8hSYRpE05ADhZk6PVsPatJ6MqzqvzYxlL-kMC8";
474
475 #[test]
482 fn thumbprint_matches_an_independent_rfc7638_implementation() {
483 let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
484 assert_eq!(key.thumbprint().unwrap(), JOSE_THUMBPRINT);
485 }
486
487 #[test]
490 fn thumbprint_is_identical_for_the_private_and_public_halves() {
491 let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
492 let public_only = serde_json::to_string(&key.public_jwk().unwrap()).unwrap();
493 let reloaded = SigningKey::public_thumbprint_of(&public_only).unwrap();
494 assert_eq!(reloaded, key.thumbprint().unwrap());
495 assert_eq!(reloaded, JOSE_THUMBPRINT);
496 }
497
498 #[test]
501 fn thumbprint_ignores_member_order_and_whitespace() {
502 let reordered = r#"{"y":"wwvoKJJUKd57bdQ3f3GpVuW-0-1MI_FhMt86Q9M95Ig","d":"ltBp9dkK7xkLm9VXOd6CMiLdFRKWQwVrN0Vf8QwC3a4","crv":"P-256","kty":"EC","x":"HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I"}"#;
503 let key = SigningKey::from_jwk_json(reordered, KID).unwrap();
504 assert_eq!(key.thumbprint().unwrap(), JOSE_THUMBPRINT);
505 }
506
507 #[test]
518 fn public_thumbprint_of_rejects_members_that_are_not_base64url() {
519 for x in [r#"A\"A"#, r#"A\\A"#, "A A", "A+A", "A/A", "AAA=", "", "é"] {
520 let jwk = serde_json::json!({"kty":"EC","crv":"P-256","x":x,"y":"BBB"});
521 assert!(
522 SigningKey::public_thumbprint_of(&jwk.to_string()).is_err(),
523 "accepted a non-base64url x: {x:?}"
524 );
525 }
526 }
527
528 #[test]
529 fn public_thumbprint_of_rejects_malformed_or_non_ec_jwks() {
530 for jwk in [
531 r#"{"kty":"RSA","crv":"P-256","x":"AAA","y":"BBB"}"#,
532 r#"{"kty":"EC","crv":"P-521","x":"AAA","y":"BBB"}"#,
533 r#"{"kty":"EC","crv":"P-256","x":"AAA"}"#,
534 r#"{"kty":"EC","crv":"P-256"}"#,
535 r#"{"kty":"EC","crv":"P-256","x":123,"y":"BBB"}"#,
536 "not json",
537 "[]",
538 ] {
539 assert!(
540 SigningKey::public_thumbprint_of(jwk).is_err(),
541 "accepted {jwk}"
542 );
543 }
544 }
545
546 #[test]
547 fn generate_produces_distinct_keys() {
548 let a = SigningKey::generate(KID);
549 let b = SigningKey::generate(KID);
550 assert_ne!(a.thumbprint().unwrap(), b.thumbprint().unwrap());
551 }
552
553 #[test]
554 fn a_generated_key_round_trips_through_its_jwk() {
555 let key = SigningKey::generate(KID);
556 let json = key.to_jwk_json().unwrap();
557 let back = SigningKey::from_jwk_json(&json, KID).unwrap();
558 assert_eq!(back.thumbprint().unwrap(), key.thumbprint().unwrap());
559 }
560
561 #[test]
564 fn the_persisted_jwk_carries_the_private_scalar_and_the_kid() {
565 let key = SigningKey::generate(KID);
566 let v: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
567 assert!(
568 v.get("d").is_some(),
569 "the signing key must persist its scalar"
570 );
571 assert_eq!(v["kid"], KID);
572 assert_eq!(v["kty"], "EC");
573 assert_eq!(v["crv"], "P-256");
574 }
575
576 #[test]
581 fn the_public_jwk_and_jwks_never_contain_the_private_scalar() {
582 let key = SigningKey::generate(KID);
583 let public = key.public_jwk().unwrap();
584 assert!(public.get("d").is_none());
585
586 let jwks = key.jwks_document().unwrap();
587 let rendered = serde_json::to_string(&jwks).unwrap();
588 assert!(
589 !rendered.contains("\"d\""),
590 "JWKS leaked the private scalar: {rendered}"
591 );
592 let private: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
603 let scalar = private["d"].as_str().expect("the private JWK has a scalar");
604 assert!(
605 !rendered.contains(scalar),
606 "JWKS leaked the private scalar: {rendered}"
607 );
608 }
609
610 #[test]
611 fn the_jwks_document_is_a_keys_array_with_the_verification_metadata() {
612 let key = SigningKey::from_jwk_json(JOSE_PRIVATE_JWK, KID).unwrap();
613 let jwks = key.jwks_document().unwrap();
614 let entry = &jwks["keys"][0];
615 assert_eq!(jwks["keys"].as_array().unwrap().len(), 1);
616 assert_eq!(entry["kty"], "EC");
617 assert_eq!(entry["crv"], "P-256");
618 assert_eq!(entry["kid"], KID);
619 assert_eq!(entry["alg"], "ES256");
620 assert_eq!(entry["use"], "sig");
621 assert_eq!(entry["x"], "HWngJQsJ6v606UgaeEf0Xv_Fe3c4MwChe3ouzCDZf3I");
622 }
623
624 fn tmp_path(name: &str) -> std::path::PathBuf {
627 let mut p = std::env::temp_dir();
628 p.push(format!("fr-oauth-key-test-{}-{name}", std::process::id()));
629 let _ = std::fs::remove_file(&p);
630 p
631 }
632
633 #[test]
634 fn load_or_create_generates_once_then_reloads_the_same_key() {
635 let path = tmp_path("reload");
636 let codec = Codec::new(Some(KEY)).unwrap();
637
638 let first = load_or_create(&path, &codec, KID).unwrap();
639 let second = load_or_create(&path, &codec, KID).unwrap();
640 assert_eq!(
641 first.thumbprint().unwrap(),
642 second.thumbprint().unwrap(),
643 "a restart must not rotate the client's signing key"
644 );
645 let _ = std::fs::remove_file(&path);
646 }
647
648 #[test]
649 fn the_key_file_is_encrypted_at_rest() {
650 let path = tmp_path("encrypted");
651 let codec = Codec::new(Some(KEY)).unwrap();
652 let key = load_or_create(&path, &codec, KID).unwrap();
653
654 let raw = std::fs::read_to_string(&path).unwrap();
655 assert!(Aead::is_ciphertext(raw.trim()), "key file is not encrypted");
656 let scalar = serde_json::from_str::<serde_json::Value>(&key.to_jwk_json().unwrap())
660 .unwrap()["d"]
661 .as_str()
662 .unwrap()
663 .to_string();
664 assert!(!raw.contains(&scalar), "private scalar found on disk");
665 let plain = codec.maybe_decrypt(raw.trim()).unwrap();
667 let same = SigningKey::from_jwk_json(&plain, KID).unwrap();
668 assert_eq!(same.thumbprint().unwrap(), key.thumbprint().unwrap());
669 let _ = std::fs::remove_file(&path);
670 }
671
672 #[test]
675 fn a_legacy_plaintext_key_file_is_migrated_to_ciphertext_in_place() {
676 let path = tmp_path("migrate");
677 let codec = Codec::new(Some(KEY)).unwrap();
678
679 let original = SigningKey::generate(KID);
680 std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
681
682 let loaded = load_or_create(&path, &codec, KID).unwrap();
683 assert_eq!(
684 loaded.thumbprint().unwrap(),
685 original.thumbprint().unwrap(),
686 "migration must not rotate the key"
687 );
688 let raw = std::fs::read_to_string(&path).unwrap();
689 assert!(
690 Aead::is_ciphertext(raw.trim()),
691 "file was not upgraded to ciphertext"
692 );
693 let _ = std::fs::remove_file(&path);
694 }
695
696 #[test]
699 fn a_key_file_written_by_the_node_sidecar_loads() {
700 let path = tmp_path("sidecar");
701 let codec = Codec::new(Some(KEY)).unwrap();
702 std::fs::write(&path, codec.encrypt(JOSE_PRIVATE_JWK)).unwrap();
703
704 let loaded = load_or_create(&path, &codec, KID).unwrap();
705 assert_eq!(loaded.thumbprint().unwrap(), JOSE_THUMBPRINT);
706 let _ = std::fs::remove_file(&path);
707 }
708
709 #[test]
713 fn an_undecryptable_key_file_is_an_error_not_a_silent_regeneration() {
714 let path = tmp_path("corrupt");
715 let codec = Codec::new(Some(KEY)).unwrap();
716 let other = Codec::new(Some("a-completely-different-passphrase")).unwrap();
717 let on_disk = other.encrypt(JOSE_PRIVATE_JWK);
718 std::fs::write(&path, &on_disk).unwrap();
719
720 assert!(load_or_create(&path, &codec, KID).is_err());
721 assert_eq!(
724 std::fs::read_to_string(&path).unwrap(),
725 on_disk,
726 "the key file was modified on a decrypt failure"
727 );
728 let _ = std::fs::remove_file(&path);
729 }
730
731 #[test]
736 fn the_private_jwk_uses_key_ops_rather_than_use() {
737 let key = SigningKey::generate(KID);
738 let v: serde_json::Value = serde_json::from_str(&key.to_jwk_json().unwrap()).unwrap();
739 assert_eq!(v["key_ops"], serde_json::json!(["sign"]));
740 assert!(
741 v.get("use").is_none(),
742 "private JWK carries `use`, which jose deprecates"
743 );
744 assert_eq!(key.public_jwk().unwrap()["use"], "sig");
746 }
747
748 #[test]
753 fn migration_leaves_no_temporary_file_behind() {
754 let path = tmp_path("atomic");
755 let codec = Codec::new(Some(KEY)).unwrap();
756 let original = SigningKey::generate(KID);
757 std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
758
759 load_or_create(&path, &codec, KID).unwrap();
760
761 let dir = path.parent().unwrap();
762 let leftovers: Vec<_> = std::fs::read_dir(dir)
763 .unwrap()
764 .filter_map(|e| e.ok())
765 .map(|e| e.file_name().to_string_lossy().to_string())
766 .filter(|n| {
767 n.starts_with(path.file_name().unwrap().to_str().unwrap())
768 && n != path.file_name().unwrap().to_str().unwrap()
769 })
770 .collect();
771 assert!(
772 leftovers.is_empty(),
773 "temp files left behind: {leftovers:?}"
774 );
775 let _ = std::fs::remove_file(&path);
776 }
777
778 #[test]
779 fn a_garbage_key_file_is_an_error() {
780 let path = tmp_path("garbage");
781 let codec = Codec::new(Some(KEY)).unwrap();
782 std::fs::write(&path, codec.encrypt("{\"kty\":\"EC\",\"crv\":\"P-256\"}")).unwrap();
783 assert!(load_or_create(&path, &codec, KID).is_err());
784 let _ = std::fs::remove_file(&path);
785 }
786
787 #[cfg(unix)]
792 #[test]
793 fn migrating_through_a_symlink_rewrites_the_target_not_the_link() {
794 let target = tmp_path("symlink-target");
795 let link = tmp_path("symlink-link");
796 let _ = std::fs::remove_file(&link);
797 let codec = Codec::new(Some(KEY)).unwrap();
798
799 let original = SigningKey::generate(KID);
800 std::fs::write(&target, original.to_jwk_json().unwrap()).unwrap();
801 std::os::unix::fs::symlink(&target, &link).unwrap();
802
803 let loaded = load_or_create(&link, &codec, KID).unwrap();
804 assert_eq!(loaded.thumbprint().unwrap(), original.thumbprint().unwrap());
805
806 assert!(
807 std::fs::symlink_metadata(&link)
808 .unwrap()
809 .file_type()
810 .is_symlink(),
811 "the symlink was replaced by a regular file"
812 );
813 let target_contents = std::fs::read_to_string(&target).unwrap();
814 assert!(
815 Aead::is_ciphertext(target_contents.trim()),
816 "the real target still holds plaintext after migration"
817 );
818 let _ = std::fs::remove_file(&link);
819 let _ = std::fs::remove_file(&target);
820 }
821
822 #[cfg(unix)]
825 #[test]
826 fn repeated_migrations_leave_no_temporary_files() {
827 let path = tmp_path("repeat");
828 let codec = Codec::new(Some(KEY)).unwrap();
829 let original = SigningKey::generate(KID);
830
831 for _ in 0..3 {
832 std::fs::write(&path, original.to_jwk_json().unwrap()).unwrap();
834 let loaded = load_or_create(&path, &codec, KID).unwrap();
835 assert_eq!(loaded.thumbprint().unwrap(), original.thumbprint().unwrap());
836 }
837
838 let name = path.file_name().unwrap().to_str().unwrap().to_string();
839 let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
840 .unwrap()
841 .filter_map(|e| e.ok())
842 .map(|e| e.file_name().to_string_lossy().to_string())
843 .filter(|n| n.starts_with(&name) && *n != name)
844 .collect();
845 assert!(
846 leftovers.is_empty(),
847 "temp files left behind: {leftovers:?}"
848 );
849 let _ = std::fs::remove_file(&path);
850 }
851
852 #[cfg(unix)]
853 #[test]
854 fn a_created_key_file_is_not_world_readable() {
855 use std::os::unix::fs::PermissionsExt;
856 let path = tmp_path("mode");
857 let codec = Codec::new(Some(KEY)).unwrap();
858 load_or_create(&path, &codec, KID).unwrap();
859
860 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
863 assert_eq!(
864 mode & 0o077,
865 0,
866 "key file mode {mode:o} is group/world readable"
867 );
868 let _ = std::fs::remove_file(&path);
869 }
870
871 #[test]
879 fn a_lost_creation_race_adopts_the_key_on_disk() {
880 let dir = std::env::temp_dir().join(format!("fr-race-{}", std::process::id()));
881 std::fs::create_dir_all(&dir).unwrap();
882 let path = dir.join("key.json");
883 let _ = std::fs::remove_file(&path);
884 let codec = Codec::new(None).unwrap();
885
886 let winner = load_or_create(&path, &codec, "kid").unwrap();
888 let loser = load_or_create(&path, &codec, "kid").unwrap();
891 assert_eq!(
892 winner.thumbprint().unwrap(),
893 loser.thumbprint().unwrap(),
894 "the second caller returned a different key than the one on disk"
895 );
896
897 let _ = std::fs::remove_file(&path);
899 let keys: Vec<String> = std::thread::scope(|s| {
900 (0..8)
901 .map(|_| {
902 let p = path.clone();
903 let c = &codec;
904 s.spawn(move || load_or_create(&p, c, "kid").unwrap().thumbprint().unwrap())
905 })
906 .collect::<Vec<_>>()
907 .into_iter()
908 .map(|h| h.join().unwrap())
909 .collect()
910 });
911 let distinct: std::collections::HashSet<_> = keys.iter().collect();
912 assert_eq!(distinct.len(), 1, "the racers ended up with different keys");
913
914 let _ = std::fs::remove_dir_all(&dir);
915 }
916
917 #[test]
924 fn a_key_file_with_trailing_whitespace_still_loads() {
925 let dir = std::env::temp_dir().join(format!("fr-trim-{}", std::process::id()));
926 std::fs::create_dir_all(&dir).unwrap();
927 let path = dir.join("key.json");
928 let codec = Codec::new(Some(&"a".repeat(43))).unwrap();
929
930 let key = SigningKey::generate("kid");
931 std::fs::write(
932 &path,
933 format!("{}\n", codec.encrypt(&key.to_jwk_json().unwrap())),
934 )
935 .unwrap();
936
937 let loaded =
938 load_or_create(&path, &codec, "kid").expect("a trailing newline is not a corrupt key");
939 assert_eq!(loaded.thumbprint().unwrap(), key.thumbprint().unwrap());
940 let _ = std::fs::remove_dir_all(&dir);
941 }
942
943 #[test]
947 fn creating_a_key_leaves_no_temporary_file_behind() {
948 let dir = std::env::temp_dir().join(format!("fr-tmp-{}", std::process::id()));
949 std::fs::create_dir_all(&dir).unwrap();
950 let path = dir.join("key.json");
951 let codec = Codec::new(None).unwrap();
952
953 load_or_create(&path, &codec, "kid").unwrap();
954
955 let leftovers: Vec<_> = std::fs::read_dir(&dir)
956 .unwrap()
957 .filter_map(|e| e.ok())
958 .map(|e| e.file_name().to_string_lossy().into_owned())
959 .filter(|n| n != "key.json")
960 .collect();
961 assert!(
962 leftovers.is_empty(),
963 "temporary files left behind: {leftovers:?}"
964 );
965 let _ = std::fs::remove_dir_all(&dir);
966 }
967}