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 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#[derive(Debug, thiserror::Error)]
38#[non_exhaustive]
39pub enum PairingError {
40 #[error("invalid short code format: {0}")]
42 InvalidShortCode(String),
43 #[error("session not available for pairing: {0}")]
45 SessionNotAvailable(String),
46 #[error("session expired")]
48 SessionExpired,
49 #[error("key exchange failed: {0}")]
51 KeyExchangeFailed(String),
52 #[error("attestation creation failed: {0}")]
54 AttestationFailed(String),
55 #[error("identity not found: {0}")]
57 IdentityNotFound(String),
58 #[error("device DID mismatch: response says '{response}' but key derives '{derived}'")]
60 DidMismatch {
61 response: String,
63 derived: String,
65 },
66 #[error("storage error: {0}")]
68 StorageError(String),
69 #[error(
72 "pairing requires a software-backed key; alias '{alias}' is hardware-backed and cannot export raw material"
73 )]
74 HardwareKeyNotExportable {
75 alias: String,
77 },
78 #[cfg(feature = "lan-pairing")]
80 #[error("pairing daemon error: {0}")]
81 DaemonError(String),
82}
83
84pub struct PairingSessionParams {
102 pub controller_did: String,
104 pub registry: String,
106 pub capabilities: Vec<Capability>,
108 pub expiry_secs: u64,
110}
111
112pub struct PairingSessionRequest {
124 pub session: auths_core::pairing::PairingSession,
126 pub create_request: auths_core::pairing::types::CreateSessionRequest,
128}
129
130pub enum PairingCompletionResult {
143 Success {
145 device_did: CanonicalDid,
147 device_name: Option<String>,
149 },
150}
151
152pub 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
181pub 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
202pub 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
235pub 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
289pub 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
365pub 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
384pub struct DeviceSigningMaterial {
392 pub seed: auths_crypto::TypedSeed,
395 pub public_key: Vec<u8>,
397 pub device_did: CanonicalDid,
399 pub controller_did: String,
401}
402
403pub enum PairingStatus {
407 SessionCreated {
409 token: Box<PairingToken>,
411 ttl_seconds: u64,
413 },
414 WaitingForApproval,
416 Approved,
418}
419
420#[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(®istry, &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(®istry, &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 let anchor = anchor_pairing_response(
534 ctx,
535 &response.responder_inception_event,
536 response.device_name.clone(),
537 )?;
538
539 relay
540 .submit_confirmation(®istry, &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
550pub struct RecoveryResult {
553 pub new_device_did: CanonicalDid,
555 pub new_device_name: Option<String>,
557 pub revoked_old_did: String,
559}
560
561pub 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 let PairingCompletionResult::Success {
593 device_did,
594 device_name,
595 } = initiate_online_pairing(params, relay, ctx, now, on_status).await?;
596
597 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 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#[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}