Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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"));
    }
}