1use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
8use std::sync::{Arc, Mutex};
9
10use crate::{
11 error::SsoError,
12 session::PairingResult,
13 state::SsoState,
14 traits::{
15 NoopProductKeyStore, PersistedSessionMeta, SsoEventSink, SsoProductKeyStore,
16 SsoSessionStore, SsoSigner, SsoTransport,
17 },
18};
19
20#[cfg(not(target_arch = "wasm32"))]
21use crate::presence;
22#[cfg(not(target_arch = "wasm32"))]
23use crate::product_key_cache::ProductKeyCache;
24
25#[cfg(not(target_arch = "wasm32"))]
31pub const PRODUCT_KEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
32
33#[cfg(not(target_arch = "wasm32"))]
42struct SignMaterial {
43 session_key: zeroize::Zeroizing<[u8; 16]>,
45 identity_pubkey: [u8; 32],
47 p256_pubkey: [u8; 65],
49}
50
51pub struct SsoManager<T, Si, St, E, Pk = NoopProductKeyStore>
66where
67 T: SsoTransport + 'static,
68 Si: SsoSigner,
69 St: SsoSessionStore + 'static,
70 E: SsoEventSink + 'static,
71 Pk: SsoProductKeyStore + 'static,
72{
73 state: Arc<Mutex<SsoState>>,
75 transport: Arc<T>,
77 signer: Si,
78 store: Arc<St>,
80 sink: Arc<E>,
82 product_key_store: Arc<Pk>,
84 metadata_url: String,
86 pairing_in_progress: Arc<AtomicBool>,
88 #[cfg(not(target_arch = "wasm32"))]
92 pairing_generation: Arc<std::sync::atomic::AtomicU64>,
93 #[cfg(not(target_arch = "wasm32"))]
95 pairing_cancel: Mutex<Option<Arc<AtomicBool>>>,
96 #[cfg(not(target_arch = "wasm32"))]
99 sign_material: Mutex<Option<SignMaterial>>,
100 #[cfg(not(target_arch = "wasm32"))]
103 product_key_cache: Mutex<Option<ProductKeyCache>>,
104 #[cfg(not(target_arch = "wasm32"))]
107 presence_cancel: Mutex<Option<Arc<AtomicBool>>>,
108 #[cfg(not(target_arch = "wasm32"))]
111 last_heartbeat_ts: Arc<AtomicI64>,
112}
113
114impl<T, Si, St, E> SsoManager<T, Si, St, E, NoopProductKeyStore>
119where
120 T: SsoTransport + 'static,
121 Si: SsoSigner,
122 St: SsoSessionStore + 'static,
123 E: SsoEventSink + 'static,
124{
125 pub fn new(transport: T, signer: Si, store: St, sink: E, metadata_url: String) -> Self {
131 Self::with_product_key_store(
132 transport,
133 signer,
134 store,
135 sink,
136 NoopProductKeyStore,
137 metadata_url,
138 )
139 }
140}
141
142impl<T, Si, St, E, Pk> SsoManager<T, Si, St, E, Pk>
147where
148 T: SsoTransport + 'static,
149 Si: SsoSigner,
150 St: SsoSessionStore + 'static,
151 E: SsoEventSink + 'static,
152 Pk: SsoProductKeyStore + 'static,
153{
154 pub fn with_product_key_store(
158 transport: T,
159 signer: Si,
160 store: St,
161 sink: E,
162 product_key_store: Pk,
163 metadata_url: String,
164 ) -> Self {
165 Self {
166 state: Arc::new(Mutex::new(SsoState::Idle)),
167 transport: Arc::new(transport),
168 signer,
169 store: Arc::new(store),
170 sink: Arc::new(sink),
171 product_key_store: Arc::new(product_key_store),
172 metadata_url,
173 pairing_in_progress: Arc::new(AtomicBool::new(false)),
174 #[cfg(not(target_arch = "wasm32"))]
175 pairing_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
176 #[cfg(not(target_arch = "wasm32"))]
177 pairing_cancel: Mutex::new(None),
178 #[cfg(not(target_arch = "wasm32"))]
179 sign_material: Mutex::new(None),
180 #[cfg(not(target_arch = "wasm32"))]
181 product_key_cache: Mutex::new(None),
182 #[cfg(not(target_arch = "wasm32"))]
183 presence_cancel: Mutex::new(None),
184 #[cfg(not(target_arch = "wasm32"))]
185 last_heartbeat_ts: Arc::new(AtomicI64::new(0)),
186 }
187 }
188}
189
190impl<T, Si, St, E, Pk> SsoManager<T, Si, St, E, Pk>
195where
196 T: SsoTransport + 'static,
197 Si: SsoSigner,
198 St: SsoSessionStore + 'static,
199 E: SsoEventSink + 'static,
200 Pk: SsoProductKeyStore + 'static,
201{
202 pub fn state(&self) -> SsoState {
204 self.state.lock().unwrap_or_else(|e| e.into_inner()).clone()
205 }
206
207 #[cfg(not(target_arch = "wasm32"))]
214 pub fn pair(&self) -> Result<(), SsoError> {
215 if self
217 .pairing_in_progress
218 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
219 .is_err()
220 {
221 return Err(SsoError::PairingAlreadyInProgress);
222 }
223
224 let identity_pubkey = self
225 .signer
226 .public_key()
227 .map_err(|e| SsoError::SignerError(e.to_string()))?;
228 let metadata_url = self.metadata_url.clone();
229
230 let transport_sub = Arc::clone(&self.transport);
233 let transport_unsub = Arc::clone(&self.transport);
234
235 let config = host_wallet::pairing::PairingConfig {
236 subscribe: Box::new(move |topic| transport_sub.subscribe(topic)),
237 unsubscribe: Box::new(move |id| transport_unsub.unsubscribe(id)),
238 identity_pubkey,
239 metadata_url,
240 };
241
242 let session = host_wallet::pairing::start_pairing(config);
245
246 *self
248 .pairing_cancel
249 .lock()
250 .unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&session.cancel));
251
252 let generation = self.pairing_generation.fetch_add(1, Ordering::AcqRel) + 1;
254
255 let state_arc = Arc::clone(&self.state);
257 let sink_arc = Arc::clone(&self.sink);
258 let in_progress = Arc::clone(&self.pairing_in_progress);
259 let gen_arc = Arc::clone(&self.pairing_generation);
260 let state_rx = session.state_rx;
261
262 log::debug!("host-sso: pair() called — pairing thread started (gen={generation})");
263
264 std::thread::spawn(move || {
266 poll_pairing_states(
267 state_rx,
268 state_arc,
269 sink_arc,
270 in_progress,
271 gen_arc,
272 generation,
273 );
274 });
275
276 Ok(())
277 }
278
279 pub fn unpair(&self) -> Result<(), SsoError> {
284 #[cfg(not(target_arch = "wasm32"))]
286 if let Some(cancel) = self
287 .pairing_cancel
288 .lock()
289 .unwrap_or_else(|e| e.into_inner())
290 .take()
291 {
292 cancel.store(true, Ordering::Release);
293 }
294
295 #[cfg(not(target_arch = "wasm32"))]
297 {
298 *self.sign_material.lock().unwrap_or_else(|e| e.into_inner()) = None;
299 }
300
301 #[cfg(not(target_arch = "wasm32"))]
303 {
304 *self
305 .product_key_cache
306 .lock()
307 .unwrap_or_else(|e| e.into_inner()) = None;
308 }
309 self.product_key_store.delete().map_err(SsoError::Store)?;
310
311 #[cfg(not(target_arch = "wasm32"))]
313 {
314 if let Some(cancel) = self
315 .presence_cancel
316 .lock()
317 .unwrap_or_else(|e| e.into_inner())
318 .take()
319 {
320 cancel.store(true, Ordering::Release);
321 }
322 self.last_heartbeat_ts.store(0, Ordering::Release);
323 }
324
325 self.store.clear().map_err(SsoError::Store)?;
326 self.pairing_in_progress.store(false, Ordering::Release);
327 self.transition(&self.state, &self.sink, SsoState::Idle);
328 log::debug!("host-sso: unpaired — session cleared");
329 Ok(())
330 }
331
332 pub fn restore_session(&self) -> Result<(), SsoError> {
337 match self.store.load().map_err(SsoError::Store)? {
338 Some(meta) => {
339 log::debug!("host-sso: restored session for address={}", meta.address);
340 self.transition(
341 &self.state,
342 &self.sink,
343 SsoState::Paired {
344 address: meta.address,
345 display_name: meta.display_name,
346 phone_online: false,
347 },
348 );
349 Ok(())
350 }
351 None => {
352 log::debug!("host-sso: no persisted session found");
353 Ok(())
354 }
355 }
356 }
357
358 pub fn handle_pairing_result(&self, result: PairingResult) -> Result<(), SsoError> {
366 #[cfg(not(target_arch = "wasm32"))]
368 {
369 let material = SignMaterial {
370 session_key: zeroize::Zeroizing::new(*result.session_key),
371 identity_pubkey: result.identity_pubkey,
372 p256_pubkey: result.p256_pubkey,
373 };
374 *self.sign_material.lock().unwrap_or_else(|e| e.into_inner()) = Some(material);
375 }
376
377 #[cfg(not(target_arch = "wasm32"))]
379 if let Some(phone_pubkey) = result.phone_wallet_pubkey {
380 self.init_product_key_cache(phone_pubkey);
381 }
382
383 let phone_wallet_pubkey_hex = result.phone_wallet_pubkey.map(|pk| {
384 pk.iter().fold(String::with_capacity(64), |mut s, b| {
385 use std::fmt::Write as _;
386 let _ = write!(s, "{b:02x}");
387 s
388 })
389 });
390
391 handle_established(EstablishedCtx {
392 address: result.address,
393 display_name: result.display_name,
394 identity_pubkey: result.identity_pubkey,
395 p256_pubkey: result.p256_pubkey,
396 phone_wallet_pubkey_hex,
397 store: &self.store,
398 in_progress: &self.pairing_in_progress,
399 state: &self.state,
400 sink: &self.sink,
401 })
402 }
403
404 #[cfg(not(target_arch = "wasm32"))]
412 pub fn request_sign(&self, payload: &[u8]) -> Result<Vec<u8>, SsoError> {
413 match self.state() {
415 SsoState::Paired { .. } => {}
416 _ => return Err(SsoError::NotPaired),
417 }
418
419 let (session_key, identity_pubkey, p256_pubkey) = {
424 let guard = self.sign_material.lock().unwrap_or_else(|e| e.into_inner());
425 match guard.as_ref() {
426 Some(m) => (
427 zeroize::Zeroizing::new(*m.session_key),
428 m.identity_pubkey,
429 m.p256_pubkey,
430 ),
431 None => return Err(SsoError::NotPaired),
432 }
433 };
434
435 let transport_write = Arc::clone(&self.transport);
436 let transport_sub = Arc::clone(&self.transport);
437 let transport_unsub = Arc::clone(&self.transport);
438
439 let config = host_wallet::pairing::PairedSignConfig {
440 write: Box::new(move |topic, value| transport_write.write(topic, value)),
441 subscribe: Box::new(move |topic| transport_sub.subscribe(topic)),
442 unsubscribe: Box::new(move |id| transport_unsub.unsubscribe(id)),
443 };
444
445 let (result_tx, result_rx) = std::sync::mpsc::sync_channel(1);
446
447 host_wallet::pairing::sign_via_paired(
448 payload.to_vec(),
449 *session_key,
450 identity_pubkey,
451 p256_pubkey,
452 config,
453 result_tx,
454 );
455
456 result_rx
457 .recv()
458 .map_err(|_| SsoError::SignFailed("result channel closed".to_string()))?
459 .map_err(SsoError::SignFailed)
460 }
461
462 #[cfg(not(target_arch = "wasm32"))]
477 pub fn request_product_key(&self, product_id: &str, index: u32) -> Result<[u8; 32], SsoError> {
478 match self.state() {
480 SsoState::Paired { .. } => {}
481 _ => return Err(SsoError::NotPaired),
482 }
483
484 let phone_pubkey = self.load_product_key_prereqs()?;
486 if let Some(cached) = self.cache_get(phone_pubkey, product_id, index) {
487 return Ok(cached);
488 }
489
490 if !self.is_phone_online() {
493 return Err(SsoError::PhoneOffline);
494 }
495
496 let pubkey = self.send_product_key_request(phone_pubkey, product_id, index)?;
498
499 self.cache_insert_and_persist(phone_pubkey, product_id, index, pubkey);
501
502 Ok(pubkey)
503 }
504
505 fn transition(&self, state: &Arc<Mutex<SsoState>>, sink: &Arc<E>, new_state: SsoState) {
511 transition_state(state, sink, new_state);
512 }
513
514 #[cfg(not(target_arch = "wasm32"))]
517 fn load_product_key_prereqs(&self) -> Result<[u8; 32], SsoError> {
518 let meta = self
519 .store
520 .load()
521 .map_err(SsoError::Store)?
522 .ok_or(SsoError::NotPaired)?;
523
524 if !meta.capabilities.iter().any(|c| c == "product_key") {
525 return Err(SsoError::ProductKeyCapabilityAbsent);
526 }
527
528 let hex = meta.phone_wallet_pubkey_hex.ok_or(SsoError::NotPaired)?;
529 let bytes =
530 hex::decode(&hex).map_err(|e| SsoError::Store(format!("bad pubkey hex: {e}")))?;
531 bytes
532 .try_into()
533 .map_err(|_| SsoError::Store("phone_wallet_pubkey_hex is not 32 bytes".to_string()))
534 }
535
536 #[cfg(not(target_arch = "wasm32"))]
538 fn cache_get(&self, phone_pubkey: [u8; 32], product_id: &str, index: u32) -> Option<[u8; 32]> {
539 let mut guard = self
540 .product_key_cache
541 .lock()
542 .unwrap_or_else(|e| e.into_inner());
543 guard
544 .as_mut()
545 .and_then(|cache| cache.get(&phone_pubkey, product_id, index))
546 }
547
548 #[cfg(not(target_arch = "wasm32"))]
550 fn cache_insert_and_persist(
551 &self,
552 phone_pubkey: [u8; 32],
553 product_id: &str,
554 index: u32,
555 pubkey: [u8; 32],
556 ) {
557 let mut guard = self
558 .product_key_cache
559 .lock()
560 .unwrap_or_else(|e| e.into_inner());
561 if let Some(cache) = guard.as_mut() {
562 if let Err(e) = cache.insert(&phone_pubkey, product_id, index, pubkey) {
563 log::warn!("host-sso: product key cache insert skipped: {e}");
565 return;
566 }
567 let entries = cache.to_entries();
568 drop(guard); if let Err(e) = self.product_key_store.save(&phone_pubkey, &entries) {
570 log::warn!("host-sso: product key cache persist failed: {e}");
571 }
572 }
573 }
574
575 #[cfg(not(target_arch = "wasm32"))]
579 fn init_product_key_cache(&self, phone_identity: [u8; 32]) {
580 let mut cache = ProductKeyCache::new(phone_identity);
581
582 match self.product_key_store.load() {
584 Ok(Some((stored_identity, entries))) => {
585 cache.load_from(&stored_identity, &entries);
586 }
587 Ok(None) => {}
588 Err(e) => {
589 log::warn!("host-sso: failed to load persisted product key cache: {e}");
590 }
591 }
592
593 *self
594 .product_key_cache
595 .lock()
596 .unwrap_or_else(|e| e.into_inner()) = Some(cache);
597 }
598
599 #[cfg(not(target_arch = "wasm32"))]
601 fn send_product_key_request(
602 &self,
603 phone_pubkey: [u8; 32],
604 product_id: &str,
605 index: u32,
606 ) -> Result<[u8; 32], SsoError> {
607 let (session_key, identity_pubkey, p256_pubkey) = self.extract_sign_material()?;
608
609 let message_id = derive_message_id(&phone_pubkey, product_id, index);
610 let req = crate::product_key::ProductKeyRequest {
611 message_id,
612 product_id: product_id.to_string(),
613 index,
614 };
615 let req_bytes = req.encode().map_err(SsoError::SignFailed)?;
616
617 let transport_write = Arc::clone(&self.transport);
618 let transport_sub = Arc::clone(&self.transport);
619 let transport_unsub = Arc::clone(&self.transport);
620
621 let config = host_wallet::pairing::PairedSignConfig {
622 write: Box::new(move |topic, value| transport_write.write(topic, value)),
623 subscribe: Box::new(move |topic| transport_sub.subscribe(topic)),
624 unsubscribe: Box::new(move |id| transport_unsub.unsubscribe(id)),
625 };
626
627 let (result_tx, result_rx) = std::sync::mpsc::sync_channel::<Result<Vec<u8>, String>>(1);
628
629 host_wallet::pairing::sign_via_paired(
633 req_bytes,
634 *session_key,
635 identity_pubkey,
636 p256_pubkey,
637 config,
638 result_tx,
639 );
640
641 let raw = result_rx
642 .recv_timeout(PRODUCT_KEY_TIMEOUT)
643 .map_err(|_| SsoError::ProductKeyTimeout)?
644 .map_err(|e| {
645 if e.contains("timed out") {
646 SsoError::ProductKeyTimeout
647 } else {
648 SsoError::SignFailed(e)
649 }
650 })?;
651
652 let response = crate::product_key::ProductKeyResponse::decode(&raw)
653 .map_err(|e| SsoError::SignFailed(format!("ProductKeyResponse decode failed: {e}")))?;
654
655 match response.payload {
656 crate::product_key::ProductKeyPayload::Ok(pubkey) => Ok(pubkey),
657 crate::product_key::ProductKeyPayload::Unauthorized(reason) => {
658 Err(SsoError::ProductKeyRejected(reason))
659 }
660 crate::product_key::ProductKeyPayload::DerivationError(reason) => {
661 Err(SsoError::ProductKeyRejected(reason))
662 }
663 }
664 }
665
666 #[cfg(not(target_arch = "wasm32"))]
668 #[allow(clippy::type_complexity)]
669 fn extract_sign_material(
670 &self,
671 ) -> Result<(zeroize::Zeroizing<[u8; 16]>, [u8; 32], [u8; 65]), SsoError> {
672 let guard = self.sign_material.lock().unwrap_or_else(|e| e.into_inner());
673 match guard.as_ref() {
674 Some(m) => Ok((
675 zeroize::Zeroizing::new(*m.session_key),
676 m.identity_pubkey,
677 m.p256_pubkey,
678 )),
679 None => Err(SsoError::NotPaired),
680 }
681 }
682
683 #[cfg(not(target_arch = "wasm32"))]
688 pub fn is_phone_online(&self) -> bool {
689 presence::is_phone_online(&self.last_heartbeat_ts)
690 }
691
692 #[cfg(not(target_arch = "wasm32"))]
696 pub fn cache_remove_and_persist(&self, product_id: &str, index: u32) {
697 let (identity, entries) = {
698 let mut guard = self
699 .product_key_cache
700 .lock()
701 .unwrap_or_else(|e| e.into_inner());
702 let cache = match guard.as_mut() {
703 Some(c) => c,
704 None => return,
705 };
706 cache.remove(product_id, index);
707 (*cache.identity(), cache.to_entries())
708 };
709 if let Err(e) = self.product_key_store.save(&identity, &entries) {
710 log::warn!("host-sso: product key cache persist failed after revocation: {e}");
711 }
712 }
713}
714
715struct EstablishedCtx<'a, St, E>
722where
723 St: SsoSessionStore,
724 E: SsoEventSink,
725{
726 address: String,
727 display_name: String,
728 identity_pubkey: [u8; 32],
729 p256_pubkey: [u8; 65],
730 phone_wallet_pubkey_hex: Option<String>,
731 store: &'a Arc<St>,
732 in_progress: &'a Arc<AtomicBool>,
733 state: &'a Arc<Mutex<SsoState>>,
734 sink: &'a Arc<E>,
735}
736
737fn handle_established<St, E>(ctx: EstablishedCtx<'_, St, E>) -> Result<(), SsoError>
741where
742 St: SsoSessionStore,
743 E: SsoEventSink,
744{
745 let p256_pubkey_hex = hex_encode_p256(&ctx.p256_pubkey);
746 let session_id = derive_session_id(&ctx.identity_pubkey, &ctx.p256_pubkey);
747
748 let meta = PersistedSessionMeta {
749 session_id,
750 address: ctx.address.clone(),
751 display_name: ctx.display_name.clone(),
752 p256_pubkey_hex,
753 phone_wallet_pubkey_hex: ctx.phone_wallet_pubkey_hex,
754 capabilities: Vec::new(),
759 };
760
761 ctx.store.save(&meta).map_err(SsoError::Store)?;
762 ctx.in_progress.store(false, Ordering::Release);
763
764 log::debug!("host-sso: pairing complete for address={}", ctx.address);
765 transition_state(
766 ctx.state,
767 ctx.sink,
768 SsoState::Paired {
769 address: ctx.address,
770 display_name: ctx.display_name,
771 phone_online: true,
772 },
773 );
774 Ok(())
775}
776
777fn transition_state<E>(state: &Arc<Mutex<SsoState>>, sink: &Arc<E>, new_state: SsoState)
780where
781 E: SsoEventSink,
782{
783 let mut guard = state.lock().unwrap_or_else(|e| e.into_inner());
784 *guard = new_state.clone();
785 drop(guard);
786 sink.on_state_changed(&new_state);
787}
788
789#[cfg(not(target_arch = "wasm32"))]
794fn poll_pairing_states<E>(
795 state_rx: std::sync::mpsc::Receiver<host_wallet::pairing::PairingState>,
796 state: Arc<Mutex<SsoState>>,
797 sink: Arc<E>,
798 in_progress: Arc<AtomicBool>,
799 generation_counter: Arc<std::sync::atomic::AtomicU64>,
800 my_generation: u64,
801) where
802 E: SsoEventSink,
803{
804 for pairing_state in state_rx {
807 if generation_counter.load(Ordering::Acquire) != my_generation {
809 log::debug!("host-sso: stale polling thread (gen={my_generation}), exiting");
810 return;
811 }
812 match pairing_state {
813 host_wallet::pairing::PairingState::AwaitingScan { qr_uri } => {
814 log::debug!("host-sso: QR ready — transitioning to AwaitingScan");
815 transition_state(&state, &sink, SsoState::AwaitingScan { qr_uri });
816 }
817 host_wallet::pairing::PairingState::Established {
818 address,
819 display_name,
820 } => {
821 log::debug!("host-sso: pairing established for address={}", address);
822 in_progress.store(false, Ordering::Release);
828 transition_state(
829 &state,
830 &sink,
831 SsoState::Paired {
832 address,
833 display_name,
834 phone_online: true,
835 },
836 );
837 }
839 host_wallet::pairing::PairingState::Failed(reason) => {
840 log::warn!("host-sso: pairing failed: {reason}");
841 in_progress.store(false, Ordering::Release);
842 transition_state(&state, &sink, SsoState::Failed { reason });
843 }
844 }
845 }
846}
847
848fn hex_encode_p256(pubkey: &[u8; 65]) -> String {
850 pubkey.iter().fold(String::with_capacity(130), |mut s, b| {
851 use std::fmt::Write as _;
852 let _ = write!(s, "{b:02x}");
853 s
854 })
855}
856
857fn derive_session_id(identity_pubkey: &[u8; 32], p256_pubkey: &[u8; 65]) -> String {
863 let mut id = String::with_capacity(64 + 130);
864 for b in identity_pubkey {
865 use std::fmt::Write as _;
866 let _ = write!(id, "{b:02x}");
867 }
868 for b in p256_pubkey {
869 use std::fmt::Write as _;
870 let _ = write!(id, "{b:02x}");
871 }
872 id
873}
874
875#[cfg(not(target_arch = "wasm32"))]
880fn derive_message_id(phone_pubkey: &[u8; 32], product_id: &str, index: u32) -> String {
881 let mut id = String::new();
884 for b in phone_pubkey.iter().take(8) {
885 use std::fmt::Write as _;
886 let _ = write!(id, "{b:02x}");
887 }
888 let _ = std::fmt::Write::write_fmt(&mut id, format_args!("-{product_id}-{index}"));
889 id
890}
891
892#[cfg(test)]
897mod tests {
898 use super::*;
899 use crate::traits::{PersistedSessionMeta, SsoProductKeyStore};
900 use std::sync::{Arc, Mutex};
901
902 struct NoopTransport;
905
906 impl SsoTransport for NoopTransport {
907 fn subscribe(
908 &self,
909 _topic_hex: &str,
910 ) -> Result<(u64, std::sync::mpsc::Receiver<(String, String)>), String> {
911 let (_tx, rx) = std::sync::mpsc::channel();
912 Ok((0, rx))
913 }
914
915 fn unsubscribe(&self, _id: u64) {}
916
917 fn write(&self, _topic_hex: &str, _value: &str) -> Result<(), String> {
918 Ok(())
919 }
920 }
921
922 struct StubSigner {
923 pubkey: [u8; 32],
924 }
925
926 impl host_wallet::HostSigner for StubSigner {
927 fn public_key(&self) -> Result<[u8; 32], host_wallet::SignerError> {
928 Ok(self.pubkey)
929 }
930
931 fn sign(&self, _payload: &[u8]) -> Result<[u8; 64], host_wallet::SignerError> {
932 Ok([0u8; 64])
933 }
934 }
935
936 #[derive(Default)]
937 struct MemoryStore {
938 data: Mutex<Option<PersistedSessionMeta>>,
939 }
940
941 impl SsoSessionStore for MemoryStore {
942 fn save(&self, session: &PersistedSessionMeta) -> Result<(), String> {
943 *self.data.lock().unwrap_or_else(|e| e.into_inner()) = Some(session.clone());
944 Ok(())
945 }
946
947 fn load(&self) -> Result<Option<PersistedSessionMeta>, String> {
948 Ok(self.data.lock().unwrap_or_else(|e| e.into_inner()).clone())
949 }
950
951 fn clear(&self) -> Result<(), String> {
952 *self.data.lock().unwrap_or_else(|e| e.into_inner()) = None;
953 Ok(())
954 }
955 }
956
957 #[derive(Default)]
958 struct RecordingSink {
959 states: Arc<Mutex<Vec<SsoState>>>,
960 }
961
962 impl SsoEventSink for RecordingSink {
963 fn on_state_changed(&self, state: &SsoState) {
964 self.states
965 .lock()
966 .unwrap_or_else(|e| e.into_inner())
967 .push(state.clone());
968 }
969 }
970
971 #[derive(Default)]
973 struct MemoryProductKeyStore {
974 data: Mutex<
975 Option<(
976 [u8; 32],
977 Vec<crate::product_key_cache::ProductKeyCacheEntry>,
978 )>,
979 >,
980 }
981
982 impl SsoProductKeyStore for MemoryProductKeyStore {
983 fn save(
984 &self,
985 identity: &[u8; 32],
986 entries: &[crate::product_key_cache::ProductKeyCacheEntry],
987 ) -> Result<(), String> {
988 *self.data.lock().unwrap_or_else(|e| e.into_inner()) =
989 Some((*identity, entries.to_vec()));
990 Ok(())
991 }
992
993 fn load(&self) -> crate::traits::ProductKeyStoreLoadResult {
994 Ok(self.data.lock().unwrap_or_else(|e| e.into_inner()).clone())
995 }
996
997 fn delete(&self) -> Result<(), String> {
998 *self.data.lock().unwrap_or_else(|e| e.into_inner()) = None;
999 Ok(())
1000 }
1001 }
1002
1003 fn make_manager() -> SsoManager<NoopTransport, StubSigner, MemoryStore, RecordingSink> {
1005 SsoManager::new(
1006 NoopTransport,
1007 StubSigner {
1008 pubkey: [0x01u8; 32],
1009 },
1010 MemoryStore::default(),
1011 RecordingSink::default(),
1012 "https://example.com/metadata".to_string(),
1013 )
1014 }
1015
1016 fn make_manager_with_pk_store(
1018 ) -> SsoManager<NoopTransport, StubSigner, MemoryStore, RecordingSink, MemoryProductKeyStore>
1019 {
1020 SsoManager::with_product_key_store(
1021 NoopTransport,
1022 StubSigner {
1023 pubkey: [0x01u8; 32],
1024 },
1025 MemoryStore::default(),
1026 RecordingSink::default(),
1027 MemoryProductKeyStore::default(),
1028 "https://example.com/metadata".to_string(),
1029 )
1030 }
1031
1032 fn make_pairing_result() -> PairingResult {
1033 PairingResult {
1034 address: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY".to_string(),
1035 display_name: "Alice's Phone".to_string(),
1036 session_key: zeroize::Zeroizing::new([0xABu8; 16]),
1037 identity_pubkey: [0x01u8; 32],
1038 p256_pubkey: [0x04u8; 65],
1039 phone_wallet_pubkey: None,
1040 }
1041 }
1042
1043 fn make_pairing_result_with_phone_pubkey(phone_pubkey: [u8; 32]) -> PairingResult {
1044 PairingResult {
1045 phone_wallet_pubkey: Some(phone_pubkey),
1046 ..make_pairing_result()
1047 }
1048 }
1049
1050 #[test]
1055 fn test_new_starts_in_idle_state() {
1056 let manager = make_manager();
1057 assert_eq!(manager.state(), SsoState::Idle);
1058 }
1059
1060 #[test]
1061 fn test_restore_session_transitions_to_paired() {
1062 let manager = make_manager();
1063
1064 manager
1066 .store
1067 .save(&PersistedSessionMeta {
1068 session_id: "sid".to_string(),
1069 address: "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY".to_string(),
1070 display_name: "Alice's Phone".to_string(),
1071 p256_pubkey_hex: "04".repeat(65),
1072 phone_wallet_pubkey_hex: None,
1073 capabilities: Vec::new(),
1074 })
1075 .unwrap();
1076
1077 manager.restore_session().unwrap();
1078
1079 assert!(matches!(
1080 manager.state(),
1081 SsoState::Paired { address, .. } if address == "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
1082 ));
1083
1084 let states = manager
1086 .sink
1087 .states
1088 .lock()
1089 .unwrap_or_else(|e| e.into_inner());
1090 assert!(!states.is_empty(), "sink must be notified on restore");
1091 assert!(matches!(states.last(), Some(SsoState::Paired { .. })));
1092 }
1093
1094 #[test]
1095 fn test_restore_session_stays_idle_when_no_stored_session() {
1096 let manager = make_manager();
1097 manager.restore_session().unwrap();
1098 assert_eq!(manager.state(), SsoState::Idle);
1099 }
1100
1101 #[test]
1102 fn test_unpair_clears_store_and_returns_to_idle() {
1103 let manager = make_manager();
1104
1105 manager
1107 .handle_pairing_result(make_pairing_result())
1108 .unwrap();
1109 assert!(matches!(manager.state(), SsoState::Paired { .. }));
1110
1111 manager.unpair().unwrap();
1112 assert_eq!(manager.state(), SsoState::Idle);
1113
1114 assert!(manager.store.load().unwrap().is_none());
1116 }
1117
1118 #[cfg(not(target_arch = "wasm32"))]
1119 #[test]
1120 fn test_pair_rejects_when_already_pairing() {
1121 let manager = make_manager();
1122
1123 manager.pair().unwrap();
1125
1126 let err = manager.pair().unwrap_err();
1128 assert!(
1129 matches!(err, SsoError::PairingAlreadyInProgress),
1130 "expected PairingAlreadyInProgress, got {err:?}"
1131 );
1132
1133 manager.unpair().unwrap();
1135 }
1136
1137 #[test]
1138 fn test_handle_pairing_result_persists_and_transitions() {
1139 let manager = make_manager();
1140
1141 manager
1142 .handle_pairing_result(make_pairing_result())
1143 .unwrap();
1144
1145 let state = manager.state();
1147 assert!(
1148 matches!(state, SsoState::Paired { ref address, .. } if address == "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"),
1149 "unexpected state: {state:?}"
1150 );
1151
1152 let meta = manager.store.load().unwrap();
1154 assert!(meta.is_some(), "session should be persisted");
1155 let meta = meta.unwrap();
1156 assert_eq!(
1157 meta.address,
1158 "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
1159 );
1160 assert_eq!(meta.display_name, "Alice's Phone");
1161
1162 assert!(
1164 !manager.pairing_in_progress.load(Ordering::Acquire),
1165 "pairing_in_progress should be false after success"
1166 );
1167 }
1168
1169 #[cfg(not(target_arch = "wasm32"))]
1176 #[test]
1177 fn test_pair_emits_awaiting_scan_with_real_qr_uri() {
1178 let manager = make_manager();
1179
1180 manager.pair().unwrap();
1181
1182 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1185 loop {
1186 {
1187 let states = manager
1188 .sink
1189 .states
1190 .lock()
1191 .unwrap_or_else(|e| e.into_inner());
1192 if let Some(SsoState::AwaitingScan { qr_uri }) = states.first() {
1193 assert!(
1194 qr_uri.starts_with("polkadotapp://pair?handshake="),
1195 "QR URI must use the polkadotapp scheme, got: {qr_uri}"
1196 );
1197 assert!(!qr_uri.is_empty(), "QR URI must be non-empty");
1198 return;
1199 }
1200 }
1201 if std::time::Instant::now() >= deadline {
1202 let states = manager
1203 .sink
1204 .states
1205 .lock()
1206 .unwrap_or_else(|e| e.into_inner());
1207 panic!("AwaitingScan not received within 2s; states so far: {states:?}");
1208 }
1209 std::thread::sleep(std::time::Duration::from_millis(20));
1210 }
1211 }
1212
1213 #[cfg(not(target_arch = "wasm32"))]
1216 #[test]
1217 #[ignore = "takes 120s (pairing timeout); run manually with --include-ignored"]
1218 fn test_pair_transitions_to_failed_on_timeout() {
1219 let manager = make_manager();
1220 manager.pair().unwrap();
1221
1222 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(125);
1224 loop {
1225 {
1226 let states = manager
1227 .sink
1228 .states
1229 .lock()
1230 .unwrap_or_else(|e| e.into_inner());
1231 if states.iter().any(|s| matches!(s, SsoState::Failed { .. })) {
1232 return;
1233 }
1234 }
1235 if std::time::Instant::now() >= deadline {
1236 panic!("Failed state not received within expected timeout window");
1237 }
1238 std::thread::sleep(std::time::Duration::from_millis(500));
1239 }
1240 }
1241
1242 #[cfg(not(target_arch = "wasm32"))]
1243 #[test]
1244 fn test_request_sign_returns_not_paired_when_idle() {
1245 let manager = make_manager();
1246 let err = manager.request_sign(b"payload").unwrap_err();
1247 assert!(
1248 matches!(err, SsoError::NotPaired),
1249 "expected NotPaired when idle, got {err:?}"
1250 );
1251 }
1252
1253 #[cfg(not(target_arch = "wasm32"))]
1254 #[test]
1255 fn test_request_sign_stores_and_clears_sign_material() {
1256 let manager = make_manager();
1257
1258 manager
1260 .handle_pairing_result(make_pairing_result())
1261 .unwrap();
1262 {
1263 let guard = manager
1264 .sign_material
1265 .lock()
1266 .unwrap_or_else(|e| e.into_inner());
1267 assert!(
1268 guard.is_some(),
1269 "sign_material must be populated after pairing"
1270 );
1271 }
1272
1273 manager.unpair().unwrap();
1275 {
1276 let guard = manager
1277 .sign_material
1278 .lock()
1279 .unwrap_or_else(|e| e.into_inner());
1280 assert!(
1281 guard.is_none(),
1282 "sign_material must be cleared after unpair"
1283 );
1284 }
1285 }
1286
1287 #[cfg(not(target_arch = "wasm32"))]
1288 #[test]
1289 fn test_request_sign_returns_not_paired_after_unpair() {
1290 let manager = make_manager();
1291 manager
1292 .handle_pairing_result(make_pairing_result())
1293 .unwrap();
1294 manager.unpair().unwrap();
1295
1296 let err = manager.request_sign(b"payload").unwrap_err();
1297 assert!(
1298 matches!(err, SsoError::NotPaired),
1299 "expected NotPaired after unpair, got {err:?}"
1300 );
1301 }
1302
1303 #[cfg(not(target_arch = "wasm32"))]
1306 #[test]
1307 fn test_unpair_cancels_in_progress_pairing() {
1308 let manager = make_manager();
1309
1310 manager.pair().unwrap();
1311 assert!(
1312 manager.pairing_in_progress.load(Ordering::Acquire),
1313 "pairing_in_progress should be set after pair()"
1314 );
1315
1316 manager.unpair().unwrap();
1317
1318 assert_eq!(manager.state(), SsoState::Idle);
1320 assert!(
1321 !manager.pairing_in_progress.load(Ordering::Acquire),
1322 "pairing_in_progress should be cleared after unpair()"
1323 );
1324 }
1325
1326 #[cfg(not(target_arch = "wasm32"))]
1332 #[test]
1333 fn test_request_product_key_returns_not_paired_when_idle() {
1334 let manager = make_manager();
1335 let err = manager.request_product_key("acme.dot", 0).unwrap_err();
1336 assert!(
1337 matches!(err, SsoError::NotPaired),
1338 "expected NotPaired when idle, got {err:?}"
1339 );
1340 }
1341
1342 #[cfg(not(target_arch = "wasm32"))]
1345 #[test]
1346 fn test_request_product_key_returns_capability_absent_when_unsupported() {
1347 let manager = make_manager();
1348 manager
1350 .handle_pairing_result(make_pairing_result())
1351 .unwrap();
1352 let err = manager.request_product_key("acme.dot", 0).unwrap_err();
1355 assert!(
1356 matches!(err, SsoError::ProductKeyCapabilityAbsent),
1357 "expected ProductKeyCapabilityAbsent, got {err:?}"
1358 );
1359 }
1360
1361 #[cfg(not(target_arch = "wasm32"))]
1364 #[test]
1365 fn test_request_product_key_returns_cached_on_hit() {
1366 const PHONE_PUBKEY: [u8; 32] = [0x02u8; 32];
1367 const CACHED_KEY: [u8; 32] = [0xAAu8; 32];
1368
1369 let manager = make_manager_with_pk_store();
1370
1371 let mut result = make_pairing_result_with_phone_pubkey(PHONE_PUBKEY);
1373 result.phone_wallet_pubkey = Some(PHONE_PUBKEY);
1374 manager.handle_pairing_result(result).unwrap();
1375
1376 {
1378 let mut meta = manager.store.load().unwrap().unwrap();
1379 meta.capabilities = vec!["product_key".to_string()];
1380 manager.store.save(&meta).unwrap();
1381 }
1382
1383 {
1385 let mut guard = manager
1386 .product_key_cache
1387 .lock()
1388 .unwrap_or_else(|e| e.into_inner());
1389 let cache = guard.get_or_insert_with(|| ProductKeyCache::new(PHONE_PUBKEY));
1390 cache
1391 .insert(&PHONE_PUBKEY, "acme.dot", 0, CACHED_KEY)
1392 .unwrap();
1393 }
1394
1395 let key = manager.request_product_key("acme.dot", 0).unwrap();
1399 assert_eq!(key, CACHED_KEY, "must return the cached key");
1400 }
1401
1402 #[cfg(not(target_arch = "wasm32"))]
1405 #[test]
1406 fn test_request_product_key_clears_cache_on_unpair() {
1407 const PHONE_PUBKEY: [u8; 32] = [0x03u8; 32];
1408 const CACHED_KEY: [u8; 32] = [0xBBu8; 32];
1409
1410 let manager = make_manager_with_pk_store();
1411
1412 manager
1414 .handle_pairing_result(make_pairing_result_with_phone_pubkey(PHONE_PUBKEY))
1415 .unwrap();
1416 {
1417 let mut guard = manager
1418 .product_key_cache
1419 .lock()
1420 .unwrap_or_else(|e| e.into_inner());
1421 let cache = guard.get_or_insert_with(|| ProductKeyCache::new(PHONE_PUBKEY));
1422 cache
1423 .insert(&PHONE_PUBKEY, "acme.dot", 0, CACHED_KEY)
1424 .unwrap();
1425 }
1426 manager
1428 .product_key_store
1429 .save(
1430 &PHONE_PUBKEY,
1431 &[crate::product_key_cache::ProductKeyCacheEntry {
1432 product_id: "acme.dot".to_string(),
1433 index: 0,
1434 pubkey: CACHED_KEY,
1435 }],
1436 )
1437 .unwrap();
1438 assert!(
1439 manager.product_key_store.load().unwrap().is_some(),
1440 "store must contain an entry before unpair"
1441 );
1442
1443 manager.unpair().unwrap();
1444
1445 let guard = manager
1447 .product_key_cache
1448 .lock()
1449 .unwrap_or_else(|e| e.into_inner());
1450 assert!(
1451 guard.is_none(),
1452 "product_key_cache must be None after unpair"
1453 );
1454 drop(guard);
1455
1456 assert!(
1458 manager.product_key_store.load().unwrap().is_none(),
1459 "product key store must be empty after unpair"
1460 );
1461 }
1462
1463 #[cfg(not(target_arch = "wasm32"))]
1470 #[test]
1471 fn test_is_phone_online_returns_false_initially() {
1472 let manager = make_manager();
1473 assert!(
1474 !manager.is_phone_online(),
1475 "phone must be offline before any heartbeat"
1476 );
1477 }
1478
1479 #[cfg(not(target_arch = "wasm32"))]
1482 #[test]
1483 fn test_request_product_key_short_circuits_on_phone_offline() {
1484 const PHONE_PUBKEY: [u8; 32] = [0x04u8; 32];
1485
1486 let manager = make_manager_with_pk_store();
1487
1488 manager
1490 .handle_pairing_result(make_pairing_result_with_phone_pubkey(PHONE_PUBKEY))
1491 .unwrap();
1492 {
1493 let mut meta = manager.store.load().unwrap().unwrap();
1494 meta.capabilities = vec!["product_key".to_string()];
1495 manager.store.save(&meta).unwrap();
1496 }
1497
1498 let err = manager.request_product_key("acme.dot", 0).unwrap_err();
1500 assert!(
1501 matches!(err, SsoError::PhoneOffline),
1502 "expected PhoneOffline on cache miss with no heartbeat, got {err:?}"
1503 );
1504 }
1505
1506 #[cfg(not(target_arch = "wasm32"))]
1509 #[test]
1510 fn test_cache_remove_and_persist_removes_entry() {
1511 const PHONE_PUBKEY: [u8; 32] = [0x05u8; 32];
1512 const PUBKEY_A: [u8; 32] = [0xAAu8; 32];
1513 const PUBKEY_B: [u8; 32] = [0xBBu8; 32];
1514
1515 let manager = make_manager_with_pk_store();
1516 manager
1517 .handle_pairing_result(make_pairing_result_with_phone_pubkey(PHONE_PUBKEY))
1518 .unwrap();
1519
1520 {
1522 let mut guard = manager
1523 .product_key_cache
1524 .lock()
1525 .unwrap_or_else(|e| e.into_inner());
1526 let cache = guard.get_or_insert_with(|| ProductKeyCache::new(PHONE_PUBKEY));
1527 cache
1528 .insert(&PHONE_PUBKEY, "acme.dot", 0, PUBKEY_A)
1529 .unwrap();
1530 cache.insert(&PHONE_PUBKEY, "foo.dot", 1, PUBKEY_B).unwrap();
1531 }
1532
1533 manager.cache_remove_and_persist("acme.dot", 0);
1535
1536 {
1538 let mut guard = manager
1539 .product_key_cache
1540 .lock()
1541 .unwrap_or_else(|e| e.into_inner());
1542 let result = guard
1543 .as_mut()
1544 .and_then(|c| c.get(&PHONE_PUBKEY, "acme.dot", 0));
1545 assert!(result.is_none(), "revoked entry must be absent from cache");
1546 }
1547
1548 let stored = manager.product_key_store.load().unwrap();
1550 let (_, entries) = stored.expect("store must have an entry after persist");
1551 assert!(
1552 !entries
1553 .iter()
1554 .any(|e| e.product_id == "acme.dot" && e.index == 0),
1555 "revoked entry must be absent from persistent store"
1556 );
1557 assert!(
1558 entries
1559 .iter()
1560 .any(|e| e.product_id == "foo.dot" && e.index == 1),
1561 "non-revoked entry must remain in persistent store"
1562 );
1563 }
1564
1565 #[cfg(not(target_arch = "wasm32"))]
1568 #[test]
1569 fn test_unpair_resets_heartbeat_timestamp() {
1570 let manager = make_manager();
1571
1572 manager.last_heartbeat_ts.store(
1574 std::time::SystemTime::now()
1575 .duration_since(std::time::UNIX_EPOCH)
1576 .unwrap_or_default()
1577 .as_secs() as i64,
1578 Ordering::Release,
1579 );
1580 assert!(
1581 manager.is_phone_online(),
1582 "phone must appear online after heartbeat"
1583 );
1584
1585 manager
1586 .handle_pairing_result(make_pairing_result())
1587 .unwrap();
1588 manager.unpair().unwrap();
1589
1590 assert!(
1591 !manager.is_phone_online(),
1592 "phone must be offline after unpair resets heartbeat timestamp"
1593 );
1594 }
1595
1596 #[test]
1602 fn test_initial_state_is_idle() {
1603 let manager = make_manager();
1604 assert_eq!(manager.state(), SsoState::Idle);
1605 }
1606
1607 #[test]
1610 fn test_restore_session_transitions_to_paired_issue108() {
1611 let manager = make_manager();
1612
1613 manager
1614 .store
1615 .save(&PersistedSessionMeta {
1616 session_id: "sid-108".to_string(),
1617 address: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty".to_string(),
1618 display_name: "Bob's Phone".to_string(),
1619 p256_pubkey_hex: "04".repeat(65),
1620 phone_wallet_pubkey_hex: None,
1621 capabilities: Vec::new(),
1622 })
1623 .unwrap();
1624
1625 manager.restore_session().unwrap();
1626
1627 assert!(
1628 matches!(
1629 manager.state(),
1630 SsoState::Paired { ref address, .. }
1631 if address == "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"
1632 ),
1633 "expected Paired state after restore, got {:?}",
1634 manager.state()
1635 );
1636 }
1637
1638 #[test]
1640 fn test_restore_session_stays_idle_when_no_session() {
1641 let manager = make_manager();
1642 manager.restore_session().unwrap();
1643 assert_eq!(manager.state(), SsoState::Idle);
1644 }
1645
1646 #[test]
1648 fn test_unpair_from_paired_returns_to_idle() {
1649 let manager = make_manager();
1650
1651 manager
1652 .handle_pairing_result(make_pairing_result())
1653 .unwrap();
1654 assert!(
1655 matches!(manager.state(), SsoState::Paired { .. }),
1656 "must be Paired before calling unpair()"
1657 );
1658
1659 manager.unpair().unwrap();
1660 assert_eq!(manager.state(), SsoState::Idle);
1661 }
1662
1663 #[test]
1666 fn test_sink_receives_state_change_on_restore() {
1667 let manager = make_manager();
1668
1669 manager
1670 .store
1671 .save(&PersistedSessionMeta {
1672 session_id: "sid-sink".to_string(),
1673 address: "5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY".to_string(),
1674 display_name: "Charlie's Phone".to_string(),
1675 p256_pubkey_hex: "04".repeat(65),
1676 phone_wallet_pubkey_hex: None,
1677 capabilities: Vec::new(),
1678 })
1679 .unwrap();
1680
1681 manager.restore_session().unwrap();
1682
1683 let states = manager
1684 .sink
1685 .states
1686 .lock()
1687 .unwrap_or_else(|e| e.into_inner());
1688 assert!(
1689 !states.is_empty(),
1690 "sink must receive at least one state notification on restore"
1691 );
1692 assert!(
1693 matches!(states.last(), Some(SsoState::Paired { .. })),
1694 "last notified state must be Paired, got {:?}",
1695 states.last()
1696 );
1697 }
1698}