passless-rs 0.17.0

FIDO2 security token emulator
Documentation
use std::fs;

use tempfile::tempdir;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TestRelyingParty {
    id: String,
    name: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TestUser {
    id: Vec<u8>,
    name: Option<String>,
    display_name: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct TestExtensions {
    cred_protect: Option<u8>,
    hmac_secret: Option<bool>,
    cred_random: Option<Vec<u8>>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TestCredential {
    id: Vec<u8>,
    rp: TestRelyingParty,
    user: TestUser,
    sign_count: u32,
    alg: i32,
    private_key: Vec<u8>,
    created: i64,
    discoverable: bool,
    #[serde(default)]
    extensions: TestExtensions,
}

fn make_test_cred(rp_id: &str, user_id: &[u8], cred_id: &[u8]) -> TestCredential {
    TestCredential {
        id: cred_id.to_vec(),
        rp: TestRelyingParty {
            id: rp_id.to_string(),
            name: Some("Test RP".to_string()),
        },
        user: TestUser {
            id: user_id.to_vec(),
            name: Some("testuser@example.com".to_string()),
            display_name: Some("Test User".to_string()),
        },
        sign_count: 0,
        alg: -7,
        private_key: vec![0x42; 32],
        created: 1700000000,
        discoverable: true,
        extensions: TestExtensions::default(),
    }
}

fn serialize_cred(cred: &TestCredential) -> Vec<u8> {
    let mut buf = Vec::new();
    ciborium::into_writer(cred, &mut buf).unwrap();
    buf
}

fn deserialize_cred(data: &[u8]) -> Result<TestCredential, String> {
    ciborium::from_reader(data).map_err(|e| format!("deserialization error: {}", e))
}

#[test]
fn credential_cbor_round_trip() {
    let cred = make_test_cred("example.com", &[1, 2, 3], &[0xAA; 16]);
    let bytes = serialize_cred(&cred);
    let decoded = deserialize_cred(&bytes).unwrap();
    assert_eq!(decoded.id, cred.id);
    assert_eq!(decoded.rp.id, "example.com");
    assert_eq!(decoded.user.id, vec![1, 2, 3]);
    assert_eq!(decoded.sign_count, 0);
    assert_eq!(decoded.alg, -7);
}

#[test]
fn sign_count_preserved_through_serialization() {
    let mut cred = make_test_cred("example.com", &[1], &[0xBB; 16]);
    cred.sign_count = 42;
    let bytes = serialize_cred(&cred);
    let decoded = deserialize_cred(&bytes).unwrap();
    assert_eq!(decoded.sign_count, 42);
}

#[test]
fn sign_count_increment_serialization() {
    let mut cred = make_test_cred("example.com", &[1], &[0xCC; 16]);
    cred.sign_count = 100;
    let bytes = serialize_cred(&cred);
    let mut decoded = deserialize_cred(&bytes).unwrap();
    decoded.sign_count += 1;
    let bytes2 = serialize_cred(&decoded);
    let decoded2 = deserialize_cred(&bytes2).unwrap();
    assert_eq!(decoded2.sign_count, 101);
}

#[test]
fn corrupted_bytes_rejected() {
    let result = deserialize_cred(b"this is not valid cbor data");
    assert!(result.is_err());
}

#[test]
fn truncated_bytes_rejected() {
    let cred = make_test_cred("example.com", &[1], &[0xDD; 16]);
    let bytes = serialize_cred(&cred);
    let truncated = &bytes[..bytes.len() / 2];
    let result = deserialize_cred(truncated);
    assert!(result.is_err());
}

#[test]
fn empty_bytes_rejected() {
    let result = deserialize_cred(b"");
    assert!(result.is_err());
}

#[test]
fn extensions_round_trip() {
    let mut cred = make_test_cred("example.com", &[1], &[0xEE; 16]);
    cred.extensions.cred_protect = Some(2);
    cred.extensions.hmac_secret = Some(true);
    cred.extensions.cred_random = Some(vec![0x11; 32]);

    let bytes = serialize_cred(&cred);
    let decoded = deserialize_cred(&bytes).unwrap();
    assert_eq!(decoded.extensions.cred_protect, Some(2));
    assert_eq!(decoded.extensions.hmac_secret, Some(true));
    assert_eq!(decoded.extensions.cred_random, Some(vec![0x11; 32]));
}

#[test]
fn missing_extensions_field_defaults() {
    let cred = make_test_cred("example.com", &[1], &[0xFF; 16]);
    let bytes = serialize_cred(&cred);
    let decoded = deserialize_cred(&bytes).unwrap();
    assert_eq!(decoded.extensions.cred_protect, None);
    assert_eq!(decoded.extensions.hmac_secret, None);
    assert_eq!(decoded.extensions.cred_random, None);
}

#[test]
fn on_disk_layout_creates_rp_directories() {
    let dir = tempdir().unwrap();
    let rp_dir = dir.path().join("example.com");
    fs::create_dir_all(&rp_dir).unwrap();

    let cred = make_test_cred("example.com", &[1], &[0x01; 16]);
    let bytes = serialize_cred(&cred);
    let cred_path = rp_dir.join(format!("{}.bin", hex::encode([0x01; 16])));
    fs::write(&cred_path, &bytes).unwrap();

    assert!(cred_path.exists());
    let read_back = fs::read(&cred_path).unwrap();
    let decoded = deserialize_cred(&read_back).unwrap();
    assert_eq!(decoded.rp.id, "example.com");
}

#[test]
fn multiple_credentials_in_same_rp_directory() {
    let dir = tempdir().unwrap();
    let rp_dir = dir.path().join("example.com");
    fs::create_dir_all(&rp_dir).unwrap();

    for i in 0..5u8 {
        let cred = make_test_cred("example.com", &[i], &[i; 16]);
        let bytes = serialize_cred(&cred);
        let path = rp_dir.join(format!("{}.bin", hex::encode([i; 16])));
        fs::write(&path, &bytes).unwrap();
    }

    let entries: Vec<_> = fs::read_dir(&rp_dir).unwrap().collect();
    assert_eq!(entries.len(), 5);
}

#[test]
fn different_rps_in_separate_directories() {
    let dir = tempdir().unwrap();

    for rp in &["a.com", "b.com", "c.com"] {
        let rp_dir = dir.path().join(rp);
        fs::create_dir_all(&rp_dir).unwrap();
        let cred = make_test_cred(rp, &[1], &[0x01; 16]);
        let bytes = serialize_cred(&cred);
        let path = rp_dir.join("01010101010101010101010101010101.bin");
        fs::write(&path, &bytes).unwrap();
    }

    assert!(dir.path().join("a.com").is_dir());
    assert!(dir.path().join("b.com").is_dir());
    assert!(dir.path().join("c.com").is_dir());
}

#[test]
fn credential_with_large_user_id_round_trips() {
    let large_user_id = vec![0xAB; 256];
    let cred = make_test_cred("example.com", &large_user_id, &[0x01; 16]);
    let bytes = serialize_cred(&cred);
    let decoded = deserialize_cred(&bytes).unwrap();
    assert_eq!(decoded.user.id, large_user_id);
}

#[test]
fn credential_with_max_alg_values() {
    for alg in &[-7, -8, -25, -35, -36] {
        let mut cred = make_test_cred("example.com", &[1], &[0x01; 16]);
        cred.alg = *alg;
        let bytes = serialize_cred(&cred);
        let decoded = deserialize_cred(&bytes).unwrap();
        assert_eq!(decoded.alg, *alg);
    }
}

#[test]
fn empty_rp_name_round_trips() {
    let mut cred = make_test_cred("example.com", &[1], &[0x01; 16]);
    cred.rp.name = None;
    let bytes = serialize_cred(&cred);
    let decoded = deserialize_cred(&bytes).unwrap();
    assert_eq!(decoded.rp.name, None);
}

#[test]
fn empty_user_name_round_trips() {
    let mut cred = make_test_cred("example.com", &[1], &[0x01; 16]);
    cred.user.name = None;
    cred.user.display_name = None;
    let bytes = serialize_cred(&cred);
    let decoded = deserialize_cred(&bytes).unwrap();
    assert_eq!(decoded.user.name, None);
    assert_eq!(decoded.user.display_name, None);
}

#[test]
fn serialized_size_is_bounded() {
    let cred = make_test_cred("example.com", &[1; 64], &[0x01; 64]);
    let bytes = serialize_cred(&cred);
    assert!(
        bytes.len() < 1024,
        "credential serialization should be < 1KB"
    );
}

#[test]
fn file_corruption_detected() {
    let dir = tempdir().unwrap();
    let rp_dir = dir.path().join("example.com");
    fs::create_dir_all(&rp_dir).unwrap();

    let cred = make_test_cred("example.com", &[1], &[0x01; 16]);
    let mut bytes = serialize_cred(&cred);
    let mid = bytes.len() / 2;
    bytes[mid] ^= 0xFF;
    let path = rp_dir.join("01010101010101010101010101010101.bin");
    fs::write(&path, &bytes).unwrap();

    let read_back = fs::read(&path).unwrap();
    let result = deserialize_cred(&read_back);
    assert!(
        result.is_err(),
        "corrupted file should fail deserialization"
    );
}