Skip to main content

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(&params, 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(&params.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(&params, 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}