//! # 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());
}
}