passless-rs 0.17.0

FIDO2 security token emulator
Documentation
use crate::storage::index::load_credential_paths;
use crate::storage::tpm::TpmStorageAdapter;
use crate::storage::tpm::portable::TpmCredentialKeyProvider;
use crate::storage::tpm::portable::provider::compute_public_from_scalar;

use soft_fido2_ctap::key_provider::CredentialKeyProvider;

use std::path::{Path, PathBuf};

use log::{debug, info};
use zeroize::Zeroizing;

use passless_core::config::tpm_path;
use passless_core::{Error, Result};

const COSE_ALG_ES256: i32 = -7;

#[derive(Debug, Default)]
pub struct MigrationReport {
    pub migrated: Vec<String>,
    pub skipped: Vec<(String, String)>,
    pub failed: Vec<(String, String)>,
}

impl MigrationReport {
    pub fn already_portable_count(&self) -> usize {
        self.skipped
            .iter()
            .filter(|(_, r)| r == "already portable")
            .count()
    }

    pub fn non_es256_skip_count(&self) -> usize {
        self.skipped
            .iter()
            .filter(|(_, r)| r != "already portable")
            .count()
    }
}

pub fn migrate_credentials(
    credential_id: Option<String>,
    all: bool,
    dry_run: bool,
    backup_dir: Option<String>,
    path: Option<String>,
    tcti: Option<String>,
) -> Result<MigrationReport> {
    let storage_dir = PathBuf::from(path.as_deref().unwrap_or(&tpm_path()));
    let tcti_str = tcti.or_else(|| Some("device:/dev/tpmrm0".to_string()));

    if !storage_dir.exists() {
        return Err(Error::Other(format!(
            "TPM storage directory does not exist: {}",
            storage_dir.display()
        )));
    }

    let portable_parent_path = storage_dir.join("portable_parent.json");
    if !portable_parent_path.exists() {
        return Err(Error::Other(
            "TPM portable parent is not provisioned. \
             Run 'passless tpm provision' before migrating."
                .to_string(),
        ));
    }

    let target_cred_id = if let Some(ref hex_id) = credential_id {
        Some(
            hex::decode(hex_id)
                .map_err(|e| Error::Other(format!("Invalid credential ID hex: {}", e)))?,
        )
    } else {
        None
    };

    let cred_paths = load_credential_paths(&storage_dir, "tpm")
        .map_err(|e| Error::Other(format!("Failed to scan credentials: {}", e)))?;

    let legacy_adapter =
        TpmStorageAdapter::new_with_options(storage_dir.clone(), tcti_str.clone(), true)
            .map_err(|_| Error::Other("Failed to create legacy TPM adapter".to_string()))?;

    let mut report = MigrationReport::default();

    let candidate_paths: Vec<PathBuf> = if let Some(ref cred_id) = target_cred_id {
        cred_paths
            .id
            .values()
            .filter(|p| p.cred_id == *cred_id)
            .map(|p| p.to_path(&storage_dir))
            .collect()
    } else if all {
        cred_paths
            .id
            .values()
            .map(|p| p.to_path(&storage_dir))
            .collect()
    } else {
        Vec::new()
    };

    for cred_path in &candidate_paths {
        let cred_id_hex = cred_path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("<unknown>")
            .to_string();

        let cred = match legacy_adapter.read_credential_from_path_no_cache(cred_path) {
            Ok(c) => c,
            Err(_) => {
                debug!(
                    "Skipping {} (cannot unseal with legacy adapter, likely already portable)",
                    cred_id_hex
                );
                report
                    .skipped
                    .push((cred_id_hex, "already portable".to_string()));
                continue;
            }
        };

        if !cred.key.provider.is_software() {
            debug!("Skipping {} (already has key provider)", cred_id_hex);
            report
                .skipped
                .push((cred_id_hex, "already portable".to_string()));
            continue;
        }

        if cred.alg != COSE_ALG_ES256 {
            let reason = format!("not migratable (algorithm {} != ES256)", cred.alg);
            report.skipped.push((cred_id_hex, reason));
            continue;
        }

        let scalar_bytes = cred.key.material.as_slice();
        if scalar_bytes.len() != 32 {
            let reason = format!(
                "not migratable (private key is {} bytes, expected 32)",
                scalar_bytes.len()
            );
            report.skipped.push((cred_id_hex, reason));
            continue;
        }

        if dry_run {
            report.migrated.push(cred_id_hex);
            continue;
        }

        match migrate_one(&storage_dir, &tcti_str, cred_path, &cred, &backup_dir) {
            Ok(()) => {
                info!("Migrated credential {}", hex::encode(&cred.id));
                report.migrated.push(cred_id_hex);
            }
            Err(e) => {
                let reason = format!("migration failed: {}", e);
                report.failed.push((cred_id_hex, reason));
            }
        }
    }

    Ok(report)
}

fn migrate_one(
    storage_dir: &Path,
    tcti: &Option<String>,
    cred_path: &Path,
    cred: &soft_fido2::Credential,
    backup_dir: &Option<String>,
) -> Result<()> {
    let mut scalar = Zeroizing::new([0u8; 32]);
    scalar.copy_from_slice(cred.key.material.as_slice());

    let (pub_x, pub_y) = compute_public_from_scalar(&scalar)
        .map_err(|_| Error::Other("Failed to compute public key from scalar".to_string()))?;

    let mut expected_cose = vec![0x04];
    expected_cose.extend_from_slice(&pub_x);
    expected_cose.extend_from_slice(&pub_y);

    let provider = TpmCredentialKeyProvider::new(storage_dir.to_path_buf(), tcti.clone())
        .map_err(|_| Error::Other("Failed to create TPM key provider".to_string()))?;

    let imported = provider
        .import_existing_es256_key(&scalar)
        .map_err(|e| Error::Other(format!("Failed to import ES256 key into TPM: {:?}", e)))?;

    if imported.cose_public_key != expected_cose {
        return Err(Error::Other(
            "Imported TPM public key does not match recomputed public key".to_string(),
        ));
    }

    let test_message = b"passless-migrate-self-test";
    let signature = provider
        .sign(&imported.key, COSE_ALG_ES256, test_message)
        .map_err(|e| Error::Other(format!("Self-test signing failed: {:?}", e)))?;

    verify_signature_with_public(&imported.cose_public_key, test_message, &signature)?;

    let backup_path = resolve_backup_dir(storage_dir, backup_dir);
    std::fs::create_dir_all(&backup_path).map_err(|e| {
        Error::Other(format!(
            "Failed to create backup directory {}: {}",
            backup_path.display(),
            e
        ))
    })?;

    let rel = cred_path
        .strip_prefix(storage_dir)
        .map_err(|_| Error::Other("Credential path not under storage dir".to_string()))?;
    let backup_file = backup_path.join(rel);
    if let Some(backup_parent) = backup_file.parent() {
        std::fs::create_dir_all(backup_parent)
            .map_err(|e| Error::Other(format!("Failed to create backup subdirectory: {}", e)))?;
    }
    std::fs::copy(cred_path, &backup_file)
        .map_err(|e| Error::Other(format!("Failed to backup legacy record: {}", e)))?;
    info!("Backed up legacy record to {}", backup_file.display());

    let migrated_cred = soft_fido2::Credential {
        id: cred.id.clone(),
        rp: cred.rp.clone(),
        user: cred.user.clone(),
        sign_count: cred.sign_count,
        alg: cred.alg,
        key: imported.key,
        created: cred.created,
        discoverable: cred.discoverable,
        extensions: cred.extensions.clone(),
        backup_state: cred.backup_state,
    };

    let mut portable_adapter =
        TpmStorageAdapter::new_portable(storage_dir.to_path_buf(), tcti.clone(), true).map_err(
            |_| Error::Other("Failed to create portable TPM adapter for writing".to_string()),
        )?;

    portable_adapter
        .write_credential(&migrated_cred)
        .map_err(|e| Error::Other(format!("Failed to write portable record: {:?}", e)))?;

    Ok(())
}

fn verify_signature_with_public(
    cose_public_key: &[u8],
    message: &[u8],
    signature_der: &[u8],
) -> Result<()> {
    use p256::PublicKey;
    use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};

    let public_key = PublicKey::from_sec1_bytes(cose_public_key)
        .map_err(|e| Error::Other(format!("Failed to parse COSE public key: {}", e)))?;
    let verifying_key = VerifyingKey::from(&public_key);
    let sig = Signature::from_der(signature_der)
        .map_err(|e| Error::Other(format!("Failed to parse DER signature: {}", e)))?;
    verifying_key
        .verify(message, &sig)
        .map_err(|e| Error::Other(format!("Self-test signature verification failed: {}", e)))?;

    Ok(())
}

fn resolve_backup_dir(storage_dir: &Path, backup_dir: &Option<String>) -> PathBuf {
    if let Some(dir) = backup_dir {
        PathBuf::from(dir)
    } else {
        storage_dir.join(".backup")
    }
}