diff --git a/Cargo.lock b/Cargo.lock index af83de1..cff1a89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2595,6 +2595,7 @@ name = "iroh-endpoint-core" version = "0.1.0" dependencies = [ "blake3", + "ed25519-dalek 3.0.0", "hex", "polymorph-tls-profile", "polymorph-tls-quic", diff --git a/core/Cargo.toml b/core/Cargo.toml index 1692ad2..dc599ae 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -5,6 +5,11 @@ edition.workspace = true publish.workspace = true description = "Shared endpoint core: the webcrypto-held identity, RPK TLS configs over the polymorph:tls profile, and relay wire framing" +[features] +# In-guest Ed25519 signing from a raw private-key seed: opt-in, because it +# puts the identity's private key in guest memory. +guest-ed25519-signing = ["dep:ed25519-dalek"] + [dependencies] # The TLS 1.3 crypto core: the polymorph:tls sibling's wasm-safe profile with # QUIC packet protection and quinn session glue, all in-guest RustCrypto. @@ -17,6 +22,9 @@ hex = "0.4" # encode and decode them with postcard itself, not by hand. postcard = { version = "1", default-features = false, features = ["alloc"] } serde = { version = "1", default-features = false, features = ["derive"] } +# Only the `guest-ed25519-signing` path. `zeroize` wipes the key on drop; +# `fast` (precomputed tables) stays off — the guest is size-optimised. +ed25519-dalek = { version = "3", default-features = false, features = ["zeroize"], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] blake3 = { version = "1", default-features = false } diff --git a/core/src/crypto/mod.rs b/core/src/crypto/mod.rs index 84f95fa..f59b3c4 100644 --- a/core/src/crypto/mod.rs +++ b/core/src/crypto/mod.rs @@ -4,8 +4,9 @@ //! Everything else that was once here — the record-protection suite, key //! exchange, verification — now comes from the `polymorph:tls` sibling's //! curated crates (`polymorph-tls-quic`), which run those surfaces in-guest -//! under its wasm timing-class profile. Identity signing stays delegated: -//! the private key never enters guest memory. +//! under its wasm timing-class profile. Identity signing stays delegated +//! by default; the `guest-ed25519-signing` feature adds an opt-in path +//! that signs in-guest instead (see `sign`). -#[cfg(target_arch = "wasm32")] +#[cfg(any(target_arch = "wasm32", feature = "guest-ed25519-signing"))] pub mod sign; diff --git a/core/src/crypto/sign.rs b/core/src/crypto/sign.rs index 1b70a06..1bd6731 100644 --- a/core/src/crypto/sign.rs +++ b/core/src/crypto/sign.rs @@ -1,26 +1,40 @@ -//! The node identity: an Ed25519 signing key held as a `polymorph:webcrypto` -//! handle, presented to rustls as a `SigningKey`/`Signer` pair (iroh's raw -//! public key shape). The private key material never enters guest memory; -//! `sign` crosses the WIT boundary per handshake, not per packet. +//! The node identity: an Ed25519 signing key presented to rustls as a +//! `SigningKey`/`Signer` pair (iroh's raw public key shape). +//! +//! By default the key is a `polymorph:webcrypto` handle: the private key +//! material never enters guest memory, and `sign` crosses the WIT boundary +//! per handshake, not per packet. Under the `guest-ed25519-signing` +//! feature, `Identity::from_seed` instead holds an `ed25519-dalek` key in +//! guest memory and signs there. use std::fmt; use std::sync::Arc; +#[cfg(target_arch = "wasm32")] use polymorph_webcrypto_guest::{ed25519, SigningKeyOptions}; use rustls::pki_types::{alg_id, SubjectPublicKeyInfoDer}; use rustls::sign::{public_key_to_spki, Signer, SigningKey}; use rustls::{Error, SignatureAlgorithm, SignatureScheme}; -/// A node identity: the webcrypto signing-key handle plus its public half. +/// A node identity: the Ed25519 signing key plus its public half. pub struct Identity { - key: Arc, + key: SignerKind, /// The raw 32-byte Ed25519 public key — iroh's `EndpointID`. pub endpoint_id: [u8; 32], } +/// Where an identity's private key lives, and so which signer serves it. +enum SignerKind { + #[cfg(target_arch = "wasm32")] + Webcrypto(Arc), + #[cfg(feature = "guest-ed25519-signing")] + Guest(Arc), +} + impl Identity { /// Generate a fresh identity. The signing key is minted /// non-extractable: the handle can sign, nothing can read it. + #[cfg(target_arch = "wasm32")] pub async fn generate() -> Result { let (signing, verifying) = ed25519::generate_key(SigningKeyOptions { sign: true, @@ -38,10 +52,10 @@ impl Identity { .map_err(|_| format!("expected 32-byte Ed25519 public key, got {}", raw.len()))?; let spki = public_key_to_spki(&alg_id::ED25519, raw); Ok(Self { - key: Arc::new(WebcryptoEd25519 { + key: SignerKind::Webcrypto(Arc::new(WebcryptoEd25519 { key: Arc::new(signing), spki: spki.to_vec(), - }), + })), endpoint_id, }) } @@ -53,6 +67,7 @@ impl Identity { /// shape, and a sign/verify probe of the halves against each other — /// so a bad pair fails at bind rather than as handshake failures /// against every peer. + #[cfg(target_arch = "wasm32")] pub async fn from_injected( signing: polymorph_webcrypto_guest::SigningKey, verifying: polymorph_webcrypto_guest::VerifyingKey, @@ -95,42 +110,76 @@ impl Identity { })?; let spki = public_key_to_spki(&alg_id::ED25519, raw); Ok(Self { - key: Arc::new(WebcryptoEd25519 { + key: SignerKind::Webcrypto(Arc::new(WebcryptoEd25519 { key: Arc::new(signing), spki: spki.to_vec(), - }), + })), endpoint_id, }) } - /// The identity as a rustls signer: the webcrypto handle behind the - /// `SigningKey` trait, reporting the Ed25519 SPKI as its public key. + /// Mint an identity from an Ed25519 private-key seed: the 32-byte + /// private key of RFC 8032 section 5.1.5, expanded to the signing + /// scalar here. The key is held in guest memory for the identity's + /// lifetime. + #[cfg(feature = "guest-ed25519-signing")] + pub fn from_seed(seed: &[u8]) -> Result { + let seed: [u8; 32] = seed + .try_into() + .map_err(|_| format!("expected a 32-byte Ed25519 seed, got {}", seed.len()))?; + let key = ed25519_dalek::SigningKey::from_bytes(&seed); + let endpoint_id = key.verifying_key().to_bytes(); + let spki = public_key_to_spki(&alg_id::ED25519, endpoint_id); + Ok(Self { + key: SignerKind::Guest(Arc::new(GuestEd25519 { + key, + spki: spki.to_vec(), + })), + endpoint_id, + }) + } + + /// The identity as a rustls signer, reporting the Ed25519 SPKI as + /// its public key. pub fn signing_key(&self) -> Arc { - self.key.clone() + match &self.key { + #[cfg(target_arch = "wasm32")] + SignerKind::Webcrypto(key) => key.clone(), + #[cfg(feature = "guest-ed25519-signing")] + SignerKind::Guest(key) => key.clone(), + } } /// Sign `message` with the identity key (the relay handshake path; /// TLS signing goes through the rustls `Signer` instead). pub async fn sign(&self, message: &[u8]) -> Result, String> { - self.key - .key - .sign(message) - .await - .map_err(|e| format!("webcrypto ed25519 sign: {e:?}")) + match &self.key { + #[cfg(target_arch = "wasm32")] + SignerKind::Webcrypto(key) => key + .key + .sign(message) + .await + .map_err(|e| format!("webcrypto ed25519 sign: {e:?}")), + #[cfg(feature = "guest-ed25519-signing")] + SignerKind::Guest(key) => Ok(key.sign(message)), + } } } +#[cfg(target_arch = "wasm32")] struct WebcryptoEd25519 { key: Arc, spki: Vec, } +#[cfg(target_arch = "wasm32")] impl fmt::Debug for WebcryptoEd25519 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WebcryptoEd25519").finish_non_exhaustive() } } +#[cfg(target_arch = "wasm32")] impl SigningKey for WebcryptoEd25519 { fn choose_scheme(&self, offered: &[SignatureScheme]) -> Option> { offered @@ -147,8 +196,10 @@ impl SigningKey for WebcryptoEd25519 { } } +#[cfg(target_arch = "wasm32")] struct WebcryptoEd25519Signer(Arc); +#[cfg(target_arch = "wasm32")] impl fmt::Debug for WebcryptoEd25519Signer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WebcryptoEd25519Signer") @@ -156,6 +207,7 @@ impl fmt::Debug for WebcryptoEd25519Signer { } } +#[cfg(target_arch = "wasm32")] impl Signer for WebcryptoEd25519Signer { fn sign(&self, message: &[u8]) -> Result, Error> { wit_bindgen::block_on(async { @@ -170,3 +222,106 @@ impl Signer for WebcryptoEd25519Signer { SignatureScheme::ED25519 } } + +/// An identity whose Ed25519 key is held, and signed with, in guest +/// memory (`guest-ed25519-signing`). +#[cfg(feature = "guest-ed25519-signing")] +struct GuestEd25519 { + key: ed25519_dalek::SigningKey, + spki: Vec, +} + +#[cfg(feature = "guest-ed25519-signing")] +impl GuestEd25519 { + fn sign(&self, message: &[u8]) -> Vec { + use ed25519_dalek::Signer as _; + self.key.sign(message).to_bytes().to_vec() + } +} + +#[cfg(feature = "guest-ed25519-signing")] +impl fmt::Debug for GuestEd25519 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GuestEd25519").finish_non_exhaustive() + } +} + +#[cfg(feature = "guest-ed25519-signing")] +impl SigningKey for GuestEd25519 { + fn choose_scheme(&self, offered: &[SignatureScheme]) -> Option> { + offered + .contains(&SignatureScheme::ED25519) + .then(|| Box::new(GuestEd25519Signer(self.key.clone())) as Box) + } + + fn public_key(&self) -> Option> { + Some(SubjectPublicKeyInfoDer::from(&self.spki[..])) + } + + fn algorithm(&self) -> SignatureAlgorithm { + SignatureAlgorithm::ED25519 + } +} + +#[cfg(feature = "guest-ed25519-signing")] +struct GuestEd25519Signer(ed25519_dalek::SigningKey); + +#[cfg(feature = "guest-ed25519-signing")] +impl fmt::Debug for GuestEd25519Signer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GuestEd25519Signer").finish_non_exhaustive() + } +} + +#[cfg(feature = "guest-ed25519-signing")] +impl Signer for GuestEd25519Signer { + fn sign(&self, message: &[u8]) -> Result, Error> { + use ed25519_dalek::Signer as _; + Ok(self.0.sign(message).to_bytes().to_vec()) + } + + fn scheme(&self) -> SignatureScheme { + SignatureScheme::ED25519 + } +} + +#[cfg(all(test, feature = "guest-ed25519-signing"))] +mod tests { + use rustls::SignatureScheme; + + use super::Identity; + + /// The seed-to-identity mapping and the signature it produces must be + /// RFC 8032's: a seed accepted here names the same public key, and + /// signs the same bytes, as it does for every other Ed25519 + /// implementation. Test vector 1 of RFC 8032 section 7.1 (empty + /// message). + #[test] + fn seed_to_identity_matches_rfc8032_vector_1() { + let seed = hex::decode("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") + .unwrap(); + let public = + hex::decode("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a") + .unwrap(); + let signature = hex::decode( + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b", + ) + .unwrap(); + + let identity = Identity::from_seed(&seed).expect("32-byte seed is accepted"); + assert_eq!(identity.endpoint_id.as_slice(), public.as_slice()); + + let signer = identity + .signing_key() + .choose_scheme(&[SignatureScheme::ED25519]) + .expect("the guest signer offers ED25519"); + assert_eq!(signer.sign(b"").unwrap(), signature); + } + + /// A seed of the wrong length is rejected rather than padded or + /// truncated into a different identity. + #[test] + fn short_seed_is_rejected() { + assert!(Identity::from_seed(&[0u8; 31]).is_err()); + } +} diff --git a/endpoint/Cargo.toml b/endpoint/Cargo.toml index 5b2a7cf..3b456dd 100644 --- a/endpoint/Cargo.toml +++ b/endpoint/Cargo.toml @@ -8,6 +8,11 @@ description = "The polymorph:iroh endpoint component: connect/accept by endpoint [lib] crate-type = ["cdylib"] +[features] +# In-guest Ed25519 signing from a raw private-key seed: exports +# `identity-from-seed`. See the interface's WIT docs for the trade. +guest-ed25519-signing = ["iroh-endpoint-core/guest-ed25519-signing"] + [dependencies] iroh-endpoint-core = { path = "../core" } polymorph-tls-quic.workspace = true diff --git a/endpoint/src/identity.rs b/endpoint/src/identity.rs index 377db72..4ab0c67 100644 --- a/endpoint/src/identity.rs +++ b/endpoint/src/identity.rs @@ -1,5 +1,6 @@ //! The `identity` interface family: the identity resource plus its -//! constructor interfaces (`identity-generate`, `identity-from-keys`). +//! constructor interfaces (`identity-generate`, `identity-from-keys`, and +//! `identity-from-seed` when the `guest-ed25519-signing` feature is on). //! Construction validates, so an `identity` in hand is valid by //! construction; the resource is reusable across any number of //! `endpoint-options`. @@ -12,6 +13,8 @@ use crate::bindings::exports::polymorph::iroh::identity::{Guest as IdentityGuest use crate::bindings::exports::polymorph::iroh::identity_from_keys::{ Guest as FromKeysGuest, Identity, SigningKey, VerifyingKey, }; +#[cfg(feature = "guest-ed25519-signing")] +use crate::bindings::exports::polymorph::iroh::identity_from_seed::Guest as FromSeedGuest; use crate::bindings::exports::polymorph::iroh::identity_generate::Guest as GenerateGuest; use crate::bindings::polymorph::iroh::types::Error; use crate::Component; @@ -53,3 +56,13 @@ impl FromKeysGuest for Component { })) } } + +#[cfg(feature = "guest-ed25519-signing")] +impl FromSeedGuest for Component { + fn from_seed(seed: Vec) -> Result { + let core = CoreIdentity::from_seed(&seed).map_err(Error::InvalidArgument)?; + Ok(Identity::new(IdentityRes { + inner: Rc::new(core), + })) + } +} diff --git a/endpoint/src/lib.rs b/endpoint/src/lib.rs index f817588..fd86f4e 100644 --- a/endpoint/src/lib.rs +++ b/endpoint/src/lib.rs @@ -25,29 +25,42 @@ mod udp; mod webrtc; pub(crate) mod bindings { - wit_bindgen::generate!({ - path: "../wit", - world: "iroh-endpoint", - generate_all, - // The websocket interfaces are bound once in iroh-endpoint-core, - // whose relay client this component shares; webrtc's structurally - // equal `stream-message` is then the only stream payload generated - // in this crate — two in one generation collide under wit-bindgen - // 0.59's structural canonicalization of stream payloads. - // - // The webcrypto interfaces are bound once in polymorph-webcrypto-guest, - // whose newtypes wrap only that generation; `endpoint-options.identity` - // carries `signature` handles, so those interfaces (and their type - // dependencies) must resolve to the same resource types the SDK - // wraps. - with: { - "polymorph:websocket/types@0.1.0": iroh_endpoint_core::bindings::polymorph::websocket::types, - "polymorph:websocket/connections@0.1.0": iroh_endpoint_core::bindings::polymorph::websocket::connections, - "polymorph:webcrypto/types@0.1.0": polymorph_webcrypto_guest::bindings::types, - "polymorph:webcrypto/wrapping@0.1.0": polymorph_webcrypto_guest::bindings::wrapping, - "polymorph:webcrypto/signature@0.1.0": polymorph_webcrypto_guest::bindings::signature, - }, - }); + // `generate!` cannot read cfg, and the `@unstable` WIT feature must be + // named in `features:` only when the cargo feature is on; so the one + // shared invocation lives in a macro, expanded once per cfg arm. + macro_rules! bind { + ($($features:tt)*) => { + wit_bindgen::generate!({ + path: "../wit", + world: "iroh-endpoint", + generate_all, + $($features)* + // The websocket interfaces are bound once in iroh-endpoint-core, + // whose relay client this component shares; webrtc's structurally + // equal `stream-message` is then the only stream payload generated + // in this crate — two in one generation collide under wit-bindgen + // 0.59's structural canonicalization of stream payloads. + // + // The webcrypto interfaces are bound once in polymorph-webcrypto-guest, + // whose newtypes wrap only that generation; `endpoint-options.identity` + // carries `signature` handles, so those interfaces (and their type + // dependencies) must resolve to the same resource types the SDK + // wraps. + with: { + "polymorph:websocket/types@0.1.0": iroh_endpoint_core::bindings::polymorph::websocket::types, + "polymorph:websocket/connections@0.1.0": iroh_endpoint_core::bindings::polymorph::websocket::connections, + "polymorph:webcrypto/types@0.1.0": polymorph_webcrypto_guest::bindings::types, + "polymorph:webcrypto/wrapping@0.1.0": polymorph_webcrypto_guest::bindings::wrapping, + "polymorph:webcrypto/signature@0.1.0": polymorph_webcrypto_guest::bindings::signature, + }, + }); + }; + } + + #[cfg(feature = "guest-ed25519-signing")] + bind!(features: ["guest-ed25519-signing"],); + #[cfg(not(feature = "guest-ed25519-signing"))] + bind!(); } bindings::export!(Component with_types_in bindings); diff --git a/justfile b/justfile index 17c5fcd..4ef09f1 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,7 @@ build: build-components build-hosts # Native tests: the crypto/framing known answers. test: cargo test -p iroh-endpoint-core + cargo test -p iroh-endpoint-core --features guest-ed25519-signing fmt-check: cargo fmt --all --check @@ -48,9 +49,11 @@ clippy: cargo clippy --all-targets cargo clippy -p iroh-peer --all-targets cargo clippy -p iroh-endpoint -p iroh-endpoint-demo -p iroh-exec-model-guest --target wasm32-wasip2 + cargo clippy -p iroh-endpoint --features guest-ed25519-signing --target wasm32-wasip2 validate-wit: wasm-tools component wit wit/ > /dev/null + wasm-tools component wit wit/ --all-features > /dev/null wasm-tools component wit core/wit/ > /dev/null wasm-tools component wit endpoint-demo/wit/ > /dev/null wasm-tools component wit experiments/exec-model/wit/ > /dev/null diff --git a/wit/iroh.wit b/wit/iroh.wit index 8b0bc3c..6af7ac2 100644 --- a/wit/iroh.wit +++ b/wit/iroh.wit @@ -151,9 +151,11 @@ interface types { interface identity { use types.{endpoint-id}; - /// An endpoint identity: an Ed25519 key pair whose private half - /// lives behind the component's crypto import and is never present - /// in this component's memory. + /// An endpoint identity: an Ed25519 key pair. For every constructor + /// interface except `identity-from-seed`, the private half lives + /// behind the component's crypto import and is never present in this + /// component's memory; an identity made by `identity-from-seed` + /// holds its private key in the component's memory instead. /// /// An identity is valid by construction — every constructor /// interface validates before minting — and reusable: any number @@ -201,6 +203,33 @@ interface identity-from-keys { from-keys: async func(signing: signing-key, verifying: verifying-key) -> result; } +/// Identity construction from a raw Ed25519 private-key seed. +/// +/// The private key is held in this component's memory for the +/// identity's lifetime, and this component signs with it directly. +/// Every other constructor interface keeps the private key behind the +/// crypto import. Use this interface only where that trade is +/// acceptable, for example to load a key that the embedder already +/// holds in the clear. The interface is present only in builds that +/// enable the `guest-ed25519-signing` feature. +@unstable(feature = guest-ed25519-signing) +interface identity-from-seed { + @unstable(feature = guest-ed25519-signing) + use types.{error}; + @unstable(feature = guest-ed25519-signing) + use identity.{identity}; + + /// Mint an identity from an Ed25519 private-key seed: the 32-byte + /// private key of RFC 8032 section 5.1.5. The expansion of the seed + /// to the signing scalar happens here, so the caller supplies the + /// seed, not the scalar. + /// + /// `seed` must be exactly 32 bytes; any other length fails + /// `error.invalid-argument`. Every 32-byte value is a valid seed. + @unstable(feature = guest-ed25519-signing) + from-seed: func(seed: list) -> result; +} + /// The stateful endpoint surface: one identity, its connections, and /// their streams. /// @@ -547,5 +576,7 @@ world iroh-endpoint { export identity; export identity-generate; export identity-from-keys; + @unstable(feature = guest-ed25519-signing) + export identity-from-seed; export endpoint; }