Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 }
Expand Down
7 changes: 4 additions & 3 deletions core/src/crypto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
191 changes: 173 additions & 18 deletions core/src/crypto/sign.rs
Original file line number Diff line number Diff line change
@@ -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<WebcryptoEd25519>,
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<WebcryptoEd25519>),
#[cfg(feature = "guest-ed25519-signing")]
Guest(Arc<GuestEd25519>),
}

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<Self, String> {
let (signing, verifying) = ed25519::generate_key(SigningKeyOptions {
sign: true,
Expand All @@ -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,
})
}
Expand All @@ -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,
Expand Down Expand Up @@ -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<Self, String> {
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<dyn SigningKey> {
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<Vec<u8>, 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<polymorph_webcrypto_guest::SigningKey>,
spki: Vec<u8>,
}

#[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<Box<dyn Signer>> {
offered
Expand All @@ -147,15 +196,18 @@ impl SigningKey for WebcryptoEd25519 {
}
}

#[cfg(target_arch = "wasm32")]
struct WebcryptoEd25519Signer(Arc<polymorph_webcrypto_guest::SigningKey>);

#[cfg(target_arch = "wasm32")]
impl fmt::Debug for WebcryptoEd25519Signer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WebcryptoEd25519Signer")
.finish_non_exhaustive()
}
}

#[cfg(target_arch = "wasm32")]
impl Signer for WebcryptoEd25519Signer {
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, Error> {
wit_bindgen::block_on(async {
Expand All @@ -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<u8>,
}

#[cfg(feature = "guest-ed25519-signing")]
impl GuestEd25519 {
fn sign(&self, message: &[u8]) -> Vec<u8> {
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<Box<dyn Signer>> {
offered
.contains(&SignatureScheme::ED25519)
.then(|| Box::new(GuestEd25519Signer(self.key.clone())) as Box<dyn Signer>)
}

fn public_key(&self) -> Option<SubjectPublicKeyInfoDer<'_>> {
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<Vec<u8>, 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());
}
}
5 changes: 5 additions & 0 deletions endpoint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion endpoint/src/identity.rs
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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;
Expand Down Expand Up @@ -53,3 +56,13 @@ impl FromKeysGuest for Component {
}))
}
}

#[cfg(feature = "guest-ed25519-signing")]
impl FromSeedGuest for Component {
fn from_seed(seed: Vec<u8>) -> Result<Identity, Error> {
let core = CoreIdentity::from_seed(&seed).map_err(Error::InvalidArgument)?;
Ok(Identity::new(IdentityRes {
inner: Rc::new(core),
}))
}
}
Loading
Loading