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_verifier::types::CanonicalDid;
30use chrono::{DateTime, Utc};
31
32use crate::context::AuthsContext;
33
34#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum PairingError {
38 #[error("invalid short code format: {0}")]
40 InvalidShortCode(String),
41 #[error("session not available for pairing: {0}")]
43 SessionNotAvailable(String),
44 #[error("session expired")]
46 SessionExpired,
47 #[error("key exchange failed: {0}")]
49 KeyExchangeFailed(String),
50 #[error("attestation creation failed: {0}")]
52 AttestationFailed(String),
53 #[error("identity not found: {0}")]
55 IdentityNotFound(String),
56 #[error("device DID mismatch: response says '{response}' but key derives '{derived}'")]
58 DidMismatch {
59 response: String,
61 derived: String,
63 },
64 #[error("storage error: {0}")]
66 StorageError(String),
67 #[error(
70 "pairing requires a software-backed key; alias '{alias}' is hardware-backed and cannot export raw material"
71 )]
72 HardwareKeyNotExportable {
73 alias: String,
75 },
76 #[cfg(feature = "lan-pairing")]
78 #[error("pairing daemon error: {0}")]
79 DaemonError(String),
80}
81
82pub struct PairingSessionParams {
100 pub controller_did: String,
102 pub registry: String,
104 pub capabilities: Vec<String>,
106 pub expiry_secs: u64,
108}
109
110pub struct PairingSessionRequest {
122 pub session: auths_core::pairing::PairingSession,
124 pub create_request: auths_core::pairing::types::CreateSessionRequest,
126}
127
128pub enum PairingCompletionResult {
141 Success {
143 device_did: CanonicalDid,
145 device_name: Option<String>,
147 },
148}
149
150pub 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
179pub 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
200pub 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
233pub 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
287pub 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 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
366pub 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
385pub struct DeviceSigningMaterial {
393 pub seed: auths_crypto::TypedSeed,
396 pub public_key: Vec<u8>,
398 pub device_did: CanonicalDid,
400 pub controller_did: String,
402}
403
404pub enum PairingStatus {
408 SessionCreated {
410 token: Box<PairingToken>,
412 ttl_seconds: u64,
414 },
415 WaitingForApproval,
417 Approved,
419}
420
421#[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(®istry, &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(®istry, &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 let anchor = anchor_pairing_response(
535 ctx,
536 &response.responder_inception_event,
537 response.device_name.clone(),
538 )?;
539
540 relay
541 .submit_confirmation(®istry, &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
551pub struct RecoveryResult {
554 pub new_device_did: CanonicalDid,
556 pub new_device_name: Option<String>,
558 pub revoked_old_did: String,
560}
561
562pub 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 let PairingCompletionResult::Success {
594 device_did,
595 device_name,
596 } = initiate_online_pairing(params, relay, ctx, now, on_status).await?;
597
598 let managed = ctx
600 .identity_storage
601 .load_identity()
602 .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
603 #[allow(clippy::disallowed_methods)]
604 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 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#[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}