Skip to main content

host_sso/
manager.rs

1//! `SsoManager` — coordinator for the SSO QR-pairing lifecycle.
2//!
3//! All I/O is delegated to injected trait adapters (`SsoTransport`,
4//! `SsoSigner`, `SsoSessionStore`, `SsoEventSink`, `SsoProductKeyStore`) so
5//! the crate has no direct I/O dependencies and is straightforward to unit-test.
6
7use 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// ---------------------------------------------------------------------------
26// Timeout constant
27// ---------------------------------------------------------------------------
28
29/// Timeout for a product key request. A const so tests can read it.
30#[cfg(not(target_arch = "wasm32"))]
31pub const PRODUCT_KEY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
32
33// ---------------------------------------------------------------------------
34// Sign material
35// ---------------------------------------------------------------------------
36
37/// Session key material required to forward sign/product-key requests to the
38/// paired phone.
39///
40/// Stored in `SsoManager` after a successful pairing and zeroized on unpair.
41#[cfg(not(target_arch = "wasm32"))]
42struct SignMaterial {
43    /// AES-128 session key negotiated during P-256 ECDH pairing.
44    session_key: zeroize::Zeroizing<[u8; 16]>,
45    /// 32-byte sr25519 account ID (`//wallet//sso`) used as HMAC key.
46    identity_pubkey: [u8; 32],
47    /// 65-byte uncompressed P-256 public key from the pairing handshake.
48    p256_pubkey: [u8; 65],
49}
50
51// ---------------------------------------------------------------------------
52// SsoManager
53// ---------------------------------------------------------------------------
54
55/// Central coordinator for the SSO pairing lifecycle.
56///
57/// Generic over five injected adapters so hosts provide platform-specific
58/// implementations without coupling this crate to any I/O runtime.
59///
60/// The fifth generic `Pk` defaults to [`NoopProductKeyStore`], so existing
61/// callers constructed via [`SsoManager::new`] do not need to change.
62///
63/// All shared fields are wrapped in `Arc` so the background pairing thread
64/// can update state and notify the sink without requiring `Arc<SsoManager>`.
65pub 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    /// Current observable state; shared with the background pairing thread.
74    state: Arc<Mutex<SsoState>>,
75    /// Transport adapter shared with the pairing closures (FnOnce captures Arc).
76    transport: Arc<T>,
77    signer: Si,
78    /// Session store shared with the background pairing thread.
79    store: Arc<St>,
80    /// Event sink shared with the background pairing thread.
81    sink: Arc<E>,
82    /// Product key persistence adapter.
83    product_key_store: Arc<Pk>,
84    /// Metadata URL embedded in the QR handshake (may be empty).
85    metadata_url: String,
86    /// Guards against launching two concurrent pairing attempts.
87    pairing_in_progress: Arc<AtomicBool>,
88    /// Monotonic counter incremented on each pair() call. The polling thread
89    /// checks its generation matches the current one before mutating state,
90    /// preventing a stale thread from corrupting a newer pairing session.
91    #[cfg(not(target_arch = "wasm32"))]
92    pairing_generation: Arc<std::sync::atomic::AtomicU64>,
93    /// Cancel flag forwarded to the active `PairingSession`; `unpair()` sets it.
94    #[cfg(not(target_arch = "wasm32"))]
95    pairing_cancel: Mutex<Option<Arc<AtomicBool>>>,
96    /// Session key material for forwarding sign requests to the paired phone.
97    /// Set by `handle_pairing_result`, cleared by `unpair`.
98    #[cfg(not(target_arch = "wasm32"))]
99    sign_material: Mutex<Option<SignMaterial>>,
100    /// In-memory product key cache, bound to the current phone identity.
101    /// `None` until a successful pairing establishes a phone identity.
102    #[cfg(not(target_arch = "wasm32"))]
103    product_key_cache: Mutex<Option<ProductKeyCache>>,
104    /// Cancel flag for the background presence monitor thread.
105    /// Set to `true` by `unpair()` to stop the monitor.
106    #[cfg(not(target_arch = "wasm32"))]
107    presence_cancel: Mutex<Option<Arc<AtomicBool>>>,
108    /// Unix timestamp (seconds) of the last heartbeat received from the phone.
109    /// `0` means no heartbeat has been received; reads need no mutex.
110    #[cfg(not(target_arch = "wasm32"))]
111    last_heartbeat_ts: Arc<AtomicI64>,
112}
113
114// ---------------------------------------------------------------------------
115// Constructor: new() — only available when Pk = NoopProductKeyStore
116// ---------------------------------------------------------------------------
117
118impl<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    /// Construct a new manager starting in the `Idle` state with the default
126    /// no-op product key store.
127    ///
128    /// If you need product key caching with persistence, use
129    /// [`SsoManager::with_product_key_store`] instead.
130    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
142// ---------------------------------------------------------------------------
143// Constructor: with_product_key_store() — available for all Pk
144// ---------------------------------------------------------------------------
145
146impl<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    /// Construct a new manager with a custom product key store.
155    ///
156    /// The manager starts in the `Idle` state.
157    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
190// ---------------------------------------------------------------------------
191// Public API — general impl block for all Pk
192// ---------------------------------------------------------------------------
193
194impl<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    /// Return a snapshot of the current state.
203    pub fn state(&self) -> SsoState {
204        self.state.lock().unwrap_or_else(|e| e.into_inner()).clone()
205    }
206
207    /// Initiate a QR-pairing session in a background thread.
208    ///
209    /// Transitions: `Idle` / `Failed` → `AwaitingScan` (when QR is ready) →
210    /// `Paired` on success, or `Failed` on error or cancellation.
211    ///
212    /// Returns `Err(PairingAlreadyInProgress)` if a pairing is already running.
213    #[cfg(not(target_arch = "wasm32"))]
214    pub fn pair(&self) -> Result<(), SsoError> {
215        // Reject duplicate pairing attempts atomically.
216        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        // Each PairingConfig closure captures an Arc clone of the transport so
231        // the FnOnce can call &self methods after the manager is potentially dropped.
232        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        // start_pairing spawns its own handshake thread internally and returns
243        // immediately with a channel receiver for state updates.
244        let session = host_wallet::pairing::start_pairing(config);
245
246        // Store the cancel flag so unpair() can abort in-flight pairing.
247        *self
248            .pairing_cancel
249            .lock()
250            .unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&session.cancel));
251
252        // Increment generation so stale polling threads discard their events.
253        let generation = self.pairing_generation.fetch_add(1, Ordering::AcqRel) + 1;
254
255        // Capture shared state for the polling thread.
256        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        // Spawn a lightweight thread that drains state_rx and applies transitions.
265        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    /// Clear the current session and return to the `Idle` state.
280    ///
281    /// If a pairing is in progress it is cancelled before clearing.
282    /// Safe to call from any state, including `Idle`.
283    pub fn unpair(&self) -> Result<(), SsoError> {
284        // Cancel any in-flight pairing handshake.
285        #[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        // Zeroize sign material so the AES session key is overwritten in memory.
296        #[cfg(not(target_arch = "wasm32"))]
297        {
298            *self.sign_material.lock().unwrap_or_else(|e| e.into_inner()) = None;
299        }
300
301        // Clear the in-memory product key cache and remove persisted cache.
302        #[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        // Stop the presence monitor and reset the heartbeat timestamp.
312        #[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    /// Load a previously persisted session from the store.
333    ///
334    /// On success, transitions to `Paired`. If no session is stored, stays
335    /// `Idle`. Returns `Err(Store(_))` if the store call itself fails.
336    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    /// Finalise a completed pairing handshake.
359    ///
360    /// Persists the session metadata, stores sign material for future
361    /// `request_sign` calls, initialises the product key cache with the phone
362    /// identity (loading any persisted entries), clears the in-progress guard,
363    /// and transitions to `Paired`. The `PairingResult` is consumed and its
364    /// `session_key` is zeroized on drop — callers must not use it after this call.
365    pub fn handle_pairing_result(&self, result: PairingResult) -> Result<(), SsoError> {
366        // Cache sign material before the result fields are moved into the ctx.
367        #[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        // Initialise the product key cache with the phone wallet identity.
378        #[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    /// Send a sign request to the paired phone and block until response or timeout.
405    ///
406    /// Must be called from a background thread (blocking I/O).
407    /// Returns the signature bytes on success.
408    ///
409    /// Returns `Err(NotPaired)` if no paired session exists, or `Err(SignFailed)`
410    /// if the transport or phone returns an error.
411    #[cfg(not(target_arch = "wasm32"))]
412    pub fn request_sign(&self, payload: &[u8]) -> Result<Vec<u8>, SsoError> {
413        // Guard: must be in Paired state.
414        match self.state() {
415            SsoState::Paired { .. } => {}
416            _ => return Err(SsoError::NotPaired),
417        }
418
419        // Extract session material under the lock, then release immediately.
420        // The state check and material extraction are separate critical sections;
421        // unpair() could clear sign_material between them, which is handled by
422        // the None arm returning NotPaired.
423        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    /// Request a product public key from the paired phone.
463    ///
464    /// Checks the in-memory cache first (no network round-trip on hit). On a
465    /// cache miss, verifies the phone is online before sending a
466    /// `ProductKeyRequest` over the encrypted channel and waiting up to 30
467    /// seconds for the phone's response.
468    ///
469    /// Returns `Err(NotPaired)` if no paired session exists.
470    /// Returns `Err(PhoneOffline)` if the phone has not sent a heartbeat
471    ///   within [`presence::HEARTBEAT_TIMEOUT_SECS`] (cache misses only).
472    /// Returns `Err(ProductKeyCapabilityAbsent)` if the phone does not advertise
473    ///   the `"product_key"` capability.
474    /// Returns `Err(ProductKeyRejected)` if the phone denies the request.
475    /// Returns `Err(ProductKeyTimeout)` if no response arrives within 30 s.
476    #[cfg(not(target_arch = "wasm32"))]
477    pub fn request_product_key(&self, product_id: &str, index: u32) -> Result<[u8; 32], SsoError> {
478        // Guard: must be in Paired state.
479        match self.state() {
480            SsoState::Paired { .. } => {}
481            _ => return Err(SsoError::NotPaired),
482        }
483
484        // Single store load: check capability and extract phone pubkey together.
485        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        // Best-effort guard: the phone could go offline between here and the
491        // network round-trip. If it does, the request times out after PRODUCT_KEY_TIMEOUT.
492        if !self.is_phone_online() {
493            return Err(SsoError::PhoneOffline);
494        }
495
496        // Send request to the phone.
497        let pubkey = self.send_product_key_request(phone_pubkey, product_id, index)?;
498
499        // Insert into cache and persist.
500        self.cache_insert_and_persist(phone_pubkey, product_id, index, pubkey);
501
502        Ok(pubkey)
503    }
504
505    // -----------------------------------------------------------------------
506    // Private helpers
507    // -----------------------------------------------------------------------
508
509    /// Update the internal state and notify the event sink.
510    fn transition(&self, state: &Arc<Mutex<SsoState>>, sink: &Arc<E>, new_state: SsoState) {
511        transition_state(state, sink, new_state);
512    }
513
514    /// Load session meta once and extract both capability check and phone pubkey.
515    /// Avoids double store load (review finding C-1).
516    #[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    /// Try to get a product key from the in-memory cache.
537    #[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    /// Insert a product key into the cache and persist the snapshot.
549    #[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                // Identity mismatch after a concurrent re-pair — log and skip persist.
564                log::warn!("host-sso: product key cache insert skipped: {e}");
565                return;
566            }
567            let entries = cache.to_entries();
568            drop(guard); // release lock before I/O
569            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    /// Initialise (or reinitialise) the product key cache for `phone_identity`.
576    ///
577    /// Loads any matching persisted entries from the store.
578    #[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        // Load persisted entries for this identity, if any.
583        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    /// Send a `ProductKeyRequest` over the encrypted channel and await the response.
600    #[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        // Re-use sign_via_paired: the encrypted response bytes are the SCALE-encoded
630        // ProductKeyResponse. The phone must decrypt our request and encrypt its response
631        // using the same session key and channel derivation.
632        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    /// Extract sign material from the mutex. Returns `Err(NotPaired)` if absent.
667    #[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    /// Return `true` if the phone sent a heartbeat within the presence timeout.
684    ///
685    /// Delegates to [`presence::is_phone_online`] using the atomic timestamp so
686    /// no mutex is required.
687    #[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    /// Remove a single entry from the product key cache and persist the snapshot.
693    ///
694    /// No-op if the cache is uninitialised or the entry does not exist.
695    #[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
715// ---------------------------------------------------------------------------
716// Free helpers used by both the manager and the background polling thread
717// ---------------------------------------------------------------------------
718
719/// Bundled arguments for `handle_established` to stay under clippy's
720/// `too_many_arguments` limit.
721struct 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
737/// Persist session metadata, clear the in-progress flag, and transition
738/// to `Paired`. Extracted so both `handle_pairing_result` and the polling
739/// thread can call it without an `&SsoManager` reference.
740fn 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        // TODO(backlog:sso-session-manager): Capabilities are always empty until
755        // the phone firmware sends them in the pairing JSON response. Until then,
756        // request_product_key() returns ProductKeyCapabilityAbsent. To test the
757        // product key flow, manually inject capabilities into the persisted meta.
758        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
777/// Set `state` to `new_state` and notify `sink`. Used by free-function helpers
778/// that do not have a `&SsoManager` reference (e.g., the polling thread).
779fn 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/// Drain `state_rx` from a `PairingSession` and apply state transitions.
790///
791/// Runs on a dedicated thread spawned by `pair()`. The thread exits when the
792/// channel is closed (i.e., the pairing background thread finishes).
793#[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    // We receive a fixed sequence: AwaitingScan → Established|Failed.
805    // Loop until the sender drops (channel closed).
806    for pairing_state in state_rx {
807        // If a newer pair() call has started, this thread is stale — discard events.
808        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                // TODO(backlog:sso-session-manager): PairingState::Established does
823                // not carry identity_pubkey or p256_pubkey. Session persistence is
824                // skipped here — the host must call handle_pairing_result() with the
825                // full PairingResult (including real key material) to persist the session.
826                // Phase 2 will extend PairingState to carry these fields.
827                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                // Channel will close after this; loop exits naturally.
838            }
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
848/// Hex-encode a 65-byte P-256 public key.
849fn 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
857/// Derive a stable session ID from the identity pubkey and P-256 pubkey.
858///
859/// TODO(backlog:sso-session-manager): Replace with HMAC-SHA256 domain-separated
860/// hash. Current concatenation is not unique if the same identity key pairs with
861/// a different P-256 key (re-pairing edge case).
862fn 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/// Derive a stable, deterministic message ID for a product key request.
876///
877/// Using a deterministic ID means the phone can deduplicate re-sent requests
878/// for the same (product_id, index) tuple within the same session.
879#[cfg(not(target_arch = "wasm32"))]
880fn derive_message_id(phone_pubkey: &[u8; 32], product_id: &str, index: u32) -> String {
881    // Simple concatenation — sufficient for uniqueness within a session.
882    // The phone treats message_id as opaque; it only echoes it back.
883    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// ---------------------------------------------------------------------------
893// Tests
894// ---------------------------------------------------------------------------
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899    use crate::traits::{PersistedSessionMeta, SsoProductKeyStore};
900    use std::sync::{Arc, Mutex};
901
902    // --- Stub implementations ---
903
904    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    // A memory-backed product key store for tests.
972    #[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    // Helper: build a manager with the default NoopProductKeyStore.
1004    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    // Helper: build a manager with the MemoryProductKeyStore.
1017    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    // -----------------------------------------------------------------------
1051    // Existing tests (preserved unchanged)
1052    // -----------------------------------------------------------------------
1053
1054    #[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        // Pre-populate the store.
1065        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        // Verify the sink was notified of the state transition.
1085        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        // Establish a paired state first.
1106        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        // Store must be empty after unpair.
1115        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        // First call should succeed.
1124        manager.pair().unwrap();
1125
1126        // Second call must fail with PairingAlreadyInProgress.
1127        let err = manager.pair().unwrap_err();
1128        assert!(
1129            matches!(err, SsoError::PairingAlreadyInProgress),
1130            "expected PairingAlreadyInProgress, got {err:?}"
1131        );
1132
1133        // Clean up so the flag is reset.
1134        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        // State must be Paired.
1146        let state = manager.state();
1147        assert!(
1148            matches!(state, SsoState::Paired { ref address, .. } if address == "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"),
1149            "unexpected state: {state:?}"
1150        );
1151
1152        // Metadata must be persisted.
1153        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        // pairing_in_progress flag must be cleared.
1163        assert!(
1164            !manager.pairing_in_progress.load(Ordering::Acquire),
1165            "pairing_in_progress should be false after success"
1166        );
1167    }
1168
1169    /// Verify that `pair()` transitions to `AwaitingScan` with a real QR URI.
1170    ///
1171    /// The `NoopTransport` subscribe closure returns a channel whose sender is
1172    /// dropped immediately, causing `start_pairing`'s handshake thread to time
1173    /// out. The polling thread will eventually emit `Failed`, but we only
1174    /// observe the intermediate `AwaitingScan` transition here.
1175    #[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        // The pairing thread emits AwaitingScan almost immediately after generating
1183        // the P-256 keypair. Poll for up to 2 seconds to accommodate scheduler variance.
1184        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    /// Verify that `pair()` eventually transitions to `Failed` when the
1214    /// transport returns a closed channel (no mobile response).
1215    #[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        // Block until we see a Failed state. Pairing times out after 120s.
1223        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        // handle_pairing_result populates sign_material.
1259        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        // unpair must zeroize sign_material.
1274        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    /// Verify that calling `unpair()` during pairing cancels the handshake and
1304    /// returns to `Idle`.
1305    #[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        // After unpair the flag must be cleared and state must be Idle.
1319        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    // -----------------------------------------------------------------------
1327    // New product key tests
1328    // -----------------------------------------------------------------------
1329
1330    /// `request_product_key` must return `NotPaired` when the manager is Idle.
1331    #[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    /// `request_product_key` must return `ProductKeyCapabilityAbsent` when the
1343    /// persisted session does not list `"product_key"` in capabilities.
1344    #[cfg(not(target_arch = "wasm32"))]
1345    #[test]
1346    fn test_request_product_key_returns_capability_absent_when_unsupported() {
1347        let manager = make_manager();
1348        // Pair without advertising the product_key capability.
1349        manager
1350            .handle_pairing_result(make_pairing_result())
1351            .unwrap();
1352        // The capabilities list is empty by default after handle_pairing_result.
1353
1354        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    /// `request_product_key` must return a cached key without calling the
1362    /// transport when a matching entry is already in the cache.
1363    #[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        // Pair with a phone that advertises the product_key capability.
1372        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        // Manually advertise the capability in the persisted meta.
1377        {
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        // Manually seed the in-memory cache.
1384        {
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        // The key must be returned from the cache — NoopTransport would fail if
1396        // the transport were actually called (write() returns Ok but subscribe
1397        // returns a closed channel, causing a timeout).
1398        let key = manager.request_product_key("acme.dot", 0).unwrap();
1399        assert_eq!(key, CACHED_KEY, "must return the cached key");
1400    }
1401
1402    /// After `unpair()`, the product key cache must be cleared and the
1403    /// persisted store entry must be deleted.
1404    #[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        // Pair and seed the cache.
1413        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        // Persist the cache manually so we can verify deletion.
1427        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        // In-memory cache must be cleared.
1446        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        // Persisted store entry must be deleted.
1457        assert!(
1458            manager.product_key_store.load().unwrap().is_none(),
1459            "product key store must be empty after unpair"
1460        );
1461    }
1462
1463    // -----------------------------------------------------------------------
1464    // Phase 3: presence + revocation tests
1465    // -----------------------------------------------------------------------
1466
1467    /// `is_phone_online()` must return `false` on a freshly constructed manager
1468    /// because `last_heartbeat_ts` is initialised to 0.
1469    #[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    /// `request_product_key` must return `PhoneOffline` on a cache miss when
1480    /// no heartbeat has been received (default initial state).
1481    #[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        // Pair and advertise the product_key capability.
1489        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        // last_heartbeat_ts is 0 (never received) — phone is offline.
1499        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    /// `cache_remove_and_persist` must evict the specified entry and write the
1507    /// updated snapshot to the product key store.
1508    #[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        // Seed cache with two entries.
1521        {
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        // Revoke acme.dot/0.
1534        manager.cache_remove_and_persist("acme.dot", 0);
1535
1536        // In-memory cache must no longer contain acme.dot/0.
1537        {
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        // Persisted snapshot must still contain foo.dot/1 but not acme.dot/0.
1549        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    /// `unpair()` must reset `last_heartbeat_ts` to 0 so a subsequent
1566    /// `is_phone_online()` call returns `false`.
1567    #[cfg(not(target_arch = "wasm32"))]
1568    #[test]
1569    fn test_unpair_resets_heartbeat_timestamp() {
1570        let manager = make_manager();
1571
1572        // Simulate a received heartbeat by writing a recent timestamp.
1573        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    // -----------------------------------------------------------------------
1597    // Issue #108 — SsoManager happy-path tests
1598    // -----------------------------------------------------------------------
1599
1600    /// A freshly constructed manager must start in the `Idle` state.
1601    #[test]
1602    fn test_initial_state_is_idle() {
1603        let manager = make_manager();
1604        assert_eq!(manager.state(), SsoState::Idle);
1605    }
1606
1607    /// When the store contains a persisted session, `restore_session` must
1608    /// transition to `Paired` with the stored address.
1609    #[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    /// When the store is empty, `restore_session` must leave the manager `Idle`.
1639    #[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    /// After pairing, `unpair()` must return the manager to the `Idle` state.
1647    #[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    /// The event sink must receive a `Paired` notification when
1664    /// `restore_session` finds a persisted session.
1665    #[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}