auths_sdk/pairing.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
7use auths_core::pairing::normalize_short_code;
8use auths_core::ports::clock::ClockProvider;
9use auths_core::signing::PassphraseProvider;
10use auths_core::storage::keychain::{KeyAlias, KeyStorage};
11use auths_id::attestation::export::AttestationSink;
12use auths_id::storage::identity::IdentityStorage;
13use chrono::{DateTime, Utc};
14use std::path::PathBuf;
15use std::sync::Arc;
16
17/// Errors from pairing operations.
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum PairingError {
21 /// The short code format is invalid.
22 #[error("invalid short code format: {0}")]
23 InvalidShortCode(String),
24 /// The session is not in the expected state for pairing.
25 #[error("session not available for pairing: {0}")]
26 SessionNotAvailable(String),
27 /// The pairing session has expired.
28 #[error("session expired")]
29 SessionExpired,
30 /// The ephemeral ECDH key exchange failed.
31 #[error("key exchange failed: {0}")]
32 KeyExchangeFailed(String),
33 /// Creating the device attestation failed.
34 #[error("attestation creation failed: {0}")]
35 AttestationFailed(String),
36 /// The identity could not be loaded from storage.
37 #[error("identity not found: {0}")]
38 IdentityNotFound(String),
39 /// The DID derived from the device public key does not match the claimed DID.
40 #[error("device DID mismatch: response says '{response}' but key derives '{derived}'")]
41 DidMismatch {
42 /// The DID claimed by the responding device.
43 response: String,
44 /// The DID derived from the device's public key.
45 derived: String,
46 },
47 /// A storage operation failed during pairing.
48 #[error("storage error: {0}")]
49 StorageError(String),
50}
51
52/// Parameters for initiating a new pairing session.
53///
54/// Args:
55/// * `controller_did`: DID of the identity initiating the pairing.
56/// * `registry`: Registry endpoint URL.
57/// * `capabilities`: Capability strings to grant to the paired device.
58/// * `expiry_secs`: Session lifetime in seconds.
59///
60/// Usage:
61/// ```ignore
62/// let params = PairingSessionParams {
63/// controller_did: "did:keri:abc123".into(),
64/// registry: "https://registry.auths.dev".into(),
65/// capabilities: vec!["sign_commit".into()],
66/// expiry_secs: 300,
67/// };
68/// ```
69pub struct PairingSessionParams {
70 /// DID of the identity initiating the pairing.
71 pub controller_did: String,
72 /// Registry endpoint URL.
73 pub registry: String,
74 /// Capability strings to grant to the paired device.
75 pub capabilities: Vec<String>,
76 /// Session lifetime in seconds.
77 pub expiry_secs: u64,
78}
79
80/// The result of building a pairing session request.
81///
82/// Contains the live session (for ECDH later) and the registration payload
83/// to POST to the registry.
84///
85/// Usage:
86/// ```ignore
87/// let req = build_pairing_session_request(params)?;
88/// client.post(url).json(&req.create_request).send().await?;
89/// let shared_secret = req.session.complete_exchange(&device_pubkey)?;
90/// ```
91pub struct PairingSessionRequest {
92 /// The live pairing session with the ephemeral ECDH keypair.
93 pub session: auths_core::pairing::PairingSession,
94 /// The registration payload to POST to the registry.
95 pub create_request: auths_core::pairing::types::CreateSessionRequest,
96}
97
98/// Decrypted pairing response payload from the responding device.
99///
100/// Built by the CLI after completing ECDH and resolving the identity key.
101/// Passed to [`complete_pairing_from_response`] for attestation creation.
102///
103/// Args:
104/// * `auths_dir`: Path to the `~/.auths` identity repository.
105/// * `device_pubkey`: Ed25519 signing public key bytes (32 bytes).
106/// * `device_did`: DID string of the responding device.
107/// * `device_name`: Optional human-readable device name.
108/// * `capabilities`: Capability strings to grant.
109/// * `identity_key_alias`: Resolved keychain alias for the identity key.
110///
111/// Usage:
112/// ```ignore
113/// let response = DecryptedPairingResponse {
114/// auths_dir: auths_dir.to_path_buf(),
115/// device_pubkey: pubkey_bytes,
116/// device_did: "did:key:z6Mk...".into(),
117/// device_name: Some("iPhone 15".into()),
118/// capabilities: vec!["sign_commit".into()],
119/// identity_key_alias: "main".into(),
120/// };
121/// ```
122pub struct DecryptedPairingResponse {
123 /// Path to the `~/.auths` identity repository.
124 pub auths_dir: PathBuf,
125 /// Ed25519 signing public key bytes (32 bytes).
126 pub device_pubkey: Vec<u8>,
127 /// DID string of the responding device.
128 pub device_did: String,
129 /// Optional human-readable device name.
130 pub device_name: Option<String>,
131 /// Capability strings to grant.
132 pub capabilities: Vec<String>,
133 /// Resolved keychain alias for the identity key.
134 pub identity_key_alias: KeyAlias,
135}
136
137/// Outcome of a completed pairing operation.
138///
139/// Usage:
140/// ```ignore
141/// match result {
142/// PairingCompletionResult::Success { device_did, .. } => println!("Paired {}", device_did),
143/// PairingCompletionResult::Fallback { error, .. } => {
144/// eprintln!("Attestation failed: {}", error);
145/// save_device_info(auths_dir, &raw_response)?;
146/// }
147/// }
148/// ```
149pub enum PairingCompletionResult {
150 /// Pairing completed successfully with a signed attestation.
151 Success {
152 /// The DID of the paired device.
153 device_did: String,
154 /// Optional human-readable name of the paired device.
155 device_name: Option<String>,
156 },
157 /// Attestation creation failed; caller should fall back to raw device info storage.
158 Fallback {
159 /// The DID of the device that could not be fully attested.
160 device_did: String,
161 /// Optional human-readable name of the device.
162 device_name: Option<String>,
163 /// The error message from the failed attestation attempt.
164 error: String,
165 },
166}
167
168/// Parameters for creating a pairing attestation.
169///
170/// Args:
171/// * `identity_storage`: Pre-initialized identity storage adapter.
172/// * `key_storage`: Pre-initialized key storage for signing key access.
173/// * `device_pubkey`: The device's Ed25519 public key (32 bytes).
174/// * `device_did_str`: The device's DID string.
175/// * `capabilities`: List of capability strings to grant.
176/// * `identity_key_alias`: The key alias to use for signing.
177/// * `passphrase_provider`: Provider for the signing passphrase.
178///
179/// Usage:
180/// ```ignore
181/// let attestation = create_pairing_attestation(¶ms, now)?;
182/// ```
183pub struct PairingAttestationParams<'a> {
184 /// Pre-initialized identity storage adapter.
185 pub identity_storage: Arc<dyn IdentityStorage + Send + Sync>,
186 /// Pre-initialized key storage for signing key access.
187 pub key_storage: Arc<dyn KeyStorage + Send + Sync>,
188 /// The device's Ed25519 public key (32 bytes).
189 pub device_pubkey: &'a [u8],
190 /// The device's DID string.
191 pub device_did_str: &'a str,
192 /// List of capability strings to grant.
193 pub capabilities: &'a [String],
194 /// The key alias to use for signing.
195 pub identity_key_alias: &'a KeyAlias,
196 /// Provider for the signing passphrase.
197 pub passphrase_provider:
198 std::sync::Arc<dyn auths_core::signing::PassphraseProvider + Send + Sync>,
199}
200
201/// Validate and normalize a pairing short code.
202///
203/// Args:
204/// * `code`: The raw short code input from the user.
205///
206/// Usage:
207/// ```ignore
208/// let normalized = validate_short_code("ABC-123")?;
209/// assert_eq!(normalized, "ABC123");
210/// ```
211pub fn validate_short_code(code: &str) -> Result<String, PairingError> {
212 let normalized = normalize_short_code(code);
213
214 if normalized.len() != 6 {
215 return Err(PairingError::InvalidShortCode(format!(
216 "must be exactly 6 characters (got {})",
217 normalized.len()
218 )));
219 }
220
221 if !normalized.chars().all(|c| c.is_ascii_alphanumeric()) {
222 return Err(PairingError::InvalidShortCode(
223 "must contain only alphanumeric characters".to_string(),
224 ));
225 }
226
227 Ok(normalized)
228}
229
230/// Verify that a pairing session is in the correct state for pairing.
231///
232/// Args:
233/// * `status`: The session status from the registry.
234///
235/// Usage:
236/// ```ignore
237/// verify_session_status(&session_status)?;
238/// ```
239pub fn verify_session_status(
240 status: &auths_core::pairing::types::SessionStatus,
241) -> Result<(), PairingError> {
242 use auths_core::pairing::types::SessionStatus;
243
244 match status {
245 SessionStatus::Pending => Ok(()),
246 SessionStatus::Expired => Err(PairingError::SessionExpired),
247 other => Err(PairingError::SessionNotAvailable(format!("{:?}", other))),
248 }
249}
250
251/// Verify that a derived device DID matches the claimed DID.
252///
253/// Args:
254/// * `device_pubkey`: The device's Ed25519 public key (32 bytes).
255/// * `claimed_did`: The DID string claimed by the device.
256///
257/// Usage:
258/// ```ignore
259/// verify_device_did(&pubkey_bytes, "did:key:z...")?;
260/// ```
261pub fn verify_device_did(device_pubkey: &[u8; 32], claimed_did: &str) -> Result<(), PairingError> {
262 use auths_verifier::types::DeviceDID;
263
264 let derived = DeviceDID::from_ed25519(device_pubkey);
265 let claimed = DeviceDID::new(claimed_did.to_string());
266
267 if derived != claimed {
268 return Err(PairingError::DidMismatch {
269 response: claimed.to_string(),
270 derived: derived.to_string(),
271 });
272 }
273
274 Ok(())
275}
276
277/// Create a signed device attestation for a paired device.
278///
279/// Args:
280/// * `params`: Attestation creation parameters.
281///
282/// Usage:
283/// ```ignore
284/// let attestation = create_pairing_attestation(&PairingAttestationParams {
285/// auths_dir: Path::new("~/.auths"),
286/// device_pubkey: &pubkey_bytes,
287/// device_did_str: "did:key:z...",
288/// capabilities: &["sign_commit".to_string()],
289/// identity_key_alias: "main",
290/// passphrase_provider: provider,
291/// })?;
292/// ```
293pub fn create_pairing_attestation(
294 params: &PairingAttestationParams,
295 now: DateTime<Utc>,
296) -> Result<auths_verifier::core::Attestation, PairingError> {
297 use auths_core::signing::StorageSigner;
298 use auths_id::attestation::create::create_signed_attestation;
299 use auths_id::identity::helpers::ManagedIdentity;
300 use auths_id::storage::git_refs::AttestationMetadata;
301 use auths_verifier::Capability;
302 use auths_verifier::types::DeviceDID;
303
304 let managed_identity: ManagedIdentity = params
305 .identity_storage
306 .load_identity()
307 .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
308
309 let controller_did = managed_identity.controller_did;
310 let rid = managed_identity.storage_id;
311
312 let device_pubkey_32: &[u8; 32] = params.device_pubkey.try_into().map_err(|_| {
313 PairingError::AttestationFailed("device public key must be 32 bytes".into())
314 })?;
315
316 verify_device_did(device_pubkey_32, params.device_did_str)?;
317
318 let meta = AttestationMetadata {
319 timestamp: Some(now),
320 expires_at: None,
321 note: Some("Paired via QR".to_string()),
322 };
323
324 let device_capabilities: Vec<Capability> = params
325 .capabilities
326 .iter()
327 .map(|s| {
328 s.parse::<Capability>()
329 .map_err(|e| PairingError::AttestationFailed(format!("invalid capability: {e}")))
330 })
331 .collect::<Result<Vec<_>, _>>()?;
332
333 let target_did = DeviceDID::new(params.device_did_str.to_string());
334 let secure_signer = StorageSigner::new(Arc::clone(¶ms.key_storage));
335
336 let attestation = create_signed_attestation(
337 now,
338 &rid,
339 &controller_did,
340 &target_did,
341 params.device_pubkey,
342 None,
343 &meta,
344 &secure_signer,
345 params.passphrase_provider.as_ref(),
346 Some(params.identity_key_alias),
347 None,
348 device_capabilities,
349 None,
350 None,
351 )
352 .map_err(|e| PairingError::AttestationFailed(e.to_string()))?;
353
354 Ok(attestation)
355}
356
357/// Build a pairing session and its registry registration payload.
358///
359/// Generates a new `PairingSession` with an ephemeral X25519 keypair and
360/// constructs the `CreateSessionRequest` ready to POST to the registry.
361///
362/// Args:
363/// * `params`: Session parameters (controller DID, registry, capabilities, expiry).
364///
365/// Usage:
366/// ```ignore
367/// let req = build_pairing_session_request(PairingSessionParams {
368/// controller_did: "did:keri:abc123".into(),
369/// registry: "https://registry.auths.dev".into(),
370/// capabilities: vec!["sign_commit".into()],
371/// expiry_secs: 300,
372/// })?;
373/// client.post(&url).json(&req.create_request).send().await?;
374/// ```
375pub fn build_pairing_session_request(
376 now: DateTime<Utc>,
377 params: PairingSessionParams,
378) -> Result<PairingSessionRequest, PairingError> {
379 use auths_core::pairing::PairingToken;
380 use auths_core::pairing::types::CreateSessionRequest;
381
382 let expiry = chrono::Duration::seconds(params.expiry_secs as i64);
383 let session = PairingToken::generate_with_expiry(
384 now,
385 params.controller_did,
386 params.registry,
387 params.capabilities,
388 expiry,
389 )
390 .map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?;
391
392 let session_id = session.token.short_code.clone();
393 let create_request = CreateSessionRequest {
394 session_id: session_id.clone(),
395 controller_did: session.token.controller_did.clone(),
396 ephemeral_pubkey: auths_core::pairing::types::Base64UrlEncoded::from_raw(
397 session.token.ephemeral_pubkey.clone(),
398 ),
399 short_code: session.token.short_code.clone(),
400 capabilities: session.token.capabilities.clone(),
401 expires_at: session.token.expires_at.timestamp(),
402 };
403
404 Ok(PairingSessionRequest {
405 session,
406 create_request,
407 })
408}
409
410/// Complete a pairing operation from a decrypted device response.
411///
412/// Creates and exports a signed device attestation. Returns
413/// [`PairingCompletionResult::Success`] on success, or
414/// [`PairingCompletionResult::Fallback`] when attestation creation fails
415/// so the caller can perform alternative device info storage.
416///
417/// This function performs no I/O beyond attestation persistence and holds
418/// no mutable session state — it is fully testable without a live connection.
419///
420/// Args:
421/// * `response`: Decrypted response payload built by the CLI after ECDH.
422/// * `passphrase_provider`: Provider for the identity key passphrase.
423///
424/// Usage:
425/// ```ignore
426/// let result = complete_pairing_from_response(decrypted, provider)?;
427/// match result {
428/// PairingCompletionResult::Success { device_did, .. } => println!("Paired"),
429/// PairingCompletionResult::Fallback { error, .. } => {
430/// eprintln!("Attestation failed: {}", error);
431/// }
432/// }
433/// ```
434pub fn complete_pairing_from_response(
435 response: DecryptedPairingResponse,
436 identity_storage: Arc<dyn IdentityStorage + Send + Sync>,
437 attestation_sink: Arc<dyn AttestationSink + Send + Sync>,
438 key_storage: Arc<dyn KeyStorage + Send + Sync>,
439 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
440 clock: &dyn ClockProvider,
441) -> Result<PairingCompletionResult, PairingError> {
442 let now = clock.now();
443
444 let DecryptedPairingResponse {
445 device_pubkey,
446 device_did,
447 device_name,
448 capabilities,
449 identity_key_alias,
450 ..
451 } = response;
452
453 let attestation_result = {
454 let params = PairingAttestationParams {
455 identity_storage,
456 key_storage,
457 device_pubkey: &device_pubkey,
458 device_did_str: &device_did,
459 capabilities: &capabilities,
460 identity_key_alias: &identity_key_alias,
461 passphrase_provider,
462 };
463 create_pairing_attestation(¶ms, now)
464 };
465
466 let attestation = match attestation_result {
467 Ok(a) => a,
468 Err(e) => {
469 return Ok(PairingCompletionResult::Fallback {
470 device_did,
471 device_name,
472 error: e.to_string(),
473 });
474 }
475 };
476
477 attestation_sink
478 .export(&auths_verifier::VerifiedAttestation::dangerous_from_unchecked(attestation.clone()))
479 .map_err(|e| PairingError::StorageError(e.to_string()))?;
480
481 attestation_sink.sync_index(&attestation);
482
483 Ok(PairingCompletionResult::Success {
484 device_did,
485 device_name,
486 })
487}
488
489/// Load the controller DID from a pre-initialized identity storage adapter.
490///
491/// Args:
492/// * `identity_storage`: Pre-initialized identity storage adapter.
493///
494/// Usage:
495/// ```ignore
496/// let did = load_controller_did(identity_storage.as_ref())?;
497/// ```
498pub fn load_controller_did(identity_storage: &dyn IdentityStorage) -> Result<String, PairingError> {
499 use auths_id::identity::helpers::ManagedIdentity;
500
501 let managed: ManagedIdentity = identity_storage
502 .load_identity()
503 .map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
504
505 Ok(managed.controller_did.into_inner())
506}