diff --git a/Cargo.toml b/Cargo.toml index d54e336..51646bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ resolver = "2" members = [ "mid-types", + "mid-signer", "kms-types", "kms-verifier", "kms-client", diff --git a/kms-client/Cargo.toml b/kms-client/Cargo.toml index 66da37e..bcce185 100644 --- a/kms-client/Cargo.toml +++ b/kms-client/Cargo.toml @@ -14,6 +14,10 @@ crate-type = ["rlib"] # Shared envelope types. kms-types = { path = "../kms-types", version = "0.2.0" } +# The device-signing seam: `DeviceSigner` and the in-memory signer live in +# their own leaf so implementations and consumers need not pull this crate. +mid-signer = { path = "../mid-signer", version = "0.2.0" } + # ECDSA P-256 — same primitive as kms-verifier and the existing M5 keys # managed by `mata-identity` / `mata-sync`. p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] } @@ -30,11 +34,6 @@ serde_json = "1" # Typed client errors. thiserror = "1" -# OS randomness for the `InMemoryDeviceSigner::generate` test helper. The -# trait `DeviceSigner` itself never touches the RNG; this is only for the -# helper that callers can opt out of by constructing a SigningKey themselves. -rand_core = { version = "0.6", features = ["getrandom"] } - # wasm32 needs the older getrandom 0.2 (pulled by rand_core 0.6 → p256 0.13) # enabled with the `js` feature so it can pull entropy from crypto.getRandomValues(). [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/kms-client/src/client.rs b/kms-client/src/client.rs index 138df52..432739e 100644 --- a/kms-client/src/client.rs +++ b/kms-client/src/client.rs @@ -119,9 +119,7 @@ impl KmsClient { .await .map_err(|e| ClientError::BadResponseBody(e.to_string()))?; - envelope - .validate() - .map_err(ClientError::InvalidEnvelope)?; + envelope.validate().map_err(ClientError::InvalidEnvelope)?; Ok(envelope) } diff --git a/kms-client/src/lib.rs b/kms-client/src/lib.rs index 7b5aa64..7576fee 100644 --- a/kms-client/src/lib.rs +++ b/kms-client/src/lib.rs @@ -40,11 +40,10 @@ mod client; mod sign; -mod signer; pub use client::{ClientError, KmsClient}; +pub use mid_signer::{DeviceSigner, InMemoryDeviceSigner}; pub use sign::{sign_assertion, sign_roster_update}; -pub use signer::{DeviceSigner, InMemoryDeviceSigner}; // Re-exported so callers don't need to depend on kms-types directly. pub use kms_types::{ diff --git a/kms-client/src/signer.rs b/kms-client/src/signer.rs deleted file mode 100644 index ae57844..0000000 --- a/kms-client/src/signer.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Device-signing abstraction. -//! -//! Production implementations live in `packages/ui` (or wherever the M5 -//! signing key actually resides). This crate ships only the trait and a -//! plain-software implementation usable as a test helper or bootstrap. - -use p256::ecdsa::signature::hazmat::PrehashSigner; -use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; - -/// The abstract capability `kms-client` needs from a device's signing key. -/// -/// Implementations MUST: -/// -/// 1. Return a stable, deterministic identifier for the signing device via -/// [`device_id`](DeviceSigner::device_id) — must match a `device_id` in -/// the user's DID roster. -/// 2. Produce a canonical (low-s) ECDSA P-256 signature over a 32-byte -/// prehash via [`sign_prehash`](DeviceSigner::sign_prehash). High-s -/// signatures will be rejected by `kms-verifier` as -/// [`VerifyError::SignatureInvalid`]. -pub trait DeviceSigner { - /// Stable identifier of this signing device. Matched against the user's - /// DID roster at the verifier. - fn device_id(&self) -> &str; - - /// Sign a 32-byte SHA-256 prehash. Returns the canonical (low-s) 64-byte - /// `r || s` representation. - fn sign_prehash(&self, prehash: &[u8; 32]) -> [u8; 64]; -} - -/// In-memory [`DeviceSigner`] backed by a raw [`SigningKey`]. -/// -/// Use this for tests and for a first-cut production deploy where the -/// signing key lives in the app's encrypted vault rather than in a Secure -/// Enclave / Keystore. Future hardware-backed implementations replace this -/// without changing call sites — the trait is the contract. -pub struct InMemoryDeviceSigner { - device_id: String, - signing_key: SigningKey, -} - -impl InMemoryDeviceSigner { - /// Wrap an existing [`SigningKey`] under a stable `device_id`. - pub fn new(device_id: impl Into, signing_key: SigningKey) -> Self { - Self { - device_id: device_id.into(), - signing_key, - } - } - - /// Generate a fresh P-256 keypair using OS randomness. Convenience for - /// tests and bootstrap flows. - #[cfg(not(target_arch = "wasm32"))] - pub fn generate(device_id: impl Into) -> Self { - use rand_core::OsRng; - Self::new(device_id, SigningKey::random(&mut OsRng)) - } - - /// 65-byte SEC1-uncompressed public key (`0x04 || x[32] || y[32]`). - /// Use this when adding the device to the user's roster. - pub fn pubkey_sec1(&self) -> Vec { - self.verifying_key().to_encoded_point(false).as_bytes().to_vec() - } - - /// Underlying public key handle. Most callers want - /// [`pubkey_sec1`](Self::pubkey_sec1) instead. - pub fn verifying_key(&self) -> &VerifyingKey { - self.signing_key.verifying_key() - } - - /// Borrow the underlying [`SigningKey`]. Used by tests that need to - /// construct unusual signatures for adversarial cases; production code - /// should not reach for this. - pub fn signing_key(&self) -> &SigningKey { - &self.signing_key - } -} - -impl DeviceSigner for InMemoryDeviceSigner { - fn device_id(&self) -> &str { - &self.device_id - } - - fn sign_prehash(&self, prehash: &[u8; 32]) -> [u8; 64] { - let raw: Signature = self - .signing_key - .sign_prehash(prehash) - .expect("sign_prehash with 32-byte input cannot fail for a valid key"); - // Canonicalize to low-s — the verifier rejects high-s as a - // malleability defense. - let normalized = raw.normalize_s().unwrap_or(raw); - let bytes = normalized.to_bytes(); - let mut out = [0u8; 64]; - out.copy_from_slice(bytes.as_slice()); - out - } -} diff --git a/mid-signer/Cargo.toml b/mid-signer/Cargo.toml new file mode 100644 index 0000000..51c88c5 --- /dev/null +++ b/mid-signer/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "mid-signer" +version = "0.2.0" +readme = "README.md" +license = "MIT OR Apache-2.0" +repository = "https://github.com/Remade-With-Rust/mid" +edition = "2021" +description = "The device-signing seam of MATA mID: the DeviceSigner trait (a stable device id and a low-s ECDSA P-256 signature over a 32-byte prehash) and the in-memory software signer. no_std (the trait needs no allocator), so a secure element, an eFuse key or a microcontroller implements the same contract kms-client, mid-issuer and mata-sign consume." + +[lib] +crate-type = ["rlib"] + +[features] +default = ["std"] +# `std`: p256's std error types and the `InMemoryDeviceSigner::generate` +# helper (OS randomness). `alloc`: the in-memory signer (it owns a `String` +# and hands out a `Vec`). With neither the crate is the trait and the low-s +# rule over `core` alone — what a microcontroller without an allocator links. +std = ["alloc", "p256/std", "dep:rand_core"] +alloc = ["p256/alloc"] + +[dependencies] +# ECDSA P-256 — the same primitive as kms-verifier and the M5 keys. +p256 = { version = "0.13", default-features = false, features = ["ecdsa"] } + +# OS randomness for `InMemoryDeviceSigner::generate` only. The trait never +# touches an RNG. +rand_core = { version = "0.6", features = ["getrandom"], optional = true } + +[dev-dependencies] +sha2 = "0.10" diff --git a/mid-signer/README.md b/mid-signer/README.md new file mode 100644 index 0000000..57c6084 --- /dev/null +++ b/mid-signer/README.md @@ -0,0 +1,31 @@ +# mid-signer + +The device-signing seam of MATA mID, as a leaf crate. + +Every place a device signs — `kms-client`'s assertions, `mid-issuer`'s genesis +roster, `mata-sign`'s tokens, a Janus microcontroller's capability manifest — +asks the same two things of the key: a stable device id, and a canonical +(low-s) ECDSA P-256 signature over a 32-byte prehash. That contract is the +`DeviceSigner` trait, and this crate is where it lives, so: + +- a hardware-backed implementation (secure element, eFuse key, an ESP32's DS + peripheral) depends on `mid-signer` and `p256` and nothing else — no HTTP + client, no runtime; +- every consumer names one trait, and the software signer + (`InMemoryDeviceSigner`) is the reference each implementation is checked + against: same key, same prehash, same 64 bytes (RFC 6979). + +`no_std` without the default `std` feature; `alloc` (implied by `std`) is what +`InMemoryDeviceSigner` needs, and without it the crate is the trait and the +low-s rule alone — a microcontroller with its key in hardware links nothing +else. `std` adds p256's std error types and the `generate` helper's OS +randomness. + +```rust +use mid_signer::{DeviceSigner, InMemoryDeviceSigner}; + +let signer = InMemoryDeviceSigner::generate("dev-laptop"); +let sig: [u8; 64] = signer.sign_prehash(&prehash); // low-s, always +``` + +`kms-client` re-exports both names, so existing code keeps compiling. diff --git a/mid-signer/src/lib.rs b/mid-signer/src/lib.rs new file mode 100644 index 0000000..3cad3b5 --- /dev/null +++ b/mid-signer/src/lib.rs @@ -0,0 +1,209 @@ +//! The device-signing seam of MATA mID. +//! +//! Every place a device signs — `kms-client`'s assertions, `mid-issuer`'s +//! genesis roster, `mata-sign`'s tokens, a Janus microcontroller's manifest — +//! asks for the same two things: a stable device id and a canonical (low-s) +//! ECDSA P-256 signature over a 32-byte prehash. That contract is +//! [`DeviceSigner`]. This crate is the leaf that holds it, so a hardware +//! implementation (a secure element, an eFuse-backed key, a microcontroller's +//! DS peripheral) depends on nothing but this and `p256`, and no consumer has +//! to pull an HTTP client to name the trait. +//! +//! [`InMemoryDeviceSigner`] is the plain-software implementation: tests, a +//! first-cut deploy where the key lives in an encrypted vault, and the +//! reference every other implementation is checked against (same key, same +//! prehash, same 64 bytes — RFC 6979 makes it deterministic). +//! +//! `no_std` without the `std` feature; without `alloc` as well it is the +//! trait and [`canonical_bytes`] alone, which is all a microcontroller with +//! its key in hardware needs. + +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +#[cfg(feature = "alloc")] +extern crate alloc; + +#[cfg(feature = "alloc")] +use alloc::string::String; +#[cfg(feature = "alloc")] +use alloc::vec::Vec; + +#[cfg(feature = "alloc")] +use p256::ecdsa::signature::hazmat::PrehashSigner; +use p256::ecdsa::Signature; +#[cfg(feature = "alloc")] +use p256::ecdsa::{SigningKey, VerifyingKey}; + +/// The abstract capability a device's signing key provides. +/// +/// Implementations MUST: +/// +/// 1. Return a stable, deterministic identifier for the signing device via +/// [`device_id`](DeviceSigner::device_id) — it is matched against a +/// `device_id` in the user's DID roster. +/// 2. Produce a canonical (low-s) ECDSA P-256 signature over a 32-byte +/// prehash via [`sign_prehash`](DeviceSigner::sign_prehash). Every MATA +/// verifier rejects high-s as a malleability defence. +pub trait DeviceSigner { + /// Stable identifier of this signing device. Matched against the user's + /// DID roster at the verifier. + fn device_id(&self) -> &str; + + /// Sign a 32-byte SHA-256 prehash. Returns the canonical (low-s) 64-byte + /// `r || s` representation. + fn sign_prehash(&self, prehash: &[u8; 32]) -> [u8; 64]; +} + +impl DeviceSigner for &T { + fn device_id(&self) -> &str { + (**self).device_id() + } + + fn sign_prehash(&self, prehash: &[u8; 32]) -> [u8; 64] { + (**self).sign_prehash(prehash) + } +} + +/// Canonicalise a signature to low-s and lay it out as `r || s`. +/// +/// The one function every software [`DeviceSigner`] shares, exposed so a +/// hardware-backed implementation that gets raw `(r, s)` from its part can +/// apply the same rule. +#[must_use] +pub fn canonical_bytes(raw: Signature) -> [u8; 64] { + let normalized = raw.normalize_s().unwrap_or(raw); + let bytes = normalized.to_bytes(); + let mut out = [0u8; 64]; + out.copy_from_slice(&bytes); + out +} + +/// In-memory [`DeviceSigner`] backed by a raw [`SigningKey`] (feature `alloc`). +/// +/// Use this for tests and for a first-cut production deploy where the +/// signing key lives in the app's encrypted vault rather than in a Secure +/// Enclave / Keystore. Hardware-backed implementations replace this without +/// changing call sites — the trait is the contract. +#[cfg(feature = "alloc")] +pub struct InMemoryDeviceSigner { + device_id: String, + signing_key: SigningKey, +} + +#[cfg(feature = "alloc")] +impl InMemoryDeviceSigner { + /// Wrap an existing [`SigningKey`] under a stable `device_id`. + pub fn new(device_id: impl Into, signing_key: SigningKey) -> Self { + Self { + device_id: device_id.into(), + signing_key, + } + } + + /// Generate a fresh P-256 keypair using OS randomness. Convenience for + /// tests and bootstrap flows. + #[cfg(all(feature = "std", not(target_arch = "wasm32")))] + pub fn generate(device_id: impl Into) -> Self { + use rand_core::OsRng; + Self::new(device_id, SigningKey::random(&mut OsRng)) + } + + /// 65-byte SEC1-uncompressed public key (`0x04 || x[32] || y[32]`). + /// Use this when adding the device to the user's roster. + pub fn pubkey_sec1(&self) -> Vec { + self.verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec() + } + + /// Underlying public key handle. Most callers want + /// [`pubkey_sec1`](Self::pubkey_sec1) instead. + pub fn verifying_key(&self) -> &VerifyingKey { + self.signing_key.verifying_key() + } + + /// Borrow the underlying [`SigningKey`]. Used by tests that need to + /// construct unusual signatures for adversarial cases; production code + /// should not reach for this. + pub fn signing_key(&self) -> &SigningKey { + &self.signing_key + } +} + +#[cfg(feature = "alloc")] +impl DeviceSigner for InMemoryDeviceSigner { + fn device_id(&self) -> &str { + &self.device_id + } + + fn sign_prehash(&self, prehash: &[u8; 32]) -> [u8; 64] { + let raw: Signature = self + .signing_key + .sign_prehash(prehash) + .expect("sign_prehash with 32-byte input cannot fail for a valid key"); + canonical_bytes(raw) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::signature::hazmat::PrehashVerifier; + use sha2::{Digest, Sha256}; + + fn signer() -> InMemoryDeviceSigner { + let seed: [u8; 32] = Sha256::digest(b"mid-signer test key").into(); + InMemoryDeviceSigner::new("dev-1", SigningKey::from_bytes(&seed.into()).unwrap()) + } + + #[test] + fn every_signature_is_low_s_deterministic_and_verifies() { + let s = signer(); + assert_eq!(s.device_id(), "dev-1"); + for i in 0..200u32 { + let prehash: [u8; 32] = Sha256::digest(i.to_le_bytes()).into(); + let bytes = s.sign_prehash(&prehash); + assert_eq!(bytes, s.sign_prehash(&prehash), "RFC 6979: deterministic"); + let sig = Signature::from_slice(&bytes).unwrap(); + assert!(sig.normalize_s().is_none(), "low-s, always"); + s.verifying_key().verify_prehash(&prehash, &sig).unwrap(); + } + } + + #[test] + fn canonical_bytes_flips_a_high_s_and_leaves_a_low_s() { + let s = signer(); + let prehash: [u8; 32] = Sha256::digest(b"x").into(); + let low = Signature::from_slice(&s.sign_prehash(&prehash)).unwrap(); + let (r, sc) = low.split_scalars(); + let high = Signature::from_scalars(r, -*sc).unwrap(); + assert!(high.normalize_s().is_some()); + assert_eq!(canonical_bytes(high), canonical_bytes(low)); + assert_eq!(canonical_bytes(low), s.sign_prehash(&prehash)); + } + + #[test] + fn a_reference_to_a_signer_is_a_signer() { + fn takes(signer: impl DeviceSigner) -> [u8; 64] { + signer.sign_prehash(&[7u8; 32]) + } + let s = signer(); + assert_eq!(takes(&s), s.sign_prehash(&[7u8; 32])); + let dynamic: &dyn DeviceSigner = &s; + assert_eq!(dynamic.device_id(), "dev-1"); + } + + #[test] + fn pubkey_is_sec1_uncompressed() { + let s = signer(); + let pk = s.pubkey_sec1(); + assert_eq!(pk.len(), 65); + assert_eq!(pk[0], 0x04); + assert_eq!( + VerifyingKey::from_sec1_bytes(&pk).unwrap(), + *s.verifying_key() + ); + } +}