feat: M2 + M3 — AuthService, MLS group lifecycle, Delivery Service
M2: - schemas/auth.capnp: AuthenticationService (upload/fetch KeyPackage) - noiseml-core: IdentityKeypair (Ed25519), generate_key_package, NoiseTransport with send_envelope/recv_envelope, Noise_XX handshake (initiator + responder) - noiseml-proto: auth_capnp module, ParsedEnvelope helpers - noiseml-server: AuthServiceImpl backed by DashMap queue (single-use KPs) - noiseml-client: register + fetch-key subcommands, ping over Noise_XX - tests: auth_service integration test (upload → fetch round-trip) M3: - schemas/delivery.capnp: DeliveryService (enqueue/fetch opaque payloads) - noiseml-core/group.rs: GroupMember — MLS group lifecycle create_group, add_member (→ Commit+Welcome), join_group, send_message, receive_message; uses openmls 0.5 public API (extract() not into_welcome, KeyPackageIn::validate() not From<KeyPackageIn>) - noiseml-server: DeliveryServiceImpl on port 7001 alongside AS on 7000 - noiseml-proto: delivery_capnp module TODO (see M3_STATUS.md): - noiseml-client: group subcommands (create-group, invite, join, send, recv) - noiseml-client/tests/mls_group.rs: full MLS round-trip integration test
This commit is contained in:
@@ -6,9 +6,7 @@ description = "Crypto primitives, Noise_XX transport, MLS state machine, and Cap
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
# Crypto
|
||||
# openmls / openmls_rust_crypto / openmls_basic_credential — added in M2
|
||||
# ml-kem — added in M5 (hybrid PQ ciphersuite)
|
||||
# Crypto — classical
|
||||
x25519-dalek = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
snow = { workspace = true }
|
||||
@@ -17,12 +15,20 @@ hkdf = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
# Crypto — MLS (M2); ml-kem added in M5
|
||||
openmls = { workspace = true }
|
||||
openmls_rust_crypto = { workspace = true }
|
||||
openmls_traits = { workspace = true }
|
||||
tls_codec = { workspace = true }
|
||||
|
||||
# Serialisation
|
||||
capnp = { workspace = true }
|
||||
noiseml-proto = { path = "../noiseml-proto" }
|
||||
|
||||
# Async codec
|
||||
# Async runtime + codec
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
bytes = { version = "1" }
|
||||
|
||||
# Error handling
|
||||
|
||||
@@ -68,4 +68,10 @@ pub enum CoreError {
|
||||
/// The limit is [`MAX_PLAINTEXT_LEN`] bytes per frame.
|
||||
#[error("plaintext {size} B exceeds Noise frame limit of {MAX_PLAINTEXT_LEN} B")]
|
||||
MessageTooLarge { size: usize },
|
||||
|
||||
/// An MLS operation failed.
|
||||
///
|
||||
/// The inner string is the debug representation of the openmls error.
|
||||
#[error("MLS error: {0}")]
|
||||
Mls(String),
|
||||
}
|
||||
|
||||
428
crates/noiseml-core/src/group.rs
Normal file
428
crates/noiseml-core/src/group.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
//! MLS group state machine.
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client
|
||||
//! [`OpenMlsRustCrypto`] backend. The backend is **persistent** — it holds the
|
||||
//! in-memory key store that maps init-key references to HPKE private keys.
|
||||
//! openmls's `new_from_welcome` reads those private keys from the key store to
|
||||
//! decrypt the Welcome, so the same backend instance must be used from
|
||||
//! `generate_key_package` through `join_group`.
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! All MLS messages are serialised/deserialised using TLS presentation language
|
||||
//! encoding (`tls_codec`). The resulting byte vectors are what the transport
|
||||
//! layer (and the Delivery Service) sees.
|
||||
//!
|
||||
//! # MLS ciphersuite
|
||||
//!
|
||||
//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` — same as M2.
|
||||
//!
|
||||
//! # Ratchet tree
|
||||
//!
|
||||
//! `use_ratchet_tree_extension = true` so that the ratchet tree is embedded
|
||||
//! in Welcome messages. `new_from_welcome` is called with `ratchet_tree = None`;
|
||||
//! openmls extracts the tree from the Welcome's `GroupInfo` extension.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use openmls::prelude::{
|
||||
Ciphersuite, CryptoConfig, Credential, CredentialType, CredentialWithKey,
|
||||
GroupId, KeyPackage, KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody,
|
||||
MlsMessageOut, ProcessedMessageContent, ProtocolMessage, ProtocolVersion,
|
||||
TlsDeserializeTrait, TlsSerializeTrait,
|
||||
};
|
||||
use openmls_rust_crypto::OpenMlsRustCrypto;
|
||||
use openmls_traits::OpenMlsCryptoProvider;
|
||||
|
||||
use crate::{error::CoreError, identity::IdentityKeypair};
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CIPHERSUITE: Ciphersuite =
|
||||
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
|
||||
|
||||
// ── GroupMember ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Per-client MLS state: identity keypair, crypto backend, and optional group.
|
||||
///
|
||||
/// # Lifecycle
|
||||
///
|
||||
/// ```text
|
||||
/// GroupMember::new(identity)
|
||||
/// ├─ generate_key_package() → upload to AS
|
||||
/// ├─ create_group(group_id) → become sole member
|
||||
/// │ └─ add_member(kp) → invite a peer; returns (commit, welcome)
|
||||
/// └─ join_group(welcome) → join after receiving a Welcome
|
||||
/// ├─ send_message(msg) → encrypt application data
|
||||
/// └─ receive_message(b) → decrypt; returns Some(plaintext) or None
|
||||
/// ```
|
||||
pub struct GroupMember {
|
||||
/// Persistent crypto backend. Holds the in-memory key store with HPKE
|
||||
/// private keys created during `generate_key_package`.
|
||||
backend: OpenMlsRustCrypto,
|
||||
/// Long-term Ed25519 identity keypair. Also used as the MLS `Signer`.
|
||||
identity: Arc<IdentityKeypair>,
|
||||
/// Active MLS group, if any.
|
||||
group: Option<MlsGroup>,
|
||||
/// Shared group configuration (wire format, ratchet tree extension, etc.).
|
||||
config: MlsGroupConfig,
|
||||
}
|
||||
|
||||
impl GroupMember {
|
||||
/// Create a new `GroupMember` with a fresh crypto backend.
|
||||
pub fn new(identity: Arc<IdentityKeypair>) -> Self {
|
||||
let config = MlsGroupConfig::builder()
|
||||
// Embed the ratchet tree in Welcome messages so joinees do not
|
||||
// need an out-of-band tree delivery mechanism.
|
||||
.use_ratchet_tree_extension(true)
|
||||
.build();
|
||||
|
||||
Self {
|
||||
backend: OpenMlsRustCrypto::default(),
|
||||
identity,
|
||||
group: None,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
// ── KeyPackage ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a fresh single-use MLS KeyPackage.
|
||||
///
|
||||
/// The HPKE init private key is stored in `self.backend`'s key store.
|
||||
/// **The same `GroupMember` instance must later call `join_group`** so
|
||||
/// that `new_from_welcome` can retrieve the private key.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// TLS-encoded KeyPackage bytes, ready for upload to the Authentication
|
||||
/// Service.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage.
|
||||
pub fn generate_key_package(&mut self) -> Result<Vec<u8>, CoreError> {
|
||||
let credential_with_key = self.make_credential_with_key()?;
|
||||
|
||||
let key_package = KeyPackage::builder()
|
||||
.build(
|
||||
CryptoConfig::with_default_version(CIPHERSUITE),
|
||||
&self.backend,
|
||||
self.identity.as_ref(),
|
||||
credential_with_key,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
|
||||
key_package
|
||||
.tls_serialize_detached()
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))
|
||||
}
|
||||
|
||||
// ── Group creation ────────────────────────────────────────────────────────
|
||||
|
||||
/// Create a new MLS group with `group_id` as the group identifier.
|
||||
///
|
||||
/// The caller becomes the sole member (epoch 0). Use `add_member` to
|
||||
/// invite additional members.
|
||||
///
|
||||
/// `group_id` can be any non-empty byte string; SHA-256 of a human-readable
|
||||
/// name is a good choice.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if the group already exists or openmls fails.
|
||||
pub fn create_group(&mut self, group_id: &[u8]) -> Result<(), CoreError> {
|
||||
let credential_with_key = self.make_credential_with_key()?;
|
||||
let mls_id = GroupId::from_slice(group_id);
|
||||
|
||||
let group = MlsGroup::new_with_group_id(
|
||||
&self.backend,
|
||||
self.identity.as_ref(),
|
||||
&self.config,
|
||||
mls_id,
|
||||
credential_with_key,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
|
||||
self.group = Some(group);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Membership ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Add a new member by their TLS-encoded KeyPackage bytes.
|
||||
///
|
||||
/// Produces a Commit (to update existing members' state) and a Welcome
|
||||
/// (to bootstrap the new member). The caller is responsible for
|
||||
/// distributing these:
|
||||
///
|
||||
/// - Send `commit_bytes` to all **existing** group members via the DS.
|
||||
/// (In the 2-party case where the creator is the only member, this can
|
||||
/// be discarded — the creator applies it locally via this method.)
|
||||
/// - Send `welcome_bytes` to the **new** member via the DS.
|
||||
///
|
||||
/// This method also merges the pending Commit into the local group state
|
||||
/// (advancing the epoch), so the caller is immediately ready to encrypt.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `(commit_bytes, welcome_bytes)` — both TLS-encoded MLS messages.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if the KeyPackage is malformed, no active
|
||||
/// group exists, or openmls fails.
|
||||
pub fn add_member(
|
||||
&mut self,
|
||||
key_package_bytes: &[u8],
|
||||
) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
|
||||
let group = self
|
||||
.group
|
||||
.as_mut()
|
||||
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
|
||||
|
||||
// Deserialise and validate the peer's KeyPackage. KeyPackage only derives
|
||||
// TlsSerialize; KeyPackageIn derives TlsDeserialize and provides validate()
|
||||
// which verifies the signature and returns a trusted KeyPackage.
|
||||
let key_package: KeyPackage =
|
||||
KeyPackageIn::tls_deserialize(&mut key_package_bytes.as_ref())
|
||||
.map_err(|e| CoreError::Mls(format!("KeyPackage deserialise: {e:?}")))?
|
||||
.validate(self.backend.crypto(), ProtocolVersion::Mls10)
|
||||
.map_err(|e| CoreError::Mls(format!("KeyPackage validate: {e:?}")))?;
|
||||
|
||||
// Create the Commit + Welcome. The third return value (GroupInfo) is for
|
||||
// external commits and is not needed here.
|
||||
let (commit_out, welcome_out, _group_info) = group
|
||||
.add_members(
|
||||
&self.backend,
|
||||
self.identity.as_ref(),
|
||||
&[key_package],
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("add_members: {e:?}")))?;
|
||||
|
||||
// Merge the pending Commit into our own state, advancing the epoch.
|
||||
group
|
||||
.merge_pending_commit(&self.backend)
|
||||
.map_err(|e| CoreError::Mls(format!("merge_pending_commit: {e:?}")))?;
|
||||
|
||||
let commit_bytes = commit_out
|
||||
.to_bytes()
|
||||
.map_err(|e| CoreError::Mls(format!("commit serialise: {e:?}")))?;
|
||||
let welcome_bytes = welcome_out
|
||||
.to_bytes()
|
||||
.map_err(|e| CoreError::Mls(format!("welcome serialise: {e:?}")))?;
|
||||
|
||||
Ok((commit_bytes, welcome_bytes))
|
||||
}
|
||||
|
||||
/// Join an existing MLS group from a TLS-encoded Welcome message.
|
||||
///
|
||||
/// The caller must have previously called [`generate_key_package`] on
|
||||
/// **this same instance** so that the HPKE init private key is in the
|
||||
/// backend's key store.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if the Welcome does not match any known
|
||||
/// KeyPackage, or openmls validation fails.
|
||||
///
|
||||
/// [`generate_key_package`]: Self::generate_key_package
|
||||
pub fn join_group(&mut self, welcome_bytes: &[u8]) -> Result<(), CoreError> {
|
||||
// Deserialise MlsMessageIn, then extract the inner Welcome.
|
||||
let msg_in =
|
||||
openmls::prelude::MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_ref())
|
||||
.map_err(|e| CoreError::Mls(format!("Welcome deserialise: {e:?}")))?;
|
||||
|
||||
// into_welcome() is feature-gated in openmls 0.5; extract() is public.
|
||||
let welcome = match msg_in.extract() {
|
||||
MlsMessageInBody::Welcome(w) => w,
|
||||
_ => return Err(CoreError::Mls("expected a Welcome message".into())),
|
||||
};
|
||||
|
||||
// ratchet_tree = None because use_ratchet_tree_extension = true embeds
|
||||
// the tree inside the Welcome's GroupInfo extension.
|
||||
let group = MlsGroup::new_from_welcome(
|
||||
&self.backend,
|
||||
&self.config,
|
||||
welcome,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?;
|
||||
|
||||
self.group = Some(group);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Application messages ──────────────────────────────────────────────────
|
||||
|
||||
/// Encrypt `plaintext` as an MLS Application message.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// TLS-encoded `MlsMessageOut` bytes (PrivateMessage variant).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if there is no active group or encryption fails.
|
||||
pub fn send_message(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, CoreError> {
|
||||
let group = self
|
||||
.group
|
||||
.as_mut()
|
||||
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
|
||||
|
||||
let mls_msg: MlsMessageOut = group
|
||||
.create_message(&self.backend, self.identity.as_ref(), plaintext)
|
||||
.map_err(|e| CoreError::Mls(format!("create_message: {e:?}")))?;
|
||||
|
||||
mls_msg
|
||||
.to_bytes()
|
||||
.map_err(|e| CoreError::Mls(format!("message serialise: {e:?}")))
|
||||
}
|
||||
|
||||
/// Process an incoming TLS-encoded MLS message.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(Some(plaintext))` for Application messages.
|
||||
/// - `Ok(None)` for Commit messages (group state is updated internally).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if the message is malformed, fails
|
||||
/// authentication, or the group state is inconsistent.
|
||||
pub fn receive_message(&mut self, bytes: &[u8]) -> Result<Option<Vec<u8>>, CoreError> {
|
||||
let group = self
|
||||
.group
|
||||
.as_mut()
|
||||
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
|
||||
|
||||
let msg_in =
|
||||
openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes.as_ref())
|
||||
.map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?;
|
||||
|
||||
// into_protocol_message() is feature-gated; extract() + manual construction is not.
|
||||
let protocol_message = match msg_in.extract() {
|
||||
MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m),
|
||||
MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m),
|
||||
_ => return Err(CoreError::Mls("not a protocol message".into())),
|
||||
};
|
||||
|
||||
let processed = group
|
||||
.process_message(&self.backend, protocol_message)
|
||||
.map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?;
|
||||
|
||||
match processed.into_content() {
|
||||
ProcessedMessageContent::ApplicationMessage(app) => {
|
||||
Ok(Some(app.into_bytes()))
|
||||
}
|
||||
ProcessedMessageContent::StagedCommitMessage(staged) => {
|
||||
// Merge the Commit into the local state (epoch advances).
|
||||
group
|
||||
.merge_staged_commit(&self.backend, *staged)
|
||||
.map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?;
|
||||
Ok(None)
|
||||
}
|
||||
// Proposals are stored for a later Commit; nothing to return yet.
|
||||
ProcessedMessageContent::ProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
Ok(None)
|
||||
}
|
||||
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
|
||||
group.store_pending_proposal(*proposal);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return the MLS group ID bytes, or `None` if no group is active.
|
||||
pub fn group_id(&self) -> Option<Vec<u8>> {
|
||||
self.group
|
||||
.as_ref()
|
||||
.map(|g| g.group_id().as_slice().to_vec())
|
||||
}
|
||||
|
||||
/// Return a reference to the identity keypair.
|
||||
pub fn identity(&self) -> &IdentityKeypair {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
fn make_credential_with_key(&self) -> Result<CredentialWithKey, CoreError> {
|
||||
let credential = Credential::new(
|
||||
self.identity.public_key_bytes().to_vec(),
|
||||
CredentialType::Basic,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
|
||||
Ok(CredentialWithKey {
|
||||
credential,
|
||||
signature_key: self.identity.public_key_bytes().to_vec().into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Full two-party MLS round-trip: create group → add member → exchange messages.
|
||||
#[test]
|
||||
fn two_party_mls_round_trip() {
|
||||
let alice_id = Arc::new(IdentityKeypair::generate());
|
||||
let bob_id = Arc::new(IdentityKeypair::generate());
|
||||
|
||||
let mut alice = GroupMember::new(Arc::clone(&alice_id));
|
||||
let mut bob = GroupMember::new(Arc::clone(&bob_id));
|
||||
|
||||
// Bob generates a KeyPackage (stored in bob's backend key store).
|
||||
let bob_kp = bob.generate_key_package().expect("Bob KeyPackage");
|
||||
|
||||
// Alice creates the group.
|
||||
alice.create_group(b"test-group-m3").expect("Alice create group");
|
||||
|
||||
// Alice adds Bob → (commit, welcome).
|
||||
// Alice is the sole existing member, so she merges the commit herself.
|
||||
let (_, welcome) = alice.add_member(&bob_kp).expect("Alice add Bob");
|
||||
|
||||
// Bob joins via the Welcome. His backend holds the matching init key.
|
||||
bob.join_group(&welcome).expect("Bob join group");
|
||||
|
||||
// Alice → Bob: application message.
|
||||
let ct_a = alice.send_message(b"hello bob").expect("Alice send");
|
||||
let pt_b = bob
|
||||
.receive_message(&ct_a)
|
||||
.expect("Bob recv")
|
||||
.expect("should be application message");
|
||||
assert_eq!(pt_b, b"hello bob");
|
||||
|
||||
// Bob → Alice: reply.
|
||||
let ct_b = bob.send_message(b"hello alice").expect("Bob send");
|
||||
let pt_a = alice
|
||||
.receive_message(&ct_b)
|
||||
.expect("Alice recv")
|
||||
.expect("should be application message");
|
||||
assert_eq!(pt_a, b"hello alice");
|
||||
}
|
||||
|
||||
/// `group_id()` returns None before create_group, Some afterwards.
|
||||
#[test]
|
||||
fn group_id_lifecycle() {
|
||||
let id = Arc::new(IdentityKeypair::generate());
|
||||
let mut member = GroupMember::new(id);
|
||||
|
||||
assert!(member.group_id().is_none(), "no group before create");
|
||||
member.create_group(b"gid").unwrap();
|
||||
assert_eq!(
|
||||
member.group_id().unwrap(),
|
||||
b"gid".as_slice(),
|
||||
"group_id must match what was passed"
|
||||
);
|
||||
}
|
||||
}
|
||||
97
crates/noiseml-core/src/identity.rs
Normal file
97
crates/noiseml-core/src/identity.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! Ed25519 identity keypair for MLS credentials and AS registration.
|
||||
//!
|
||||
//! # Relationship to the Noise keypair
|
||||
//!
|
||||
//! The X25519 [`NoiseKeypair`](crate::NoiseKeypair) is the transport-layer
|
||||
//! static key used in the Noise_XX handshake. The Ed25519 [`IdentityKeypair`]
|
||||
//! is the long-term identity key embedded in MLS `BasicCredential`s. The two
|
||||
//! keys serve different roles and must not be confused.
|
||||
//!
|
||||
//! # Zeroize
|
||||
//!
|
||||
//! The 32-byte private seed is stored as `Zeroizing<[u8; 32]>`, which zeroes
|
||||
//! the bytes on drop. `[u8; 32]` is `Copy + Default` and satisfies zeroize's
|
||||
//! `DefaultIsZeroes` constraint, avoiding a conflict with ed25519-dalek's
|
||||
//! `SigningKey` zeroize impl.
|
||||
//!
|
||||
//! # Fingerprint
|
||||
//!
|
||||
//! A 32-byte SHA-256 digest of the raw public key bytes is used as a compact,
|
||||
//! collision-resistant identifier for logging.
|
||||
|
||||
use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey};
|
||||
use openmls_traits::signatures::Signer;
|
||||
use openmls_traits::types::{Error as MlsError, SignatureScheme};
|
||||
use sha2::{Digest, Sha256};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// An Ed25519 identity keypair.
|
||||
///
|
||||
/// Created with [`IdentityKeypair::generate`]. The private signing key seed
|
||||
/// is zeroed when this struct is dropped.
|
||||
pub struct IdentityKeypair {
|
||||
/// Raw 32-byte private seed — zeroized on drop.
|
||||
///
|
||||
/// Stored as bytes rather than `SigningKey` to satisfy zeroize's
|
||||
/// `DefaultIsZeroes` bound on `Zeroizing<T>`.
|
||||
seed: Zeroizing<[u8; 32]>,
|
||||
/// Corresponding 32-byte public verifying key.
|
||||
verifying: VerifyingKey,
|
||||
}
|
||||
|
||||
impl IdentityKeypair {
|
||||
/// Generate a fresh random Ed25519 identity keypair.
|
||||
pub fn generate() -> Self {
|
||||
use rand::rngs::OsRng;
|
||||
let signing = SigningKey::generate(&mut OsRng);
|
||||
let verifying = signing.verifying_key();
|
||||
let seed = Zeroizing::new(signing.to_bytes());
|
||||
Self { seed, verifying }
|
||||
}
|
||||
|
||||
/// Return the raw 32-byte Ed25519 public key.
|
||||
///
|
||||
/// This is the byte array used as `identityKey` in `auth.capnp` calls.
|
||||
pub fn public_key_bytes(&self) -> [u8; 32] {
|
||||
self.verifying.to_bytes()
|
||||
}
|
||||
|
||||
/// Return the SHA-256 fingerprint of the public key (32 bytes).
|
||||
pub fn fingerprint(&self) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.verifying.to_bytes());
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
/// Reconstruct the `SigningKey` from the stored seed bytes.
|
||||
fn signing_key(&self) -> SigningKey {
|
||||
SigningKey::from_bytes(&self.seed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement the openmls `Signer` trait so `IdentityKeypair` can be passed
|
||||
/// directly to `KeyPackage::builder().build(...)` without needing the external
|
||||
/// `openmls_basic_credential` crate.
|
||||
impl Signer for IdentityKeypair {
|
||||
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, MlsError> {
|
||||
let sk = self.signing_key();
|
||||
let sig: ed25519_dalek::Signature = sk.sign(payload);
|
||||
Ok(sig.to_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn signature_scheme(&self) -> SignatureScheme {
|
||||
SignatureScheme::ED25519
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IdentityKeypair {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let fp = self.fingerprint();
|
||||
f.debug_struct("IdentityKeypair")
|
||||
.field(
|
||||
"fingerprint",
|
||||
&format!("{:02x}{:02x}{:02x}{:02x}…", fp[0], fp[1], fp[2], fp[3]),
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
86
crates/noiseml-core/src/keypackage.rs
Normal file
86
crates/noiseml-core/src/keypackage.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! MLS KeyPackage generation and TLS serialisation.
|
||||
//!
|
||||
//! # Ciphersuite
|
||||
//!
|
||||
//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (ciphersuite ID `0x0001`).
|
||||
//! This is the RECOMMENDED ciphersuite from RFC 9420 §17.1.
|
||||
//!
|
||||
//! # Single-use semantics
|
||||
//!
|
||||
//! Per RFC 9420 §10.1, each KeyPackage MUST be used at most once. The
|
||||
//! Authentication Service enforces this by atomically removing a package on
|
||||
//! fetch.
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls).
|
||||
//! The resulting bytes are opaque to the noiseml transport layer.
|
||||
|
||||
use openmls::prelude::{
|
||||
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage,
|
||||
TlsSerializeTrait,
|
||||
};
|
||||
use openmls_rust_crypto::OpenMlsRustCrypto;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{error::CoreError, identity::IdentityKeypair};
|
||||
|
||||
/// The MLS ciphersuite used throughout noiseml.
|
||||
const CIPHERSUITE: Ciphersuite =
|
||||
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
|
||||
|
||||
/// Generate a fresh MLS KeyPackage for `identity` and serialise it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `(tls_bytes, sha256_fingerprint)` where:
|
||||
/// - `tls_bytes` is the TLS-encoded KeyPackage blob, suitable for uploading.
|
||||
/// - `sha256_fingerprint` is the SHA-256 digest of `tls_bytes` for tamper detection.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage or if
|
||||
/// TLS serialisation fails.
|
||||
pub fn generate_key_package(
|
||||
identity: &IdentityKeypair,
|
||||
) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
|
||||
let backend = OpenMlsRustCrypto::default();
|
||||
|
||||
// Build a BasicCredential using the raw Ed25519 public key bytes as the
|
||||
// MLS identity. Per RFC 9420, any byte string may serve as the identity.
|
||||
let credential = Credential::new(
|
||||
identity.public_key_bytes().to_vec(),
|
||||
CredentialType::Basic,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
|
||||
// The `signature_key` in CredentialWithKey is the Ed25519 public key that
|
||||
// will be used to verify the KeyPackage's leaf node signature.
|
||||
// `SignaturePublicKey` implements `From<Vec<u8>>`.
|
||||
let credential_with_key = CredentialWithKey {
|
||||
credential,
|
||||
signature_key: identity.public_key_bytes().to_vec().into(),
|
||||
};
|
||||
|
||||
// `IdentityKeypair` implements `openmls_traits::signatures::Signer`
|
||||
// so it can be passed directly to the builder.
|
||||
let key_package = KeyPackage::builder()
|
||||
.build(
|
||||
CryptoConfig::with_default_version(CIPHERSUITE),
|
||||
&backend,
|
||||
identity,
|
||||
credential_with_key,
|
||||
)
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
|
||||
// TLS-encode the KeyPackage using the trait from the openmls prelude.
|
||||
// This uses tls_codec 0.3 (the same version openmls uses internally),
|
||||
// avoiding a duplicate-trait conflict with tls_codec 0.4.
|
||||
let tls_bytes = key_package
|
||||
.tls_serialize_detached()
|
||||
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
|
||||
|
||||
let fingerprint: Vec<u8> = Sha256::digest(&tls_bytes).to_vec();
|
||||
|
||||
Ok((tls_bytes, fingerprint))
|
||||
}
|
||||
@@ -1,28 +1,32 @@
|
||||
//! Core cryptographic primitives, Noise_XX transport, and frame codec for noiseml.
|
||||
//! Core cryptographic primitives, Noise_XX transport, MLS group state machine,
|
||||
//! and frame codec for noiseml.
|
||||
//!
|
||||
//! # Module layout
|
||||
//!
|
||||
//! | Module | Responsibility |
|
||||
//! |------------|----------------------------------------------------------|
|
||||
//! | `error` | [`CoreError`] and [`CodecError`] types |
|
||||
//! | `keypair` | [`NoiseKeypair`] — static X25519 key, zeroize-on-drop |
|
||||
//! | `codec` | [`LengthPrefixedCodec`] — Tokio Encoder + Decoder |
|
||||
//! | `noise` | [`handshake_initiator`], [`handshake_responder`], [`NoiseTransport`] |
|
||||
//!
|
||||
//! # What is NOT in this crate (M1)
|
||||
//!
|
||||
//! - MLS group state machine — added in M2/M3 (`openmls` integration)
|
||||
//! - Hybrid PQ KEM — added in M5
|
||||
//! - Ed25519 identity keypair — added in M2 (needed for MLS credentials)
|
||||
//! | Module | Responsibility |
|
||||
//! |--------------|------------------------------------------------------------------|
|
||||
//! | `error` | [`CoreError`] and [`CodecError`] types |
|
||||
//! | `keypair` | [`NoiseKeypair`] — static X25519 key, zeroize-on-drop |
|
||||
//! | `codec` | [`LengthPrefixedCodec`] — Tokio Encoder + Decoder |
|
||||
//! | `noise` | [`handshake_initiator`], [`handshake_responder`], [`NoiseTransport`] |
|
||||
//! | `identity` | [`IdentityKeypair`] — Ed25519 identity key for MLS credentials |
|
||||
//! | `keypackage` | [`generate_key_package`] — standalone KeyPackage generation |
|
||||
//! | `group` | [`GroupMember`] — MLS group lifecycle (create/join/send/recv) |
|
||||
|
||||
mod codec;
|
||||
mod error;
|
||||
mod group;
|
||||
mod identity;
|
||||
mod keypair;
|
||||
mod keypackage;
|
||||
mod noise;
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG};
|
||||
pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN};
|
||||
pub use group::GroupMember;
|
||||
pub use identity::IdentityKeypair;
|
||||
pub use keypair::NoiseKeypair;
|
||||
pub use keypackage::generate_key_package;
|
||||
pub use noise::{handshake_initiator, handshake_responder, NoiseTransport};
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf, duplex},
|
||||
net::TcpStream,
|
||||
};
|
||||
use tokio_util::codec::Framed;
|
||||
|
||||
use crate::{
|
||||
@@ -155,6 +158,77 @@ impl NoiseTransport {
|
||||
parse_envelope(&bytes).map_err(CoreError::Capnp)
|
||||
}
|
||||
|
||||
// ── capnp-rpc bridge ─────────────────────────────────────────────────────
|
||||
|
||||
/// Consume the transport and return a byte-stream pair suitable for
|
||||
/// `capnp-rpc`'s `twoparty::VatNetwork`.
|
||||
///
|
||||
/// # Why this exists
|
||||
///
|
||||
/// `capnp-rpc` expects `AsyncRead + AsyncWrite` byte streams, but
|
||||
/// `NoiseTransport` is message-based (each call to `send_frame` /
|
||||
/// `recv_frame` encrypts/decrypts one Noise message). This method bridges
|
||||
/// the two models by:
|
||||
///
|
||||
/// 1. Creating a `tokio::io::duplex` pipe (an in-process byte channel).
|
||||
/// 2. Spawning a background task that shuttles bytes between the pipe and
|
||||
/// the Noise framed transport using `tokio::select!`.
|
||||
///
|
||||
/// The returned `(ReadHalf, WriteHalf)` are the **application** ends of the
|
||||
/// pipe; `capnp-rpc` reads from `ReadHalf` and writes to `WriteHalf`. The
|
||||
/// bridge task owns the **transport** end and the `NoiseTransport`.
|
||||
///
|
||||
/// # Framing
|
||||
///
|
||||
/// Each Noise frame carries at most [`MAX_PLAINTEXT_LEN`] bytes of
|
||||
/// plaintext. The bridge uses that as the read buffer size so that one
|
||||
/// frame is never split across multiple pipe writes.
|
||||
///
|
||||
/// # Lifetime
|
||||
///
|
||||
/// The bridge task runs until either side of the pipe closes. When the
|
||||
/// capnp-rpc system drops the pipe halves, the bridge exits cleanly.
|
||||
pub fn into_capnp_io(mut self) -> (ReadHalf<DuplexStream>, WriteHalf<DuplexStream>) {
|
||||
// Choose a pipe capacity large enough for one max-size Noise frame.
|
||||
let (app_stream, mut transport_stream) = duplex(MAX_PLAINTEXT_LEN);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; MAX_PLAINTEXT_LEN];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Noise → app: receive an encrypted frame and write decrypted
|
||||
// plaintext into the pipe.
|
||||
noise_result = self.recv_frame() => {
|
||||
match noise_result {
|
||||
Ok(plaintext) => {
|
||||
if transport_stream.write_all(&plaintext).await.is_err() {
|
||||
break; // app side closed
|
||||
}
|
||||
}
|
||||
Err(_) => break, // peer closed or Noise error
|
||||
}
|
||||
}
|
||||
|
||||
// app → Noise: read bytes from the pipe and send as an
|
||||
// encrypted Noise frame.
|
||||
read_result = transport_stream.read(&mut buf) => {
|
||||
match read_result {
|
||||
Ok(0) | Err(_) => break, // app side closed
|
||||
Ok(n) => {
|
||||
if self.send_frame(&buf[..n]).await.is_err() {
|
||||
break; // peer closed or Noise error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::io::split(app_stream)
|
||||
}
|
||||
|
||||
// ── Session metadata ──────────────────────────────────────────────────────
|
||||
|
||||
/// Return the remote peer's static X25519 public key (32 bytes), as
|
||||
|
||||
Reference in New Issue
Block a user