Gunnar

mikronova/tyr

crates/tyr-core/src/lib.rs

//! # tyr-core (draft)
//!
//! The first working piece of the `tyr` eID: a device-key **holder** and a **verifier**, both over
//! the shared [`eid_core`] auth+sign seam. This proves the auth+sign round trip end-to-end
//! (challenge → device signs → verify) *before* the compact binary wire format (`tyr-proto`) and
//! the server land on top.
//!
//! [`SoftDeviceKey`] is a software Ed25519 key standing in for a hardware-backed key (Secure
//! Enclave / StrongBox / PKCS#11 card). Because the crypto lives in `eid-core`, swapping the key
//! *source* for real hardware does not change the seam or the verifier.
#![forbid(unsafe_code)]

use std::collections::HashSet;

use async_trait::async_trait;
use ed25519_dalek::{Signer, SigningKey};
use eid_core::{
    Assertion, AuthError, Authenticator, Challenge, HolderKind, SigAlg, Verifier, signing_input,
    verify_assertion,
};

/// A software Ed25519 device key — placeholder for a hardware-backed key. The [`Authenticator`]
/// seam is identical whether the key lives here or in a secure element.
pub struct SoftDeviceKey {
    key: SigningKey,
}

impl SoftDeviceKey {
    /// Build from a 32-byte seed (a real device generates/stores this in hardware).
    pub fn from_seed(seed: [u8; 32]) -> Self {
        Self { key: SigningKey::from_bytes(&seed) }
    }

    /// The public key to register with a [`RegistryVerifier`]; also the holder's `key_id`.
    pub fn public_key(&self) -> Vec<u8> {
        self.key.verifying_key().to_bytes().to_vec()
    }
}

#[async_trait]
impl Authenticator for SoftDeviceKey {
    fn kind(&self) -> HolderKind {
        HolderKind::PlatformKey
    }

    async fn assert(&self, challenge: &Challenge) -> Result<Assertion, AuthError> {
        // Auth (no data_to_sign) and sign (with it) are the same operation over the canonical
        // signing input — eid-core defines that input, so both protocols agree byte-for-byte.
        let signature = self.key.sign(&signing_input(challenge)).to_bytes().to_vec();
        Ok(Assertion {
            kind: HolderKind::PlatformKey,
            alg: SigAlg::Ed25519,
            key_id: self.public_key(),
            signature,
            attestation: Vec::new(),
        })
    }
}

/// Verifier that trusts holder keys registered by their public key. A real `tyr` server backs the
/// registry with the `eid-core` `EventStore` (registered holders); the crypto is `eid-core`'s
/// [`verify_assertion`]. For tyr device keys, `key_id` *is* the Ed25519 public key.
#[derive(Default)]
pub struct RegistryVerifier {
    trusted: HashSet<Vec<u8>>,
}

impl RegistryVerifier {
    /// A verifier trusting no one yet.
    pub fn new() -> Self {
        Self::default()
    }

    /// Trust a holder's public key.
    pub fn register(&mut self, public_key: Vec<u8>) {
        self.trusted.insert(public_key);
    }
}

impl Verifier for RegistryVerifier {
    fn verify(&self, challenge: &Challenge, assertion: &Assertion) -> Result<(), AuthError> {
        // Trust decision first: only registered keys count. Then the cryptographic check.
        if !self.trusted.contains(&assertion.key_id) {
            return Err(AuthError::Verification("unknown/untrusted holder key".into()));
        }
        verify_assertion(challenge, assertion, &assertion.key_id)
    }
}

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

    fn challenge(order: &str, data: Option<&[u8]>) -> Challenge {
        Challenge {
            nonce: order.bytes().rev().collect(),
            context: format!("tyr;order={order}").into_bytes(),
            data_to_sign: data.map(<[u8]>::to_vec),
        }
    }

    #[tokio::test]
    async fn auth_and_sign_round_trip() {
        let device = SoftDeviceKey::from_seed([3u8; 32]);
        let mut verifier = RegistryVerifier::new();
        verifier.register(device.public_key());

        // authenticate
        let auth = challenge("1", None);
        let a = device.assert(&auth).await.unwrap();
        assert!(verifier.verify(&auth, &a).is_ok());

        // sign
        let sign = challenge("2", Some(b"pay 100"));
        let s = device.assert(&sign).await.unwrap();
        assert!(verifier.verify(&sign, &s).is_ok());

        // an unregistered device is rejected
        let rogue = SoftDeviceKey::from_seed([9u8; 32]);
        let r = rogue.assert(&auth).await.unwrap();
        assert!(verifier.verify(&auth, &r).is_err());

        // replaying the auth assertion against the sign challenge is rejected
        assert!(verifier.verify(&sign, &a).is_err());
    }
}