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