#[cfg(feature = "lan-pairing")]
pub mod lan;
mod delegation;
pub use delegation::{
JoinerPending, PairingAnchorResult, anchor_pairing_response, build_delegated_join_response,
finalize_delegated_join,
};
pub use auths_core::pairing::types::{
Base64UrlEncoded, CreateSessionRequest, SubmitConfirmationRequest, SubmitResponseRequest,
};
pub use auths_core::pairing::{
PairingResponse, PairingSession, PairingToken, QrOptions, normalize_short_code, render_qr,
};
use auths_core::pairing::SessionStatus;
use auths_core::ports::pairing::PairingRelayClient;
use auths_core::storage::keychain::{IdentityDID, KeyAlias, KeyStorage};
use auths_id::storage::identity::IdentityStorage;
use auths_verifier::types::CanonicalDid;
use chrono::{DateTime, Utc};
use crate::context::AuthsContext;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PairingError {
#[error("invalid short code format: {0}")]
InvalidShortCode(String),
#[error("session not available for pairing: {0}")]
SessionNotAvailable(String),
#[error("session expired")]
SessionExpired,
#[error("key exchange failed: {0}")]
KeyExchangeFailed(String),
#[error("attestation creation failed: {0}")]
AttestationFailed(String),
#[error("identity not found: {0}")]
IdentityNotFound(String),
#[error("device DID mismatch: response says '{response}' but key derives '{derived}'")]
DidMismatch {
response: String,
derived: String,
},
#[error("storage error: {0}")]
StorageError(String),
#[error(
"pairing requires a software-backed key; alias '{alias}' is hardware-backed and cannot export raw material"
)]
HardwareKeyNotExportable {
alias: String,
},
#[cfg(feature = "lan-pairing")]
#[error("pairing daemon error: {0}")]
DaemonError(String),
}
pub struct PairingSessionParams {
pub controller_did: String,
pub registry: String,
pub capabilities: Vec<String>,
pub expiry_secs: u64,
}
pub struct PairingSessionRequest {
pub session: auths_core::pairing::PairingSession,
pub create_request: auths_core::pairing::types::CreateSessionRequest,
}
pub enum PairingCompletionResult {
Success {
device_did: CanonicalDid,
device_name: Option<String>,
},
}
pub fn validate_short_code(code: &str) -> Result<String, PairingError> {
let normalized = normalize_short_code(code);
if normalized.len() != 6 {
return Err(PairingError::InvalidShortCode(format!(
"must be exactly 6 characters (got {})",
normalized.len()
)));
}
if !normalized.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(PairingError::InvalidShortCode(
"must contain only alphanumeric characters".to_string(),
));
}
Ok(normalized)
}
pub fn verify_session_status(
status: &auths_core::pairing::types::SessionStatus,
) -> Result<(), PairingError> {
use auths_core::pairing::types::SessionStatus;
match status {
SessionStatus::Pending => Ok(()),
SessionStatus::Expired => Err(PairingError::SessionExpired),
other => Err(PairingError::SessionNotAvailable(format!("{:?}", other))),
}
}
pub fn verify_device_did(
device_pubkey: &[u8],
curve: auths_crypto::CurveType,
claimed_did: &str,
) -> Result<(), PairingError> {
use auths_verifier::types::CanonicalDid;
let derived = CanonicalDid::from_public_key_did_key(device_pubkey, curve);
let claimed = CanonicalDid::parse(claimed_did).map_err(|_| PairingError::DidMismatch {
response: claimed_did.to_string(),
derived: derived.to_string(),
})?;
if derived != claimed {
return Err(PairingError::DidMismatch {
response: claimed.to_string(),
derived: derived.to_string(),
});
}
Ok(())
}
pub fn build_pairing_session_request(
now: DateTime<Utc>,
params: PairingSessionParams,
) -> Result<PairingSessionRequest, PairingError> {
use auths_core::pairing::PairingToken;
use auths_core::pairing::types::CreateSessionRequest;
let expiry = chrono::Duration::seconds(params.expiry_secs as i64);
let session = PairingToken::generate_with_expiry(
now,
params.controller_did,
params.registry,
params.capabilities,
expiry,
)
.map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?;
let session_id = session.token.session_id.clone();
let create_request = CreateSessionRequest {
session_id: session_id.clone(),
controller_did: session.token.controller_did.clone(),
ephemeral_pubkey: auths_core::pairing::types::Base64UrlEncoded::from_raw(
session.token.ephemeral_pubkey.clone(),
),
short_code: session.token.short_code.clone(),
capabilities: session.token.capabilities.clone(),
expires_at: session.token.expires_at.timestamp(),
recovery_target: None,
};
Ok(PairingSessionRequest {
session,
create_request,
})
}
pub fn load_device_signing_material(
ctx: &AuthsContext,
) -> Result<DeviceSigningMaterial, PairingError> {
use auths_core::crypto::signer::decrypt_keypair;
use auths_id::identity::helpers::ManagedIdentity;
let managed: ManagedIdentity = ctx
.identity_storage
.load_identity()
.map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
#[allow(clippy::disallowed_methods)]
let controller_identity_did = IdentityDID::new_unchecked(managed.controller_did.to_string());
let aliases = ctx
.key_storage
.list_aliases_for_identity(&controller_identity_did)
.map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
let key_alias = aliases
.into_iter()
.find(|a| !a.contains("--next-"))
.ok_or_else(|| {
PairingError::IdentityNotFound(format!(
"no signing key found for identity {}",
managed.controller_did
))
})?;
if ctx.key_storage.is_hardware_backend() {
return Err(PairingError::HardwareKeyNotExportable {
alias: key_alias.to_string(),
});
}
let (_did, _role, encrypted_key) = ctx
.key_storage
.load_key(&key_alias)
.map_err(|e| PairingError::StorageError(e.to_string()))?;
let prompt = format!("Enter passphrase for key '{}': ", key_alias);
let passphrase = ctx
.passphrase_provider
.get_passphrase(&prompt)
.map_err(|e| PairingError::StorageError(e.to_string()))?;
let pkcs8_bytes = decrypt_keypair(&encrypted_key, passphrase.as_str())
.map_err(|e| PairingError::StorageError(e.to_string()))?;
let parsed = auths_crypto::parse_key_material(&pkcs8_bytes)
.map_err(|e| PairingError::KeyExchangeFailed(format!("failed to parse key: {e}")))?;
let curve = parsed.seed.curve();
let device_did = CanonicalDid::from_public_key_did_key(&parsed.public_key, curve);
Ok(DeviceSigningMaterial {
seed: parsed.seed,
public_key: parsed.public_key,
device_did,
controller_did: managed.controller_did.to_string(),
})
}
pub fn load_controller_did(identity_storage: &dyn IdentityStorage) -> Result<String, PairingError> {
use auths_id::identity::helpers::ManagedIdentity;
let managed: ManagedIdentity = identity_storage
.load_identity()
.map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
Ok(managed.controller_did.into_inner())
}
pub struct DeviceSigningMaterial {
pub seed: auths_crypto::TypedSeed,
pub public_key: Vec<u8>,
pub device_did: CanonicalDid,
pub controller_did: String,
}
pub enum PairingStatus {
SessionCreated {
token: Box<PairingToken>,
ttl_seconds: u64,
},
WaitingForApproval,
Approved,
}
#[allow(clippy::too_many_lines)]
pub async fn initiate_online_pairing<R: PairingRelayClient>(
params: PairingSessionParams,
relay: &R,
ctx: &AuthsContext,
now: DateTime<Utc>,
on_status: Option<&(dyn Fn(PairingStatus) + Send + Sync)>,
) -> Result<PairingCompletionResult, PairingError> {
let registry = params.registry.clone();
let expiry = std::time::Duration::from_secs(params.expiry_secs);
let session_req = build_pairing_session_request(now, params)?;
let mut session = session_req.session;
let create_request = session_req.create_request;
let session_id = create_request.session_id.clone();
let created = relay
.create_session(®istry, &create_request)
.await
.map_err(|e| PairingError::StorageError(e.to_string()))?;
if let Some(cb) = on_status {
cb(PairingStatus::SessionCreated {
token: Box::new(session.token.clone()),
ttl_seconds: created.ttl_seconds,
});
}
if let Some(cb) = on_status {
cb(PairingStatus::WaitingForApproval);
}
let session_state = relay
.wait_for_update(®istry, &session_id, expiry)
.await
.map_err(|e| PairingError::StorageError(e.to_string()))?;
let state = match session_state {
None => return Err(PairingError::SessionExpired),
Some(s) => s,
};
match state.status {
SessionStatus::Responded => {}
SessionStatus::Cancelled => {
return Err(PairingError::SessionNotAvailable("cancelled".into()));
}
SessionStatus::Expired => return Err(PairingError::SessionExpired),
other => return Err(PairingError::SessionNotAvailable(format!("{other:?}"))),
}
let response = state.response.ok_or_else(|| {
PairingError::StorageError(
"server returned Responded status but no response payload".into(),
)
})?;
if let Some(cb) = on_status {
cb(PairingStatus::Approved);
}
let device_ecdh_bytes: Vec<u8> = response
.device_ephemeral_pubkey
.decode()
.map_err(|e| PairingError::KeyExchangeFailed(format!("invalid ephemeral pubkey: {e}")))?;
let device_signing_bytes = response
.device_signing_pubkey
.decode()
.map_err(|e| PairingError::KeyExchangeFailed(format!("invalid signing pubkey: {e}")))?;
let signature_bytes = response
.signature
.decode()
.map_err(|e| PairingError::KeyExchangeFailed(format!("invalid signature: {e}")))?;
let curve: auths_crypto::CurveType = response.curve.into();
session
.verify_response(
&device_signing_bytes,
&device_ecdh_bytes,
&signature_bytes,
curve,
)
.map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?;
let _shared_secret = session
.complete_exchange(&device_ecdh_bytes)
.map_err(|e| PairingError::KeyExchangeFailed(e.to_string()))?;
let anchor = anchor_pairing_response(
ctx,
&response.responder_inception_event,
response.device_name.clone(),
)?;
relay
.submit_confirmation(®istry, &session_id, &anchor.confirmation)
.await
.map_err(|e| PairingError::StorageError(e.to_string()))?;
Ok(PairingCompletionResult::Success {
device_did: anchor.device_did,
device_name: anchor.device_name,
})
}
pub struct RecoveryResult {
pub new_device_did: CanonicalDid,
pub new_device_name: Option<String>,
pub revoked_old_did: String,
}
pub async fn recover_device<R: PairingRelayClient>(
params: PairingSessionParams,
relay: &R,
ctx: &AuthsContext,
now: DateTime<Utc>,
old_device_did: &str,
on_status: Option<&(dyn Fn(PairingStatus) + Send + Sync)>,
) -> Result<RecoveryResult, PairingError> {
let PairingCompletionResult::Success {
device_did,
device_name,
} = initiate_online_pairing(params, relay, ctx, now, on_status).await?;
let managed = ctx
.identity_storage
.load_identity()
.map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
#[allow(clippy::disallowed_methods)]
let root_identity_did = IdentityDID::new_unchecked(managed.controller_did.to_string());
let aliases = ctx
.key_storage
.list_aliases_for_identity(&root_identity_did)
.map_err(|e| PairingError::IdentityNotFound(e.to_string()))?;
let root_alias = aliases
.into_iter()
.find(|a| !a.contains("--next-"))
.ok_or_else(|| {
PairingError::IdentityNotFound(format!(
"no signing key found for {}",
managed.controller_did
))
})?;
crate::domains::device::remove_device(ctx, &root_alias, old_device_did).map_err(|e| {
PairingError::StorageError(format!(
"replacement device {device_did} was paired, but revoking the old device \
{old_device_did} failed: {e}. Remove it manually with \
`auths device remove {old_device_did}`."
))
})?;
Ok(RecoveryResult {
new_device_did: device_did,
new_device_name: device_name,
revoked_old_did: old_device_did.to_string(),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn join_pairing_session<R: PairingRelayClient>(
ctx: &AuthsContext,
code: &str,
registry_url: &str,
relay: &R,
now: DateTime<Utc>,
curve: auths_crypto::CurveType,
device_alias: KeyAlias,
device_name: Option<String>,
confirmation_timeout: std::time::Duration,
) -> Result<PairingCompletionResult, PairingError> {
let normalized = validate_short_code(code)?;
let session_data = relay
.lookup_by_code(registry_url, &normalized)
.await
.map_err(|e| PairingError::StorageError(e.to_string()))?;
verify_session_status(&session_data.status)?;
let token_data = session_data
.token
.ok_or_else(|| PairingError::StorageError("session has no token data".into()))?;
let token = PairingToken {
controller_did: token_data.controller_did.clone(),
endpoint: registry_url.to_string(),
short_code: normalized.clone(),
session_id: session_data.session_id.clone(),
ephemeral_pubkey: token_data.ephemeral_pubkey.to_string(),
expires_at: chrono::DateTime::from_timestamp(token_data.expires_at, 0).unwrap_or(now),
capabilities: token_data.capabilities.clone(),
kem_slot: None,
daemon_spki_sha256: None,
};
if token.is_expired(now) {
return Err(PairingError::SessionExpired);
}
let device_name_out = device_name.clone();
let (submit_req, pending, _shared_secret) =
build_delegated_join_response(now, &token, curve, device_alias, device_name)?;
relay
.submit_response(registry_url, &session_data.session_id, &submit_req)
.await
.map_err(|e| PairingError::StorageError(e.to_string()))?;
let confirmation = relay
.wait_for_confirmation(registry_url, &session_data.session_id, confirmation_timeout)
.await
.map_err(|e| PairingError::StorageError(e.to_string()))?
.ok_or(PairingError::SessionExpired)?;
let device_did = finalize_delegated_join(ctx, pending, &confirmation)?;
Ok(PairingCompletionResult::Success {
device_did,
device_name: device_name_out,
})
}