1use base64::Engine as _;
2use choreo_keystore::ServiceCredential;
3use choreo_proto::ClientMessage;
4use tracing::{debug, info, warn};
5use x25519_dalek::{PublicKey, StaticSecret};
6use zeroize::{Zeroize, Zeroizing};
7
8use crate::error::ClientError;
9use crate::known_servers::KnownServers;
10use crate::shell::UnlockMethod;
11
12pub fn resolve_private_key(method: &UnlockMethod, addr: &str) -> Result<Vec<u8>, ClientError> {
34 match method {
35 UnlockMethod::Raw => {
36 info!(addr, "resolving stored unlock key for addr");
37 stored_or_adopted_unlock_key(addr)?
38 .map(|k| k.to_vec())
39 .ok_or_else(|| ClientError::NoUnlockKey(addr.to_string()))
40 }
41 UnlockMethod::Key(key) => {
42 info!(addr, "unlocking with caller-supplied base64 unlock key");
43 let key = decode_base64_unlock_key(key)?;
47 Ok(key.to_vec())
48 }
49 }
50}
51
52fn decode_base64_unlock_key(key: &str) -> Result<[u8; 32], ClientError> {
57 let raw = Zeroizing::new(
58 base64::engine::general_purpose::STANDARD
59 .decode(key.trim())
60 .map_err(|_| ClientError::PrivateKeyInvalid)?,
61 );
62 raw.as_slice()
63 .try_into()
64 .map_err(|_| ClientError::PrivateKeyInvalid)
65}
66
67fn read_raw_private_key() -> Result<Vec<u8>, ClientError> {
70 let path = choreo_keystore::paths::private_key_path()
71 .map_err(|e| ClientError::PrivateKeyRead(e.to_string()))?;
72 let data = std::fs::read(&path).map_err(|e| ClientError::PrivateKeyRead(e.to_string()))?;
73 if data.len() != 32 {
74 return Err(ClientError::PrivateKeyInvalid);
75 }
76 Ok(data)
77}
78
79fn stored_unlock_key(addr: &str) -> Option<[u8; 32]> {
84 match KnownServers::load() {
85 Ok(store) => match store.unlock_key(addr) {
86 Ok(Some(key)) => {
87 info!(addr, "using stored per-daemon unlock key");
88 Some(key)
89 }
90 Ok(None) => None,
91 Err(e) => {
92 warn!(addr, error = %e, "stored unlock_key failed to decode; ignoring");
93 None
94 }
95 },
96 Err(e) => {
97 warn!(addr, error = %e, "could not load known_servers store; ignoring stored unlock key");
98 None
99 }
100 }
101}
102
103fn stored_or_adopted_unlock_key(addr: &str) -> Result<Option<[u8; 32]>, ClientError> {
110 if let Some(key) = stored_unlock_key(addr) {
111 return Ok(Some(key));
112 }
113 match read_raw_private_key() {
114 Ok(key) => {
115 let key: [u8; 32] = key
116 .as_slice()
117 .try_into()
118 .map_err(|_| ClientError::PrivateKeyInvalid)?;
119 info!(
120 addr,
121 "using legacy raw private key; copying into known_servers.toml"
122 );
123 if let Err(e) = KnownServers::load().and_then(|mut s| s.set_unlock_key(addr, &key)) {
127 warn!(
128 addr,
129 error = %e,
130 "could not copy legacy unlock key into known_servers; it will be recorded on daemon confirmation"
131 );
132 }
133 Ok(Some(key))
134 }
135 Err(ClientError::PrivateKeyInvalid) => {
136 warn!(
137 addr,
138 "legacy private key file exists but is not 32 bytes; ignoring"
139 );
140 Ok(None)
141 }
142 Err(_) => Ok(None), }
144}
145
146pub fn try_auto_unlock_key(addr: &str) -> Option<Vec<u8>> {
158 match stored_or_adopted_unlock_key(addr) {
159 Ok(Some(key)) => Some(key.to_vec()),
160 Ok(None) => {
161 debug!(
162 addr,
163 "auto-unlock: no key available (daemon will start locked)"
164 );
165 None
166 }
167 Err(e) => {
168 warn!(addr, error = %e, "auto-unlock: key resolution failed");
169 None
170 }
171 }
172}
173
174pub fn record_unlock_key(addr: &str, key: &[u8]) -> Result<(), ClientError> {
186 let key: [u8; 32] = key.try_into().map_err(|_| ClientError::PrivateKeyInvalid)?;
187 KnownServers::load()?.set_unlock_key(addr, &key)?;
188 Ok(())
189}
190
191fn parse_credential(
192 credential_type: &str,
193 fields: &[String],
194) -> Result<ServiceCredential, ClientError> {
195 match credential_type {
196 "api_key" => {
197 if fields.is_empty() {
198 return Err(ClientError::CredentialParse(
199 "missing api_key field".to_string(),
200 ));
201 }
202 Ok(ServiceCredential::ApiKey {
205 key: fields.first().cloned().ok_or_else(|| {
206 ClientError::CredentialParse("missing api_key field".to_string())
207 })?,
208 })
209 }
210 "x" => {
211 if fields.len() < 5 {
212 return Err(ClientError::CredentialParse(
213 "missing X credential fields".to_string(),
214 ));
215 }
216 let bearer = match fields.get(4) {
217 Some(v) if v == "-" => None,
218 Some(v) => Some(v.clone()),
219 None => None,
220 };
221 let field = |i: usize| -> Result<String, ClientError> {
224 fields.get(i).cloned().ok_or_else(|| {
225 ClientError::CredentialParse("missing X credential fields".to_string())
226 })
227 };
228 Ok(ServiceCredential::X {
229 api_key: field(0)?,
230 api_key_secret: field(1)?,
231 access_token: field(2)?,
232 access_token_secret: field(3)?,
233 bearer_token: bearer,
234 })
235 }
236 other => Err(ClientError::CredentialParse(format!(
237 "unknown credential type: {other}"
238 ))),
239 }
240}
241
242fn resolve_keystore_key(addr: &str) -> Result<[u8; 32], ClientError> {
253 stored_or_adopted_unlock_key(addr)?.ok_or_else(|| ClientError::NoUnlockKey(addr.to_string()))
254}
255
256pub fn bind_fresh_daemon(addr: &str) -> Result<([u8; 32], ClientMessage), ClientError> {
280 let fresh: [u8; 32] = rand::random();
284 info!(
285 addr,
286 "minted fresh random keystore binding key for unbound daemon"
287 );
288 KnownServers::load()?.set_unlock_key(addr, &fresh)?;
290 debug!(addr, "recorded fresh bind key into known_servers pre-send");
291 let msg = ClientMessage::BindKeystore {
292 key: fresh.to_vec(),
293 };
294 Ok((fresh, msg))
295}
296
297#[allow(clippy::needless_pass_by_value)]
311pub fn build_add_credential_message(
312 addr: &str,
313 service: String,
314 credential_type: String,
315 fields: Vec<String>,
316) -> Result<(ClientMessage, Vec<u8>), ClientError> {
317 debug!(
318 addr,
319 service, credential_type, "building add credential message"
320 );
321 let credential = parse_credential(&credential_type, &fields)?;
322 let mut fields = fields;
327 let result = build_add_credential_from_credential(addr, service, credential);
328 for field in &mut fields {
329 field.zeroize();
330 }
331 result
332}
333
334#[derive(Clone, Copy, Debug)]
350pub struct KeystoreAutoBind {
351 attempted: bool,
355}
356
357impl Default for KeystoreAutoBind {
358 fn default() -> Self {
359 Self::new()
360 }
361}
362
363impl KeystoreAutoBind {
364 #[must_use]
366 pub fn new() -> Self {
367 Self { attempted: false }
368 }
369
370 #[must_use]
372 pub fn attempted(&self) -> bool {
373 self.attempted
374 }
375
376 pub fn on_unbound(
396 &mut self,
397 addr: &str,
398 ) -> Result<Option<([u8; 32], ClientMessage)>, ClientError> {
399 if self.attempted {
400 warn!(
404 addr,
405 "keystore still unbound after a bind attempt; not re-binding"
406 );
407 return Ok(None);
408 }
409 self.attempted = true;
413 bind_fresh_daemon(addr).map(Some)
414 }
415}
416
417#[derive(Debug)]
426pub enum AutoBindAttempt {
427 Bind { key: [u8; 32], msg: ClientMessage },
433 Suppressed,
437 Failed { error: ClientError },
443}
444
445#[must_use]
457pub fn attempt_keystore_auto_bind(bind: &mut KeystoreAutoBind, addr: &str) -> AutoBindAttempt {
458 match bind.on_unbound(addr) {
459 Ok(Some((key, msg))) => {
460 info!(addr, "auto-binding unbound daemon with a fresh key");
461 AutoBindAttempt::Bind { key, msg }
462 }
463 Ok(None) => AutoBindAttempt::Suppressed,
465 Err(e) => {
466 warn!(addr, error = %e, "auto-bind failed");
467 AutoBindAttempt::Failed { error: e }
468 }
469 }
470}
471
472#[allow(clippy::needless_pass_by_value)]
494pub fn build_add_credential_from_credential(
495 addr: &str,
496 service: String,
497 credential: ServiceCredential,
498) -> Result<(ClientMessage, Vec<u8>), ClientError> {
499 debug!(
500 addr,
501 service, "building add credential message from parsed credential"
502 );
503 let mut unlock_key = resolve_keystore_key(addr)?;
504 let derived_pub = PublicKey::from(&StaticSecret::from(unlock_key));
505
506 let mut plaintext =
507 postcard::to_allocvec(&credential).map_err(|e| ClientError::Postcard(e.to_string()))?;
508
509 let encrypted_payload =
510 choreo_keystore::crypto::encrypt_with_public_key(derived_pub.as_bytes(), &plaintext)
511 .map_err(|e| ClientError::Encryption(e.to_string()))?;
512
513 plaintext.zeroize();
517
518 let msg = ClientMessage::AddCredential {
519 service,
520 encrypted_payload,
521 unlock_key: unlock_key.to_vec(),
522 };
523 let result = (msg, unlock_key.to_vec());
527 unlock_key.zeroize();
528 Ok(result)
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
536 fn parse_credential_api_key() {
537 let cred = parse_credential("api_key", &["sk-test".into()]).unwrap();
538 assert!(matches!(cred, ServiceCredential::ApiKey { ref key } if key == "sk-test"));
539 }
540
541 #[test]
542 fn parse_credential_api_key_missing_field() {
543 let result = parse_credential("api_key", &[]);
544 assert!(result.is_err());
545 }
546
547 #[test]
548 fn parse_credential_x() {
549 let fields = vec![
550 "ak".into(),
551 "aks".into(),
552 "at".into(),
553 "ats".into(),
554 "-".into(),
555 ];
556 let cred = parse_credential("x", &fields).unwrap();
557 let view = cred.as_x().unwrap();
558 assert_eq!(view.api_key, "ak");
559 assert_eq!(view.api_key_secret, "aks");
560 assert_eq!(view.access_token, "at");
561 assert_eq!(view.access_token_secret, "ats");
562 assert!(view.bearer_token.is_none());
563 }
564
565 #[test]
566 fn parse_credential_x_with_bearer() {
567 let fields = vec![
568 "ak".into(),
569 "aks".into(),
570 "at".into(),
571 "ats".into(),
572 "bt".into(),
573 ];
574 let cred = parse_credential("x", &fields).unwrap();
575 let view = cred.as_x().unwrap();
576 assert_eq!(view.bearer_token, Some("bt"));
577 }
578
579 #[test]
580 fn parse_credential_x_missing_fields() {
581 let fields = vec!["ak".into(), "aks".into(), "at".into()];
582 let result = parse_credential("x", &fields);
583 assert!(result.is_err());
584 }
585
586 #[test]
587 fn parse_credential_unknown_type() {
588 let result = parse_credential("unknown", &[]);
589 assert!(result.is_err());
590 }
591
592 #[test]
595 fn try_auto_unlock_key_with_raw_key() {
596 let dir = tempfile::tempdir().unwrap();
597 let _guard =
598 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
599 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
600
601 let (_, sk) = choreo_keystore::crypto::generate_keypair();
602 std::fs::write(dir.path().join("choreographr/identity.pk"), sk).unwrap();
603
604 assert_eq!(try_auto_unlock_key("local.sock"), Some(sk.to_vec()));
605
606 let store = KnownServers::load().unwrap();
609 assert_eq!(store.unlock_key("local.sock").unwrap(), Some(sk));
610 assert!(dir.path().join("choreographr/identity.pk").exists());
611 }
612
613 #[test]
614 fn try_auto_unlock_key_with_invalid_raw_key_length() {
615 let dir = tempfile::tempdir().unwrap();
616 let _guard =
617 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
618 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
619
620 std::fs::write(dir.path().join("choreographr/identity.pk"), b"not 32 bytes").unwrap();
622
623 assert!(try_auto_unlock_key("local.sock").is_none());
624 }
625
626 #[test]
629 fn try_auto_unlock_key_stored_key_beats_legacy() {
630 let dir = tempfile::tempdir().unwrap();
631 let _guard =
632 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
633 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
634
635 let (_, legacy_sk) = choreo_keystore::crypto::generate_keypair();
636 std::fs::write(dir.path().join("choreographr/identity.pk"), legacy_sk).unwrap();
637
638 let mut store = KnownServers::load().unwrap();
639 let stored_key: [u8; 32] = [9u8; 32];
640 store.set_unlock_key("daemon-a:9443", &stored_key).unwrap();
641
642 assert_eq!(
643 try_auto_unlock_key("daemon-a:9443"),
644 Some(stored_key.to_vec())
645 );
646 assert_eq!(
648 try_auto_unlock_key("daemon-b:9443"),
649 Some(legacy_sk.to_vec())
650 );
651 }
652
653 #[test]
656 fn resolve_raw_prefers_stored_then_legacy() {
657 let dir = tempfile::tempdir().unwrap();
658 let _guard =
659 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
660 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
661
662 let (_, legacy_sk) = choreo_keystore::crypto::generate_keypair();
663 std::fs::write(dir.path().join("choreographr/identity.pk"), legacy_sk).unwrap();
664
665 assert_eq!(
667 resolve_private_key(&UnlockMethod::Raw, "d:1").unwrap(),
668 legacy_sk.to_vec()
669 );
670
671 let stored: [u8; 32] = [7u8; 32];
673 let mut store = KnownServers::load().unwrap();
674 store.set_unlock_key("d:1", &stored).unwrap();
675 assert_eq!(
676 resolve_private_key(&UnlockMethod::Raw, "d:1").unwrap(),
677 stored.to_vec()
678 );
679 }
680
681 #[test]
686 fn resolve_key_does_not_record_supplied_key_into_store() {
687 let dir = tempfile::tempdir().unwrap();
688 let _guard =
689 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
690 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
691
692 let key: [u8; 32] = [21u8; 32];
693 let b64 = base64::engine::general_purpose::STANDARD.encode(key);
694 assert_eq!(
695 resolve_private_key(&UnlockMethod::Key(b64), "d:1").unwrap(),
696 key.to_vec()
697 );
698 let store = KnownServers::load().unwrap();
701 assert_eq!(store.unlock_key("d:1").unwrap(), None);
702 }
703
704 #[test]
707 fn resolve_key_rejects_bad_input() {
708 let dir = tempfile::tempdir().unwrap();
709 let _guard =
710 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
711 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
712
713 assert!(resolve_private_key(&UnlockMethod::Key("not base64!!!".into()), "d:1").is_err());
714 let short = base64::engine::general_purpose::STANDARD.encode([1u8; 16]);
715 assert!(resolve_private_key(&UnlockMethod::Key(short), "d:1").is_err());
716 assert_eq!(
718 KnownServers::load().unwrap().unlock_key("d:1").unwrap(),
719 None
720 );
721 }
722
723 #[test]
726 fn resolve_raw_without_any_key_is_a_clear_error() {
727 let dir = tempfile::tempdir().unwrap();
728 let _guard =
729 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
730 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
731
732 assert!(matches!(
733 resolve_private_key(&UnlockMethod::Raw, "d:1"),
734 Err(ClientError::NoUnlockKey(_))
735 ));
736 }
737
738 fn msg_unlock_key(msg: &ClientMessage) -> Vec<u8> {
743 match msg {
744 ClientMessage::AddCredential { unlock_key, .. } => unlock_key.clone(),
745 other => panic!("expected AddCredential, got {other:?}"),
746 }
747 }
748
749 #[test]
750 fn build_add_credential_uses_stored_key_first() {
751 let dir = tempfile::tempdir().unwrap();
752 let _guard =
753 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
754 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
755
756 let stored: [u8; 32] = [4u8; 32];
757 let mut store = KnownServers::load().unwrap();
758 store.set_unlock_key("d:1", &stored).unwrap();
759
760 let (msg, key) =
761 build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()])
762 .unwrap();
763 assert_eq!(key, stored.to_vec());
764 assert_eq!(msg_unlock_key(&msg), stored.to_vec());
765 }
766
767 #[test]
768 fn build_add_credential_falls_back_to_legacy_key() {
769 let dir = tempfile::tempdir().unwrap();
770 let _guard =
771 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
772 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
773
774 let (_, sk) = choreo_keystore::crypto::generate_keypair();
775 std::fs::write(dir.path().join("choreographr/identity.pk"), sk).unwrap();
776
777 let (_msg, key) =
778 build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()])
779 .unwrap();
780 assert_eq!(key, sk.to_vec());
781 }
782
783 #[test]
787 fn build_add_credential_without_any_key_is_a_clear_error() {
788 let dir = tempfile::tempdir().unwrap();
789 let _guard =
790 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
791 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
792
793 assert!(matches!(
794 build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()]),
795 Err(ClientError::NoUnlockKey(_))
796 ));
797 assert_eq!(
799 KnownServers::load().unwrap().unlock_key("d:1").unwrap(),
800 None
801 );
802 }
803
804 #[test]
808 fn build_add_credential_blob_decrypts_with_stored_key() {
809 let dir = tempfile::tempdir().unwrap();
810 let _guard =
811 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
812 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
813
814 let stored: [u8; 32] = [4u8; 32];
815 let mut store = KnownServers::load().unwrap();
816 store.set_unlock_key("d:1", &stored).unwrap();
817
818 let (msg, key) =
819 build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()])
820 .unwrap();
821 assert_eq!(key, stored.to_vec());
822 let ClientMessage::AddCredential {
823 service,
824 encrypted_payload,
825 ..
826 } = &msg
827 else {
828 panic!("expected AddCredential");
829 };
830 assert_eq!(service, "svc");
831 let plaintext =
832 choreo_keystore::crypto::decrypt_with_private_key(&stored, encrypted_payload)
833 .expect("blob must decrypt with the stored unlock key");
834 let cred: ServiceCredential = postcard::from_bytes(&plaintext).unwrap();
835 assert!(matches!(cred, ServiceCredential::ApiKey { ref key, .. } if key == "k"));
836 }
837
838 #[test]
843 fn bind_fresh_daemon_mints_fresh_key_records_pre_send_and_returns_message() {
844 let dir = tempfile::tempdir().unwrap();
845 let _guard =
846 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
847 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
848
849 let stored: [u8; 32] = [30u8; 32];
851 let mut store = KnownServers::load().unwrap();
852 store.set_unlock_key("d:1", &stored).unwrap();
853 let (_, legacy_sk) = choreo_keystore::crypto::generate_keypair();
854 std::fs::write(dir.path().join("choreographr/identity.pk"), legacy_sk).unwrap();
855
856 let (key, msg) = bind_fresh_daemon("d:1").unwrap();
857 assert_ne!(key, stored, "bind must NEVER reuse a stored key");
858 assert_ne!(key, legacy_sk, "bind must NEVER reuse the legacy key");
859 match &msg {
860 ClientMessage::BindKeystore { key: wire_key } => {
861 assert_eq!(wire_key, &key.to_vec(), "message carries the minted key");
862 }
863 other => panic!("expected BindKeystore, got {other:?}"),
864 }
865 let recorded = KnownServers::load().unwrap().unlock_key("d:1").unwrap();
867 assert_eq!(recorded, Some(key));
868
869 record_unlock_key("d:1", &key).unwrap();
872 assert_eq!(
873 KnownServers::load().unwrap().unlock_key("d:1").unwrap(),
874 Some(key)
875 );
876 }
877
878 #[test]
881 fn bind_fresh_daemon_always_mints_a_new_key() {
882 let dir = tempfile::tempdir().unwrap();
883 let _guard =
884 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
885 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
886
887 let (k1, _) = bind_fresh_daemon("d:1").unwrap();
888 let (k2, _) = bind_fresh_daemon("d:1").unwrap();
889 assert_ne!(k1, k2, "each bind must mint a fresh key");
890 }
891
892 #[test]
895 fn record_unlock_key_persists_to_known_servers() {
896 let dir = tempfile::tempdir().unwrap();
897 let _guard =
898 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
899 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
900
901 let key: [u8; 32] = [11u8; 32];
902 record_unlock_key("unix:///run/choreo.sock", &key).unwrap();
903
904 let store = KnownServers::load().unwrap();
907 assert_eq!(
908 store.unlock_key("unix:///run/choreo.sock").unwrap(),
909 Some(key)
910 );
911 let entry = store
912 .entries()
913 .iter()
914 .find(|e| e.addr == "unix:///run/choreo.sock")
915 .unwrap();
916 assert!(entry.pubkey.is_none(), "carrier entry must have no pin");
917 }
918
919 #[test]
922 fn record_unlock_key_never_touches_legacy_files() {
923 let dir = tempfile::tempdir().unwrap();
924 let _guard =
925 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
926 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
927
928 let (_, sk) = choreo_keystore::crypto::generate_keypair();
929 std::fs::write(dir.path().join("choreographr/identity.pk"), sk).unwrap();
930
931 let other: [u8; 32] = [12u8; 32];
932 record_unlock_key("d:1", &other).unwrap();
933
934 assert!(dir.path().join("choreographr/identity.pk").exists());
936 let store = KnownServers::load().unwrap();
937 assert_eq!(store.unlock_key("d:1").unwrap(), Some(other));
938 }
939
940 #[test]
941 fn record_unlock_key_rejects_non_32_byte_key() {
942 let dir = tempfile::tempdir().unwrap();
943 let _guard =
944 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
945 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
946
947 assert!(record_unlock_key("d:1", b"short").is_err());
948 }
949
950 #[test]
953 fn auto_bind_first_call_binds_and_records() {
954 let dir = tempfile::tempdir().unwrap();
955 let _guard =
956 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
957 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
958
959 let mut bind = KeystoreAutoBind::new();
960 assert!(!bind.attempted());
961 let (key, msg) = bind
962 .on_unbound("bind-test:1")
963 .unwrap()
964 .expect("first call binds");
965 assert!(bind.attempted(), "the latch is set after the first report");
966
967 let ClientMessage::BindKeystore { key: sent } = msg else {
970 panic!("auto-bind must produce BindKeystore");
971 };
972 assert_eq!(sent, key.to_vec());
973 let store = KnownServers::load().unwrap();
974 assert_eq!(store.unlock_key("bind-test:1").unwrap(), Some(key));
975 }
976
977 #[test]
978 fn auto_bind_second_call_never_rebinds() {
979 let dir = tempfile::tempdir().unwrap();
980 let _guard =
981 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
982 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
983
984 let mut bind = KeystoreAutoBind::new();
985 let (first_key, _) = bind
986 .on_unbound("bind-test:2")
987 .unwrap()
988 .expect("first call binds");
989
990 assert!(bind.on_unbound("bind-test:2").unwrap().is_none());
993 let store = KnownServers::load().unwrap();
994 assert_eq!(
995 store.unlock_key("bind-test:2").unwrap(),
996 Some(first_key),
997 "the recorded key is never replaced by a second report"
998 );
999 }
1000
1001 #[test]
1002 fn auto_bind_store_failure_propagates_and_latches() {
1003 let dir = tempfile::tempdir().unwrap();
1007 let blocker = dir.path().join("not-a-dir");
1008 std::fs::write(&blocker, b"blocker").unwrap();
1009 let _guard = choreo_keystore::paths::TestConfigGuard::set_root(Some(blocker.clone()));
1010
1011 let mut bind = KeystoreAutoBind::new();
1012 let _err = bind.on_unbound("bind-fail:1").unwrap_err();
1014 assert!(bind.attempted());
1016 assert!(bind.on_unbound("bind-fail:1").unwrap().is_none());
1017 }
1018
1019 #[test]
1025 fn attempt_keystore_auto_bind_maps_the_three_outcomes() {
1026 let dir = tempfile::tempdir().unwrap();
1027 let _guard =
1028 choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
1029 std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
1030
1031 let mut bind = KeystoreAutoBind::new();
1032 let first = attempt_keystore_auto_bind(&mut bind, "attempt-test:1");
1033 let AutoBindAttempt::Bind {
1034 key,
1035 msg: ClientMessage::BindKeystore { key: wire_key },
1036 } = &first
1037 else {
1038 panic!("first attempt must bind, got {first:?}");
1039 };
1040 assert_eq!(
1041 wire_key,
1042 &key.to_vec(),
1043 "the message carries the minted key"
1044 );
1045 let recorded = KnownServers::load()
1049 .unwrap()
1050 .unlock_key("attempt-test:1")
1051 .unwrap();
1052 assert_eq!(recorded, Some(*key));
1053
1054 let second = attempt_keystore_auto_bind(&mut bind, "attempt-test:1");
1057 assert!(
1058 matches!(second, AutoBindAttempt::Suppressed),
1059 "the bind-loop guard must suppress, got {second:?}"
1060 );
1061
1062 let blocker = dir.path().join("not-a-dir");
1066 std::fs::write(&blocker, b"blocker").unwrap();
1067 let _guard = choreo_keystore::paths::TestConfigGuard::set_root(Some(blocker.clone()));
1068 let mut bind = KeystoreAutoBind::new();
1069 let failed = attempt_keystore_auto_bind(&mut bind, "attempt-fail:1");
1070 let AutoBindAttempt::Failed { error } = &failed else {
1071 panic!("refused store must surface as Failed, got {failed:?}");
1072 };
1073 assert!(
1074 !error.to_string().is_empty(),
1075 "the failure is surfaced with context"
1076 );
1077 assert!(bind.attempted(), "the failed attempt consumed the latch");
1078 }
1079}