Skip to main content

auths_sdk/pairing/
mod.rs

1//! Device pairing orchestration.
2//!
3//! Business logic for validating pairing codes, verifying sessions,
4//! and creating device attestations. All presentation concerns
5//! (spinners, passphrase prompts, console output) remain in the CLI.
6
7#[cfg(feature = "lan-pairing")]
8pub mod lan;
9
10mod delegation;
11
12pub use delegation::{
13    JoinerPending, PairingAnchorResult, anchor_pairing_response, build_delegated_join_response,
14    finalize_delegated_join,
15};
16
17// Re-exports of pairing types from auths-core for CLI consumption
18pub use auths_core::pairing::types::{
19    Base64UrlEncoded, CreateSessionRequest, SubmitConfirmationRequest, SubmitResponseRequest,
20    SubmitSharedKelRotRequest,
21};
22pub use auths_core::pairing::{
23    PairingResponse, PairingSession, PairingToken, QrOptions, normalize_short_code, render_qr,
24};
25
26use auths_core::pairing::SessionStatus;
27use auths_core::ports::pairing::PairingRelayClient;
28use auths_core::storage::keychain::{KeyAlias, KeyStorage};
29use auths_id::storage::identity::IdentityStorage;
30use auths_keri::Capability;
31use auths_verifier::types::CanonicalDid;
32use chrono::{DateTime, Utc};
33
34use crate::context::AuthsContext;
35
36/// Errors from pairing operations.
37#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum PairingError {
40    /// The short code format is invalid.
41    #[error("invalid short code format: {0}")]
42    InvalidShortCode(String),
43    /// The session is not in the expected state for pairing.
44    #[error("session not available for pairing: {0}")]
45    SessionNotAvailable(String),
46    /// The pairing session has expired.
47    #[error("session expired")]
48    SessionExpired,
49    /// The ephemeral ECDH key exchange failed.
50    #[error("key exchange failed: {0}")]
51    KeyExchangeFailed(String),
52    /// Creating the device attestation failed.
53    #[error("attestation creation failed: {0}")]
54    AttestationFailed(String),
55    /// The identity could not be loaded from storage.
56    #[error("identity not found: {0}")]
57    IdentityNotFound(String),
58    /// The DID derived from the device public key does not match the claimed DID.
59    #[error("device DID mismatch: response says '{response}' but key derives '{derived}'")]
60    DidMismatch {
61        /// The DID claimed by the responding device.
62        response: String,
63        /// The DID derived from the device's public key.
64        derived: String,
65    },
66    /// A storage operation failed during pairing.
67    #[error("storage error: {0}")]
68    StorageError(String),
69    /// The selected key is hardware-backed (e.g. Secure Enclave) and cannot
70    /// export the raw seed material pairing requires.
71    #[error(
72        "pairing requires a software-backed key; alias '{alias}' is hardware-backed and cannot export raw material"
73    )]
74    HardwareKeyNotExportable {
75        /// The key alias whose backend refused to export.
76        alias: String,
77    },
78    /// The LAN pairing daemon could not be constructed.
79    #[cfg(feature = "lan-pairing")]
80    #[error("pairing daemon error: {0}")]
81    DaemonError(String),
82}
83
84/// Parameters for initiating a new pairing session.
85///
86/// Args:
87/// * `controller_did`: DID of the identity initiating the pairing.
88/// * `registry`: Registry endpoint URL.
89/// * `capabilities`: Capabilities to grant to the paired device.
90/// * `expiry_secs`: Session lifetime in seconds.
91///
92/// Usage:
93/// ```ignore
94/// let params = PairingSessionParams {
95///     controller_did: "did:keri:abc123".into(),
96///     registry: "https://registry.auths.dev".into(),
97///     capabilities: vec![Capability::sign_commit()],
98///     expiry_secs: 300,
99/// };
100/// ```
101pub struct PairingSessionParams {
102    /// DID of the identity initiating the pairing.
103    pub controller_did: String,
104    /// Registry endpoint URL.
105    pub registry: String,
106    /// Capabilities to grant to the paired device.
107    pub capabilities: Vec<Capability>,
108    /// Session lifetime in seconds.
109    pub expiry_secs: u64,
110}
111
112/// The result of building a pairing session request.
113///
114/// Contains the live session (for ECDH later) and the registration payload
115/// to POST to the registry.
116///
117/// Usage:
118/// ```ignore
119/// let req = build_pairing_session_request(params)?;
120/// client.post(url).json(&req.create_request).send().await?;
121/// let shared_secret = req.session.complete_exchange(&device_pubkey)?;
122/// ```
123pub struct PairingSessionRequest {
124    /// The live pairing session with the ephemeral ECDH keypair.
125    pub session: auths_core::pairing::PairingSession,
126    /// The registration payload to POST to the registry.
127    pub create_request: auths_core::pairing::types::CreateSessionRequest,
128}
129
130/// Outcome of a completed pairing operation.
131///
132/// Pairing now anchors a KERI delegation rather than creating an attestation, so
133/// there is no attestation-fallback path — a failure is a hard error returned via
134/// `Result`, not a soft fallback variant.
135///
136/// Usage:
137/// ```ignore
138/// match result {
139///     PairingCompletionResult::Success { device_did, .. } => println!("Paired {}", device_did),
140/// }
141/// ```
142pub enum PairingCompletionResult {
143    /// Pairing completed: the device is a delegated identifier anchored by the root.
144    Success {
145        /// The delegated device's `did:keri:`.
146        device_did: CanonicalDid,
147        /// Optional human-readable name of the paired device.
148        device_name: Option<String>,
149    },
150}
151
152/// Validate and normalize a pairing short code.
153///
154/// Args:
155/// * `code`: The raw short code input from the user.
156///
157/// Usage:
158/// ```ignore
159/// let normalized = validate_short_code("ABC-123")?;
160/// assert_eq!(normalized, "ABC123");
161/// ```
162pub fn validate_short_code(code: &str) -> Result<String, PairingError> {
163    let normalized = normalize_short_code(code);
164
165    if normalized.len() != 6 {
166        return Err(PairingError::InvalidShortCode(format!(
167            "must be exactly 6 characters (got {})",
168            normalized.len()
169        )));
170    }
171
172    if !normalized.chars().all(|c| c.is_ascii_alphanumeric()) {
173        return Err(PairingError::InvalidShortCode(
174            "must contain only alphanumeric characters".to_string(),
175        ));
176    }
177
178    Ok(normalized)
179}
180
181/// Verify that a pairing session is in the correct state for pairing.
182///
183/// Args:
184/// * `status`: The session status from the registry.
185///
186/// Usage:
187/// ```ignore
188/// verify_session_status(&session_status)?;
189/// ```
190pub fn verify_session_status(
191    status: &auths_core::pairing::types::SessionStatus,
192) -> Result<(), PairingError> {
193    use auths_core::pairing::types::SessionStatus;
194
195    match status {
196        SessionStatus::Pending => Ok(()),
197        SessionStatus::Expired => Err(PairingError::SessionExpired),
198        other => Err(PairingError::SessionNotAvailable(format!("{:?}", other))),
199    }
200}
201
202/// Verify that a derived device DID matches the claimed DID.
203///
204/// Args:
205/// * `device_pubkey`: The device's Ed25519 public key (32 bytes).
206/// * `claimed_did`: The DID string claimed by the device.
207///
208/// Usage:
209/// ```ignore
210/// verify_device_did(&pubkey_bytes, "did:key:z...")?;
211/// ```
212pub fn verify_device_did(
213    device_pubkey: &[u8],
214    curve: auths_crypto::CurveType,
215    claimed_did: &str,
216) -> Result<(), PairingError> {
217    use auths_verifier::types::CanonicalDid;
218
219    let derived = CanonicalDid::from_public_key_did_key(device_pubkey, curve);
220    let claimed = CanonicalDid::parse(claimed_did).map_err(|_| PairingError::DidMismatch {
221        response: claimed_did.to_string(),
222        derived: derived.to_string(),
223    })?;
224
225    if derived != claimed {
226        return Err(PairingError::DidMismatch {
227            response: claimed.to_string(),
228            derived: derived.to_string(),
229        });
230    }
231
232    Ok(())
233}
234
235/// Build a pairing session and its registry registration payload.
236///
237/// Generates a new `PairingSession` with an ephemeral X25519 keypair and
238/// constructs the `CreateSessionRequest` ready to POST to the registry.
239///
240/// Args:
241/// * `params`: Session parameters (controller DID, registry, capabilities, expiry).
242///
243/// Usage:
244/// ```ignore
245/// let req = build_pairing_session_request(PairingSessionParams {
246///     controller_did: "did:keri:abc123".into(),
247///     registry: "https://registry.auths.dev".into(),
248///     capabilities: vec![Capability::sign_commit()],
249///     expiry_secs: 300,
250/// })?;
251/// client.post(&url).json(&req.create_request).send().await?;
252/// ```
253pub fn build_pairing_session_request(
254    now: DateTime<Utc>,
255    params: PairingSessionParams,
256) -> Result<PairingSessionRequest, PairingError> {
257    use auths_core::pairing::PairingToken;
258    use auths_core::pairing::types::CreateSessionRequest;
259
260    let expiry = chrono::Duration::seconds(params.expiry_secs as i64);
261    let session = PairingToken::generate_with_expiry(
262        now,
263        params.controller_did,
264        params.registry,
265        params.capabilities,
266        expiry,
267    )
268    .map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?;
269
270    let session_id = session.token.session_id.clone();
271    let create_request = CreateSessionRequest {
272        session_id: session_id.clone(),
273        controller_did: session.token.controller_did.clone(),
274        ephemeral_pubkey: auths_core::pairing::types::Base64UrlEncoded::from_raw(
275            session.token.ephemeral_pubkey.clone(),
276        ),
277        short_code: session.token.short_code.clone(),
278        capabilities: session.token.capabilities.clone(),
279        expires_at: session.token.expires_at.timestamp(),
280        recovery_target: None,
281    };
282
283    Ok(PairingSessionRequest {
284        session,
285        create_request,
286    })
287}
288
289/// Load device signing material from the local keychain via the context's injected providers.
290///
291/// Loads the managed identity to find the controller DID, resolves the signing key alias,
292/// decrypts the key using the context's passphrase provider, and derives the device DID.
293///
294/// The passphrase prompt is delegated to `ctx.passphrase_provider` — the CLI sets this
295/// to `CliPassphraseProvider` which handles stdin; tests may use a prefilled provider.
296///
297/// Args:
298/// * `ctx`: Runtime context supplying `identity_storage`, `key_storage`, and `passphrase_provider`.
299///
300/// Usage:
301/// ```ignore
302/// let material = load_device_signing_material(&ctx)?;
303/// join_pairing_session(code, registry, &relay, now, &material, hostname).await?;
304/// ```
305pub fn load_device_signing_material(
306    ctx: &AuthsContext,
307) -> Result<DeviceSigningMaterial, PairingError> {
308    use auths_core::crypto::signer::decrypt_keypair;
309    use auths_id::identity::helpers::ManagedIdentity;
310
311    let managed: ManagedIdentity = ctx
312        .identity_storage
313        .load_identity()
314        .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
315
316    let aliases = ctx
317        .key_storage
318        .list_aliases_for_identity(&managed.controller_did)
319        .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
320
321    let key_alias = aliases
322        .into_iter()
323        .find(|a| !a.contains("--next-"))
324        .ok_or_else(|| {
325            PairingError::IdentityNotFound(format!(
326                "no signing key found for identity {}",
327                managed.controller_did
328            ))
329        })?;
330
331    if ctx.key_storage.is_hardware_backend() {
332        return Err(PairingError::HardwareKeyNotExportable {
333            alias: key_alias.to_string(),
334        });
335    }
336
337    let (_did, _role, encrypted_key) = ctx
338        .key_storage
339        .load_key(&key_alias)
340        .map_err(|e| PairingError::StorageError(e.to_string()))?;
341
342    let prompt = format!("Enter passphrase for key '{}': ", key_alias);
343    let passphrase = ctx
344        .passphrase_provider
345        .get_passphrase(&prompt)
346        .map_err(|e| PairingError::StorageError(e.to_string()))?;
347
348    let pkcs8_bytes = decrypt_keypair(&encrypted_key, passphrase.as_str())
349        .map_err(|e| PairingError::StorageError(e.to_string()))?;
350
351    let parsed = auths_crypto::parse_key_material(&pkcs8_bytes)
352        .map_err(|e| PairingError::KeyExchangeFailed(format!("failed to parse key: {e}")))?;
353
354    let curve = parsed.seed.curve();
355    let device_did = CanonicalDid::from_public_key_did_key(&parsed.public_key, curve);
356
357    Ok(DeviceSigningMaterial {
358        seed: parsed.seed,
359        public_key: parsed.public_key,
360        device_did,
361        controller_did: managed.controller_did.to_string(),
362    })
363}
364
365/// Load the controller DID from a pre-initialized identity storage adapter.
366///
367/// Args:
368/// * `identity_storage`: Pre-initialized identity storage adapter.
369///
370/// Usage:
371/// ```ignore
372/// let did = load_controller_did(identity_storage.as_ref())?;
373/// ```
374pub fn load_controller_did(identity_storage: &dyn IdentityStorage) -> Result<String, PairingError> {
375    use auths_id::identity::helpers::ManagedIdentity;
376
377    let managed: ManagedIdentity = identity_storage
378        .load_identity()
379        .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
380
381    Ok(managed.controller_did.into_inner())
382}
383
384/// Key material loaded from the local device keychain for use in pairing operations.
385///
386/// Usage:
387/// ```ignore
388/// let material = load_device_signing_material(&ctx)?;
389/// join_pairing_session(code, registry, &relay, now, &material, hostname).await?;
390/// ```
391pub struct DeviceSigningMaterial {
392    /// Typed signing seed — curve travels with the seed so pairing flows
393    /// never need to infer curve from pubkey byte length.
394    pub seed: auths_crypto::TypedSeed,
395    /// Public key bytes (32 for Ed25519, 33 for P-256 compressed).
396    pub public_key: Vec<u8>,
397    /// DID of the local device.
398    pub device_did: CanonicalDid,
399    /// DID of the controller identity this device belongs to.
400    pub controller_did: String,
401}
402
403/// Progress events fired by [`initiate_online_pairing`] so the CLI can update spinners.
404///
405/// The SDK fires these during the async operation — the callback must not block.
406pub enum PairingStatus {
407    /// The session was registered; contains the token for QR display and TTL info.
408    SessionCreated {
409        /// Pairing token (used to render the QR code).
410        token: Box<PairingToken>,
411        /// Session time-to-live in seconds.
412        ttl_seconds: u64,
413    },
414    /// The SDK is now waiting for a device to respond.
415    WaitingForApproval,
416    /// A device response was received.
417    Approved,
418}
419
420/// Orchestrate an online pairing session: register, wait for approval, complete attestation.
421///
422/// Fires `on_status` callbacks as the session progresses so the CLI can update
423/// spinners and display QR code output without any knowledge of the HTTP transport.
424///
425/// Args:
426/// * `params`: Session parameters (controller DID, registry, capabilities, expiry).
427/// * `relay`: Pairing relay client (HTTP implementation injected by CLI).
428/// * `ctx`: Runtime context carrying identity/attestation storage and key material.
429/// * `now`: Current time (injected by caller — no `Utc::now()` in SDK).
430/// * `on_status`: Optional progress callback; fires `SessionCreated`, `WaitingForApproval`, `Approved`.
431///
432/// Usage:
433/// ```ignore
434/// let result = initiate_online_pairing(params, &relay, &ctx, Utc::now(), Some(&on_status)).await?;
435/// ```
436// INVARIANT: online-pairing is a sequence of relay round-trips that share local
437// state (session keys, status events, context references). Splitting at the
438// round-trip boundary would force threading 6+ values through sub-helpers with
439// no test or correctness benefit. One-line overrun is acceptable.
440#[allow(clippy::too_many_lines)]
441pub async fn initiate_online_pairing<R: PairingRelayClient>(
442    params: PairingSessionParams,
443    relay: &R,
444    ctx: &AuthsContext,
445    now: DateTime<Utc>,
446    on_status: Option<&(dyn Fn(PairingStatus) + Send + Sync)>,
447) -> Result<PairingCompletionResult, PairingError> {
448    let registry = params.registry.clone();
449    let expiry = std::time::Duration::from_secs(params.expiry_secs);
450
451    let session_req = build_pairing_session_request(now, params)?;
452    let mut session = session_req.session;
453    let create_request = session_req.create_request;
454    let session_id = create_request.session_id.clone();
455
456    let created = relay
457        .create_session(&registry, &create_request)
458        .await
459        .map_err(|e| PairingError::StorageError(e.to_string()))?;
460
461    if let Some(cb) = on_status {
462        cb(PairingStatus::SessionCreated {
463            token: Box::new(session.token.clone()),
464            ttl_seconds: created.ttl_seconds,
465        });
466    }
467
468    if let Some(cb) = on_status {
469        cb(PairingStatus::WaitingForApproval);
470    }
471
472    let session_state = relay
473        .wait_for_update(&registry, &session_id, expiry)
474        .await
475        .map_err(|e| PairingError::StorageError(e.to_string()))?;
476
477    let state = match session_state {
478        None => return Err(PairingError::SessionExpired),
479        Some(s) => s,
480    };
481
482    match state.status {
483        SessionStatus::Responded => {}
484        SessionStatus::Cancelled => {
485            return Err(PairingError::SessionNotAvailable("cancelled".into()));
486        }
487        SessionStatus::Expired => return Err(PairingError::SessionExpired),
488        other => return Err(PairingError::SessionNotAvailable(format!("{other:?}"))),
489    }
490
491    let response = state.response.ok_or_else(|| {
492        PairingError::StorageError(
493            "server returned Responded status but no response payload".into(),
494        )
495    })?;
496
497    if let Some(cb) = on_status {
498        cb(PairingStatus::Approved);
499    }
500
501    let device_ecdh_bytes: Vec<u8> = response
502        .device_ephemeral_pubkey
503        .decode()
504        .map_err(|e| PairingError::KeyExchangeFailed(format!("invalid ephemeral pubkey: {e}")))?;
505
506    let device_signing_bytes = response
507        .device_signing_pubkey
508        .decode()
509        .map_err(|e| PairingError::KeyExchangeFailed(format!("invalid signing pubkey: {e}")))?;
510
511    let signature_bytes = response
512        .signature
513        .decode()
514        .map_err(|e| PairingError::KeyExchangeFailed(format!("invalid signature: {e}")))?;
515
516    let curve: auths_crypto::CurveType = response.curve.into();
517    session
518        .verify_response(
519            &device_signing_bytes,
520            &device_ecdh_bytes,
521            &signature_bytes,
522            curve,
523        )
524        .map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?;
525
526    let _shared_secret = session
527        .complete_exchange(&device_ecdh_bytes)
528        .map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?;
529
530    // The device's custody of its signing key is now proven (verify_response). Anchor
531    // the delegated dip it shipped and relay the root's anchoring ixn back so the
532    // device can confirm + persist its delegation.
533    let anchor = anchor_pairing_response(
534        ctx,
535        &response.responder_inception_event,
536        response.device_name.clone(),
537    )?;
538
539    relay
540        .submit_confirmation(&registry, &session_id, &anchor.confirmation)
541        .await
542        .map_err(|e| PairingError::StorageError(e.to_string()))?;
543
544    Ok(PairingCompletionResult::Success {
545        device_did: anchor.device_did,
546        device_name: anchor.device_name,
547    })
548}
549
550/// The outcome of a device recovery: a replacement device was paired and the lost
551/// device's delegation revoked.
552pub struct RecoveryResult {
553    /// The newly-paired delegated device's `did:keri:`.
554    pub new_device_did: CanonicalDid,
555    /// The new device's friendly name, if it supplied one.
556    pub new_device_name: Option<String>,
557    /// The old device DID whose delegation was revoked.
558    pub revoked_old_did: String,
559}
560
561/// Recover from a lost/stolen device: pair a replacement delegated device, then
562/// revoke the old device's delegation.
563///
564/// The replacement is paired and anchored **first**, so the identity is never left
565/// with zero usable devices; only then is the old delegation revoked. If pairing
566/// succeeds but the revoke fails, this returns an error that names the new device so
567/// the caller can report both states — the new device stays paired and the old one
568/// can be removed manually.
569///
570/// Args:
571/// * `params`: Session parameters for pairing the replacement device.
572/// * `relay`: Pairing relay client.
573/// * `ctx`: Runtime context (the root identity's registry + signing key).
574/// * `now`: Current time (injected by caller).
575/// * `old_device_did`: The `did:keri:` of the device being replaced.
576/// * `on_status`: Optional progress callback for the pairing phase.
577///
578/// Usage:
579/// ```ignore
580/// let r = recover_device(params, &relay, &ctx, now, &old_did, None).await?;
581/// println!("paired {}, revoked {}", r.new_device_did, r.revoked_old_did);
582/// ```
583pub async fn recover_device<R: PairingRelayClient>(
584    params: PairingSessionParams,
585    relay: &R,
586    ctx: &AuthsContext,
587    now: DateTime<Utc>,
588    old_device_did: &str,
589    on_status: Option<&(dyn Fn(PairingStatus) + Send + Sync)>,
590) -> Result<RecoveryResult, PairingError> {
591    // Pair the replacement first — the identity must never have zero usable devices.
592    let PairingCompletionResult::Success {
593        device_did,
594        device_name,
595    } = initiate_online_pairing(params, relay, ctx, now, on_status).await?;
596
597    // Resolve the root's signing alias to author the revocation.
598    let managed = ctx
599        .identity_storage
600        .load_identity()
601        .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
602    let aliases = ctx
603        .key_storage
604        .list_aliases_for_identity(&managed.controller_did)
605        .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
606    let root_alias = aliases
607        .into_iter()
608        .find(|a| !a.contains("--next-"))
609        .ok_or_else(|| {
610            PairingError::IdentityNotFound(format!(
611                "no signing key found for {}",
612                managed.controller_did
613            ))
614        })?;
615
616    // Only now revoke the old delegation.
617    crate::domains::device::remove_device(ctx, &root_alias, old_device_did).map_err(|e| {
618        PairingError::StorageError(format!(
619            "replacement device {device_did} was paired, but revoking the old device \
620             {old_device_did} failed: {e}. Remove it manually with \
621             `auths device remove {old_device_did}`."
622        ))
623    })?;
624
625    Ok(RecoveryResult {
626        new_device_did: device_did,
627        new_device_name: device_name,
628        revoked_old_did: old_device_did.to_string(),
629    })
630}
631
632/// Orchestrate joining a pairing session as a delegated device.
633///
634/// The joining device generates its own key, builds + self-signs its `dip`
635/// (delegated by the session's `controller_did`), ships it in the response, then
636/// waits for the initiator to anchor it. On confirmation it verifies the anchor and
637/// persists its own KEL + key. A fresh device needs no pre-existing identity — only
638/// an initialized (possibly empty) registry + keychain in `ctx`.
639///
640/// Args:
641/// * `ctx`: The joining device's context (its own registry + keychain + passphrase).
642/// * `code`: Short code entered by the user (normalized internally).
643/// * `registry_url`: Pairing relay server URL.
644/// * `relay`: Pairing relay client.
645/// * `now`: Current time (injected by caller).
646/// * `curve`: Curve for the new device key.
647/// * `device_alias`: Keychain alias to store the new device key under.
648/// * `device_name`: Optional friendly name to include in the response.
649/// * `confirmation_timeout`: How long to wait for the initiator's anchor.
650///
651/// Usage:
652/// ```ignore
653/// let result = join_pairing_session(&ctx, code, registry, &relay, now,
654///     CurveType::Ed25519, KeyAlias::new_unchecked("laptop"), hostname, ttl).await?;
655/// ```
656#[allow(clippy::too_many_arguments)]
657pub async fn join_pairing_session<R: PairingRelayClient>(
658    ctx: &AuthsContext,
659    code: &str,
660    registry_url: &str,
661    relay: &R,
662    now: DateTime<Utc>,
663    curve: auths_crypto::CurveType,
664    device_alias: KeyAlias,
665    device_name: Option<String>,
666    confirmation_timeout: std::time::Duration,
667) -> Result<PairingCompletionResult, PairingError> {
668    let normalized = validate_short_code(code)?;
669
670    let session_data = relay
671        .lookup_by_code(registry_url, &normalized)
672        .await
673        .map_err(|e| PairingError::StorageError(e.to_string()))?;
674
675    verify_session_status(&session_data.status)?;
676
677    let token_data = session_data
678        .token
679        .ok_or_else(|| PairingError::StorageError("session has no token data".into()))?;
680
681    let token = PairingToken {
682        controller_did: token_data.controller_did.clone(),
683        endpoint: registry_url.to_string(),
684        short_code: normalized.clone(),
685        session_id: session_data.session_id.clone(),
686        ephemeral_pubkey: token_data.ephemeral_pubkey.to_string(),
687        expires_at: chrono::DateTime::from_timestamp(token_data.expires_at, 0).unwrap_or(now),
688        capabilities: token_data.capabilities.clone(),
689        kem_slot: None,
690        daemon_spki_sha256: None,
691    };
692
693    if token.is_expired(now) {
694        return Err(PairingError::SessionExpired);
695    }
696
697    let device_name_out = device_name.clone();
698    let (submit_req, pending, _shared_secret) =
699        build_delegated_join_response(now, &token, curve, device_alias, device_name)?;
700
701    relay
702        .submit_response(registry_url, &session_data.session_id, &submit_req)
703        .await
704        .map_err(|e| PairingError::StorageError(e.to_string()))?;
705
706    let confirmation = relay
707        .wait_for_confirmation(registry_url, &session_data.session_id, confirmation_timeout)
708        .await
709        .map_err(|e| PairingError::StorageError(e.to_string()))?
710        .ok_or(PairingError::SessionExpired)?;
711
712    let device_did = finalize_delegated_join(ctx, pending, &confirmation)?;
713
714    Ok(PairingCompletionResult::Success {
715        device_did,
716        device_name: device_name_out,
717    })
718}