crates/eid-core/src/lib.rs
//! # eid-core (draft)
//!
//! Shared foundation for the nordisk open eIDs — **`frida`** (EU / gRPC) and **`tyr`** (compact
//! binary) — and usable by Vetra's `heimdal` providers. This is a **seam** crate (in the spirit of
//! `nornir-warehouse-trait`): it holds only the durable *traits* and *plain-data types* both
//! protocols share. **No wire format, no network, no crypto backend** — those live in `frida` /
//! `tyr` / their backend crates.
//!
//! Three seams:
//! 1. **Holder auth** — one [`Authenticator`] interface over every channel (WebAuthn, platform
//! keystore, PKCS#11 card, Web eID), producing a verifiable [`Assertion`] for *auth* and *sign*.
//! 2. **Verification** — the server-side [`Verifier`] seam.
//! 3. **Persistence** — the [`EventStore`] seam (single-server: embedded redb/rocksdb; multi-server:
//! an append-only log with per-event ACK, large volume sinking to skade). **Never a relational
//! database.**
#![forbid(unsafe_code)]
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
mod verify;
pub use verify::{signing_input, verify_assertion};
/// Signature algorithms the eIDs use — small, modern, hardware-friendly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SigAlg {
/// Ed25519 — default for device keys.
Ed25519,
/// ECDSA P-256 — smartcards / PKCS#11 / WebAuthn.
EcdsaP256,
}
/// How a holder proves possession of its key — one interface per channel.
///
/// Channel guidance: **web page / mobile** → `WebAuthn` (browsers do not expose PKCS#11) or
/// `PlatformKey`; **desktop app / middleware with a smartcard or HSM** → `Pkcs11`; **smartcard from
/// a browser** → `WebEid` (extension + native helper, PKCS#11 underneath).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum HolderKind {
/// Web page / mobile app via WebAuthn/FIDO2 (passkeys; platform or roaming authenticators).
WebAuthn,
/// Mobile app native hardware keystore (iOS Secure Enclave / Android StrongBox).
PlatformKey,
/// Desktop app / middleware talking to a smartcard or HSM over PKCS#11.
Pkcs11,
/// Smartcard reached from a browser via the Web eID bridge.
WebEid,
}
/// A server-issued challenge the holder must sign.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Challenge {
/// Single-use random nonce — never reused.
pub nonce: Vec<u8>,
/// Opaque per-order binding (origin / RP id / order ref) the signature must cover.
pub context: Vec<u8>,
/// **Sign** orders: the exact bytes (or digest) the user approves and signs. `None` for a plain
/// **auth** order.
pub data_to_sign: Option<Vec<u8>>,
}
impl Challenge {
/// Whether this is a signing order (carries data to sign) rather than plain authentication.
pub fn is_sign(&self) -> bool {
self.data_to_sign.is_some()
}
}
/// A holder's signed response to a [`Challenge`], carrying enough for a [`Verifier`] to check it.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Assertion {
/// Which channel produced it.
pub kind: HolderKind,
/// Signature algorithm used.
pub alg: SigAlg,
/// Identifier of the signing key (WebAuthn credential id, cert thumbprint, …).
pub key_id: Vec<u8>,
/// Signature over the canonical `nonce || context || data_to_sign` input.
pub signature: Vec<u8>,
/// Channel-specific verification material (WebAuthn authenticatorData/clientDataJSON, card cert
/// chain, …) — opaque to this crate.
pub attestation: Vec<u8>,
}
// Redacting Debug: never dump signature / attestation / key bytes into logs.
impl std::fmt::Debug for Assertion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Assertion")
.field("kind", &self.kind)
.field("alg", &self.alg)
.field("key_id", &format_args!("<{} bytes>", self.key_id.len()))
.field("signature", &format_args!("<{} bytes>", self.signature.len()))
.field("attestation", &format_args!("<{} bytes>", self.attestation.len()))
.finish()
}
}
/// Client-side: obtain a signed [`Assertion`] from whatever holder the user has.
#[async_trait]
pub trait Authenticator: Send + Sync {
/// Which channel this authenticator drives.
fn kind(&self) -> HolderKind;
/// Prove identity (auth) or approve+sign data (sign, when `challenge.data_to_sign` is set).
async fn assert(&self, challenge: &Challenge) -> Result<Assertion, AuthError>;
}
/// Server-side: verify an [`Assertion`] against a trusted registration for its `key_id`.
pub trait Verifier: Send + Sync {
/// Return `Ok(())` only if the assertion is a valid, fresh signature over `challenge` by a
/// trusted, in-validity key.
fn verify(&self, challenge: &Challenge, assertion: &Assertion) -> Result<(), AuthError>;
}
/// Errors from the auth/verify seams.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AuthError {
/// The user cancelled, or the holder timed out.
#[error("holder cancelled or timed out")]
Cancelled,
/// The assertion did not verify (bad signature, unknown/expired key, replayed nonce). Hostile.
#[error("verification failed: {0}")]
Verification(String),
/// The holder or its transport failed.
#[error("holder error: {0}")]
Holder(String),
}
/// Monotonic sequence number assigned to a committed [`Event`].
pub type Seq = u64;
/// A durable, backend-agnostic event. Payload bytes are opaque to this seam.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
/// Event kind, e.g. `"auth.started"`, `"sign.completed"`, `"deploy.slot"`.
pub kind: String,
/// The order this event belongs to.
pub order_ref: String,
/// Opaque serialized payload (protocol-specific).
pub payload: Vec<u8>,
}
/// Durable persistence seam. **Never a relational database.**
///
/// - **Single server** → an embedded key-value backend (`redb` / `rocksdb`).
/// - **Multi-server** → an append-only log with **per-event ACK**: [`append`](EventStore::append)
/// returns only once the event is durably committed and acknowledged, so nodes can replicate and
/// replay deterministically. Large transactional / audit volume sinks to **skade** (the Iceberg
/// lakehouse). Concrete backends live in their own crates and implement this trait.
#[async_trait]
pub trait EventStore: Send + Sync {
/// Append `event`, returning its assigned [`Seq`] only after it is durably committed (the ACK).
async fn append(&self, event: Event) -> Result<Seq, StoreError>;
/// Events with sequence strictly greater than `after` (node catch-up / replication).
async fn since(&self, after: Seq) -> Result<Vec<Event>, StoreError>;
}
/// Errors from the persistence seam.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StoreError {
/// The backend failed.
#[error("store backend error: {0}")]
Backend(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn challenge_distinguishes_auth_from_sign() {
let auth = Challenge { nonce: vec![1], context: vec![2], data_to_sign: None };
let sign = Challenge { nonce: vec![1], context: vec![2], data_to_sign: Some(vec![3]) };
assert!(!auth.is_sign());
assert!(sign.is_sign());
}
#[test]
fn assertion_debug_redacts_bytes() {
let a = Assertion {
kind: HolderKind::WebAuthn,
alg: SigAlg::EcdsaP256,
key_id: vec![0; 8],
signature: vec![0; 64],
attestation: vec![0; 200],
};
let s = format!("{a:?}");
assert!(s.contains("<8 bytes>") && s.contains("<64 bytes>") && s.contains("<200 bytes>"));
assert!(s.contains("WebAuthn"));
}
}