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