1#[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
17pub 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#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum PairingError {
39 #[error("invalid short code format: {0}")]
41 InvalidShortCode(String),
42 #[error("session not available for pairing: {0}")]
44 SessionNotAvailable(String),
45 #[error("session expired")]
47 SessionExpired,
48 #[error("key exchange failed: {0}")]
50 KeyExchangeFailed(String),
51 #[error("attestation creation failed: {0}")]
53 AttestationFailed(String),
54 #[error("identity not found: {0}")]
56 IdentityNotFound(String),
57 #[error("device DID mismatch: response says '{response}' but key derives '{derived}'")]
59 DidMismatch {
60 response: String,
62 derived: String,
64 },
65 #[error("storage error: {0}")]
67 StorageError(String),
68 #[error(
71 "pairing requires a software-backed key; alias '{alias}' is hardware-backed and cannot export raw material"
72 )]
73 HardwareKeyNotExportable {
74 alias: String,
76 },
77 #[cfg(feature = "lan-pairing")]
79 #[error("pairing daemon error: {0}")]
80 DaemonError(String),
81}
82
83pub struct PairingSessionParams {
101 pub controller_did: String,
103 pub registry: String,
105 pub capabilities: Vec<Capability>,
107 pub expiry_secs: u64,
109}
110
111pub struct PairingSessionRequest {
123 pub session: auths_core::pairing::PairingSession,
125 pub create_request: auths_core::pairing::types::CreateSessionRequest,
127}
128
129pub enum PairingCompletionResult {
142 Success {
144 device_did: CanonicalDid,
146 device_name: Option<String>,
148 },
149}
150
151pub 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
180pub 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
201pub 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
234pub 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
288pub 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 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
367pub 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
386pub struct DeviceSigningMaterial {
394 pub seed: auths_crypto::TypedSeed,
397 pub public_key: Vec<u8>,
399 pub device_did: CanonicalDid,
401 pub controller_did: String,
403}
404
405pub enum PairingStatus {
409 SessionCreated {
411 token: Box<PairingToken>,
413 ttl_seconds: u64,
415 },
416 WaitingForApproval,
418 Approved,
420}
421
422#[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(®istry, &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(®istry, &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 let anchor = anchor_pairing_response(
536 ctx,
537 &response.responder_inception_event,
538 response.device_name.clone(),
539 )?;
540
541 relay
542 .submit_confirmation(®istry, &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
552pub struct RecoveryResult {
555 pub new_device_did: CanonicalDid,
557 pub new_device_name: Option<String>,
559 pub revoked_old_did: String,
561}
562
563pub 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 let PairingCompletionResult::Success {
595 device_did,
596 device_name,
597 } = initiate_online_pairing(params, relay, ctx, now, on_status).await?;
598
599 let managed = ctx
601 .identity_storage
602 .load_identity()
603 .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
604 #[allow(clippy::disallowed_methods)]
605 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 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#[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}