auc-tool 0.1.0

Machine-local software passkey authenticator exposed through Linux UHID.
Documentation
use serde::{Deserialize, Serialize};

pub const PROTOCOL_MAJOR: u16 = 1;
pub(crate) const MAX_FRAME_BYTES: usize = 64 * 1024;

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RequestId([u8; 16]);

impl RequestId {
    pub fn random() -> Self {
        use rand::RngExt as _;

        Self(rand::rng().random())
    }
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "method")]
pub enum ApplicationRequest {
    Status,
    Touch,
    ListCredentials,
    DeleteCredential {
        credential_id: String,
    },
    #[serde(other)]
    Unknown,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "result")]
pub enum ApplicationResponse {
    Status(Status),
    Touch(TouchReceipt),
    Credentials { credentials: Vec<CredentialSummary> },
    Deleted { credential_id: String },
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Status {
    pub product: String,
    pub package: String,
    pub version: String,
    pub protocol_major: u16,
    pub device_present: bool,
    pub pending_touch: bool,
    pub credential_count: usize,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TouchReceipt {
    pub operation: String,
    pub rp_id: String,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CredentialSummary {
    pub credential_id: String,
    pub rp_id: String,
    pub user_name: Option<String>,
    pub discoverable: bool,
    pub backup_eligible: bool,
    pub backed_up: bool,
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorCode {
    BadRequest,
    Unauthorized,
    UnsupportedProtocol,
    NotFound,
    Conflict,
    Unavailable,
    Internal,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProtocolError {
    pub code: ErrorCode,
    pub message: String,
}

impl ProtocolError {
    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ApplicationError {
    #[error("auc application frame exceeds the {MAX_FRAME_BYTES}-byte limit")]
    FrameTooLarge,
    #[error("auc agent closed the connection before sending a complete frame")]
    EarlyEof,
    #[error("failed to encode auc application CBOR: {0}")]
    Encode(String),
    #[error("failed to decode auc application CBOR: {0}")]
    Decode(String),
    #[error("auc response request ID did not match the request")]
    MismatchedRequestId,
    #[error("auc application protocol v{0} is unsupported")]
    UnsupportedProtocol(u16),
    #[error("auc request failed ({code:?}): {message}")]
    Remote { code: ErrorCode, message: String },
    #[error("auc application I/O failed: {0}")]
    Io(#[from] std::io::Error),
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct RequestEnvelope {
    pub request_id: RequestId,
    pub minimum_protocol_major: u16,
    pub maximum_protocol_major: u16,
    pub request: ApplicationRequest,
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ResponseEnvelope {
    pub request_id: RequestId,
    pub protocol_major: u16,
    pub body: ResponseBody,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", tag = "status", content = "body")]
pub(crate) enum ResponseBody {
    Ok(ApplicationResponse),
    Error(ProtocolError),
}

pub(crate) fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, ApplicationError> {
    let mut bytes = Vec::new();
    ciborium::into_writer(value, &mut bytes)
        .map_err(|error| ApplicationError::Encode(error.to_string()))?;
    if bytes.len() > MAX_FRAME_BYTES {
        return Err(ApplicationError::FrameTooLarge);
    }
    Ok(bytes)
}

pub(crate) fn decode<T>(bytes: &[u8]) -> Result<T, ApplicationError>
where
    T: for<'de> Deserialize<'de>,
{
    if bytes.len() > MAX_FRAME_BYTES {
        return Err(ApplicationError::FrameTooLarge);
    }
    ciborium::from_reader(bytes).map_err(|error| ApplicationError::Decode(error.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn requests_round_trip_and_frames_are_bounded() {
        let envelope = RequestEnvelope {
            request_id: RequestId([0xDE; 16]),
            minimum_protocol_major: 1,
            maximum_protocol_major: 1,
            request: ApplicationRequest::DeleteCredential {
                credential_id: "deadbeef".to_string(),
            },
        };
        let decoded: RequestEnvelope = decode(&encode(&envelope).unwrap()).unwrap();
        assert_eq!(decoded.request_id, envelope.request_id);
        assert_eq!(decoded.request, envelope.request);
        assert!(matches!(
            decode::<RequestEnvelope>(&vec![0; MAX_FRAME_BYTES + 1]),
            Err(ApplicationError::FrameTooLarge)
        ));
    }

    #[test]
    fn protocol_v1_ignores_additive_fields() {
        let envelope = RequestEnvelope {
            request_id: RequestId([0xDE; 16]),
            minimum_protocol_major: PROTOCOL_MAJOR,
            maximum_protocol_major: PROTOCOL_MAJOR,
            request: ApplicationRequest::DeleteCredential {
                credential_id: "deadbeef".to_string(),
            },
        };
        let mut value: ciborium::Value =
            ciborium::from_reader(encode(&envelope).unwrap().as_slice()).unwrap();
        let ciborium::Value::Map(fields) = &mut value else {
            panic!("application envelope must encode as a map");
        };
        fields.push((
            ciborium::Value::Text("future-envelope-field".to_string()),
            ciborium::Value::Bool(true),
        ));
        let request = fields
            .iter_mut()
            .find_map(|(key, value)| {
                (key == &ciborium::Value::Text("request".to_string())).then_some(value)
            })
            .unwrap();
        let ciborium::Value::Map(request_fields) = request else {
            panic!("application method must encode as a map");
        };
        request_fields.push((
            ciborium::Value::Text("future-method-field".to_string()),
            ciborium::Value::Integer(0xDEADBEEF_u64.into()),
        ));
        let mut encoded = Vec::new();
        ciborium::into_writer(&value, &mut encoded).unwrap();

        let decoded: RequestEnvelope = decode(&encoded).unwrap();
        assert_eq!(decoded.request, envelope.request);
    }
}