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:
890
Cargo.lock
generated
890
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,10 @@ members = [
|
|||||||
# ── Crypto ────────────────────────────────────────────────────────────────────
|
# ── Crypto ────────────────────────────────────────────────────────────────────
|
||||||
openmls = { version = "0.5", default-features = false, features = ["crypto-subtle"] }
|
openmls = { version = "0.5", default-features = false, features = ["crypto-subtle"] }
|
||||||
openmls_rust_crypto = { version = "0.2" }
|
openmls_rust_crypto = { version = "0.2" }
|
||||||
openmls_basic_credential = { version = "0.2" }
|
openmls_traits = { version = "0.2" }
|
||||||
|
# tls_codec must match the version used by openmls 0.5 (which uses 0.3) to avoid
|
||||||
|
# duplicate Serialize trait versions in the dependency graph.
|
||||||
|
tls_codec = { version = "0.3", features = ["derive"] }
|
||||||
# ml-kem 0.2 is the current stable release (FIPS 203, ML-KEM-768).
|
# ml-kem 0.2 is the current stable release (FIPS 203, ML-KEM-768).
|
||||||
# All three parameter sets (512/768/1024) are compiled in by default — no feature flag needed.
|
# All three parameter sets (512/768/1024) are compiled in by default — no feature flag needed.
|
||||||
ml-kem = { version = "0.2" }
|
ml-kem = { version = "0.2" }
|
||||||
@@ -31,7 +34,7 @@ capnp-rpc = { version = "0.19" }
|
|||||||
|
|
||||||
# ── Async / networking ────────────────────────────────────────────────────────
|
# ── Async / networking ────────────────────────────────────────────────────────
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["codec"] }
|
tokio-util = { version = "0.7", features = ["codec", "compat"] }
|
||||||
futures = { version = "0.3" }
|
futures = { version = "0.3" }
|
||||||
|
|
||||||
# ── Server utilities ──────────────────────────────────────────────────────────
|
# ── Server utilities ──────────────────────────────────────────────────────────
|
||||||
@@ -44,7 +47,7 @@ anyhow = { version = "1" }
|
|||||||
thiserror = { version = "1" }
|
thiserror = { version = "1" }
|
||||||
|
|
||||||
# ── CLI ───────────────────────────────────────────────────────────────────────
|
# ── CLI ───────────────────────────────────────────────────────────────────────
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
# ── Build-time ────────────────────────────────────────────────────────────────
|
# ── Build-time ────────────────────────────────────────────────────────────────
|
||||||
capnpc = { version = "0.19" }
|
capnpc = { version = "0.19" }
|
||||||
|
|||||||
153
M3_STATUS.md
Normal file
153
M3_STATUS.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# M3 Implementation Status
|
||||||
|
|
||||||
|
**Last updated:** 2026-02-19
|
||||||
|
**Branch:** feat/m1-noise-transport (all milestones on this branch so far)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is M3?
|
||||||
|
|
||||||
|
M3 adds:
|
||||||
|
1. **Delivery Service (DS)** — store-and-forward relay for MLS messages (Cap'n Proto RPC on port 7001)
|
||||||
|
2. **MLS Group Lifecycle** — `GroupMember` struct: create group, add member (Welcome), join group, send/receive encrypted application messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed in M3
|
||||||
|
|
||||||
|
### `schemas/delivery.capnp` ✅
|
||||||
|
Simple DS schema: `enqueue(recipientKey, payload)` + `fetch(recipientKey) → List(Data)`.
|
||||||
|
|
||||||
|
### `noiseml-proto/build.rs` ✅
|
||||||
|
Compiles `delivery.capnp` alongside `envelope.capnp` and `auth.capnp`.
|
||||||
|
|
||||||
|
### `noiseml-proto/src/lib.rs` ✅
|
||||||
|
Exposes `pub mod delivery_capnp`.
|
||||||
|
|
||||||
|
### `noiseml-core/src/group.rs` ✅ (FULLY FIXED, ALL TESTS PASS)
|
||||||
|
`GroupMember` struct with methods:
|
||||||
|
- `new(identity: Arc<IdentityKeypair>) -> Self`
|
||||||
|
- `generate_key_package() -> Result<Vec<u8>, CoreError>` — TLS-encoded KeyPackage bytes
|
||||||
|
- `create_group(group_id: &[u8]) -> Result<(), CoreError>`
|
||||||
|
- `add_member(key_package_bytes: &[u8]) -> Result<(commit_bytes, welcome_bytes), CoreError>`
|
||||||
|
- `join_group(welcome_bytes: &[u8]) -> Result<(), CoreError>`
|
||||||
|
- `send_message(plaintext: &[u8]) -> Result<Vec<u8>, CoreError>` — returns TLS-encoded PrivateMessage
|
||||||
|
- `receive_message(bytes: &[u8]) -> Result<Option<Vec<u8>>, CoreError>` — returns plaintext or None for Commit
|
||||||
|
- `group_id() -> Option<Vec<u8>>`
|
||||||
|
- `identity() -> &IdentityKeypair`
|
||||||
|
|
||||||
|
**openmls 0.5 API gotchas resolved:**
|
||||||
|
- `KeyPackage` only has `TlsSerialize`, not `TlsDeserialize` → use `KeyPackageIn::tls_deserialize(...)?.validate(backend.crypto(), ProtocolVersion::Mls10)?`
|
||||||
|
- `MlsMessageIn::into_welcome()` is `#[cfg(any(feature = "test-utils", test))]` → use `match msg_in.extract() { MlsMessageInBody::Welcome(w) => w, ... }`
|
||||||
|
- `MlsMessageIn::into_protocol_message()` is similarly feature-gated → use `match msg_in.extract() { MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m), MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m), ... }`
|
||||||
|
- `From<MlsMessageIn> for ProtocolMessage` is also feature-gated
|
||||||
|
- Must use `OpenMlsCryptoProvider` trait in scope for `backend.crypto()`
|
||||||
|
|
||||||
|
### `noiseml-core/src/lib.rs` ✅
|
||||||
|
Exposes `pub use group::GroupMember`.
|
||||||
|
|
||||||
|
### `noiseml-server/src/main.rs` ✅
|
||||||
|
Two listeners on one `LocalSet`:
|
||||||
|
- Port 7000 (AS): `AuthServiceImpl` — unchanged from M2
|
||||||
|
- Port 7001 (DS): `DeliveryServiceImpl` — new; uses `DashMap<Vec<u8>, VecDeque<Vec<u8>>>` keyed by Ed25519 public key
|
||||||
|
|
||||||
|
New CLI flag: `--ds-listen` (default `0.0.0.0:7001`, env `NOISEML_DS_LISTEN`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NOT YET DONE (continue tomorrow)
|
||||||
|
|
||||||
|
### 1. `noiseml-client/src/main.rs` — Group subcommands
|
||||||
|
|
||||||
|
Add these subcommands (note: need state persistence or a `demo` command approach):
|
||||||
|
|
||||||
|
**Recommended approach:** Add a `demo-group` subcommand that runs the full Alice-Bob MLS round-trip in a single process invocation against a live server. This avoids the `MlsGroup` serialization problem (openmls 0.5 MlsGroup state is hard to persist without the `serde` feature).
|
||||||
|
|
||||||
|
**Alternatively (with state file):** Enable `serde` feature on openmls in `Cargo.toml` and store `MlsGroup` state to disk. The workspace Cargo.toml uses `features = ["crypto-subtle"]` for openmls — add `"serde"` to that list.
|
||||||
|
|
||||||
|
Subcommands needed:
|
||||||
|
- `create-group --as-server --ds-server --group-id <NAME>` — creates group, saves state
|
||||||
|
- `invite --as-server --ds-server --peer-key <HEX>` — fetches peer KP from AS, creates Welcome, enqueues to DS
|
||||||
|
- `join --ds-server` — fetches Welcome from DS, joins group, saves state
|
||||||
|
- `send --ds-server --peer-key <HEX> --msg <TEXT>` — sends application message to DS
|
||||||
|
- `recv --ds-server` — fetches and decrypts messages from DS
|
||||||
|
|
||||||
|
OR: just add `demo-group --server --ds-server` that does the whole flow.
|
||||||
|
|
||||||
|
### 2. `noiseml-client/tests/mls_group.rs` — Integration test
|
||||||
|
|
||||||
|
This is the PRIORITY for testing. The integration test should:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 1. Spawn server (AS on port X, DS on port Y) with tokio::process::Command
|
||||||
|
// or by directly calling the server's accept loop in a LocalSet
|
||||||
|
// 2. Alice: GroupMember::new, generate_key_package, upload to AS
|
||||||
|
// 3. Bob: GroupMember::new, generate_key_package, upload to AS
|
||||||
|
// 4. Alice: create_group, fetch Bob's KP from AS, add_member → (commit, welcome)
|
||||||
|
// Alice: enqueue welcome for Bob via DS (recipient = bob's identity.public_key_bytes())
|
||||||
|
// 5. Bob: fetch from DS, join_group(welcome)
|
||||||
|
// 6. Alice: send_message(b"hello bob"), enqueue to DS
|
||||||
|
// 7. Bob: fetch from DS, receive_message → assert plaintext == b"hello bob"
|
||||||
|
// 8. Bob: send_message(b"hello alice"), enqueue to DS
|
||||||
|
// 9. Alice: fetch from DS, receive_message → assert plaintext == b"hello alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** For the integration test, you can bypass the CLI and use `GroupMember` + capnp-rpc client helpers directly.
|
||||||
|
|
||||||
|
Connect to DS (port 7001):
|
||||||
|
```rust
|
||||||
|
async fn connect_ds(server: &str, keypair: &NoiseKeypair) -> anyhow::Result<delivery_service::Client> {
|
||||||
|
let stream = TcpStream::connect(server).await?;
|
||||||
|
let transport = handshake_initiator(stream, keypair).await?;
|
||||||
|
let (reader, writer) = transport.into_capnp_io();
|
||||||
|
let network = twoparty::VatNetwork::new(reader.compat(), writer.compat_write(), Side::Client, Default::default());
|
||||||
|
let mut rpc = RpcSystem::new(Box::new(network), None);
|
||||||
|
let ds: delivery_service::Client = rpc.bootstrap(Side::Server);
|
||||||
|
tokio::task::spawn_local(rpc);
|
||||||
|
Ok(ds)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### DS Port (7001) vs same port
|
||||||
|
The server uses **two separate listeners** (7000 for AS, 7001 for DS) because capnp-rpc supports only one bootstrap capability per connection. No new schema was needed.
|
||||||
|
|
||||||
|
### GroupMember lifecycle (CRITICAL)
|
||||||
|
The `OpenMlsRustCrypto` backend holds the HPKE init private key **in memory**. The **same `GroupMember` instance** must be used from `generate_key_package()` through `join_group()`. Do NOT create a new GroupMember between these calls.
|
||||||
|
|
||||||
|
### KeyPackage wire format
|
||||||
|
`GroupMember::generate_key_package()` returns raw TLS-encoded `KeyPackage` bytes (NOT wrapped in `MlsMessageOut`). This is the same format as the standalone `generate_key_package()` function used in M2 tests. The AS stores these raw bytes.
|
||||||
|
|
||||||
|
When adding a member, `add_member()` deserializes via `KeyPackageIn::tls_deserialize(...)?.validate(...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results (all passing)
|
||||||
|
|
||||||
|
```
|
||||||
|
test codec::tests::* ... ok (5 tests)
|
||||||
|
test keypair::tests::* ... ok (3 tests)
|
||||||
|
test group::tests::two_party_mls_round_trip ... ok
|
||||||
|
test group::tests::group_id_lifecycle ... ok
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to continue tomorrow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/c/projects/poc-mes
|
||||||
|
git log --oneline -5 # see where we are
|
||||||
|
cargo test -p noiseml-core # verify green
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
1. Write `crates/noiseml-client/tests/mls_group.rs` (integration test) — highest priority
|
||||||
|
2. Add group subcommands to `crates/noiseml-client/src/main.rs`
|
||||||
|
|
||||||
|
The integration test is the most important piece — it proves the full M3 stack works end-to-end.
|
||||||
|
|
||||||
|
For the test, see the pattern in `crates/noiseml-client/tests/auth_service.rs` (M2 test) for how to spin up the server and connect clients.
|
||||||
@@ -26,6 +26,9 @@ futures = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
# Crypto — for fingerprint verification in fetch-key subcommand
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
@@ -34,5 +37,5 @@ tracing-subscriber = { workspace = true }
|
|||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Integration tests spin up both server and client in the same process.
|
# Integration tests use noiseml-core, noiseml-proto, and capnp-rpc directly.
|
||||||
noiseml-server = { path = "../noiseml-server" }
|
dashmap = { workspace = true }
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
//! noiseml CLI client.
|
//! noiseml CLI client.
|
||||||
//!
|
//!
|
||||||
//! # M1 subcommands
|
//! # Subcommands
|
||||||
//!
|
//!
|
||||||
//! | Subcommand | Description |
|
//! | Subcommand | Description |
|
||||||
//! |------------|-----------------------------------------|
|
//! |--------------|----------------------------------------------------------|
|
||||||
//! | `ping` | Send a Ping to the server, print RTT |
|
//! | `ping` | Send a Ping to the server, print RTT |
|
||||||
|
//! | `register` | Generate a KeyPackage and upload it to the AS |
|
||||||
|
//! | `fetch-key` | Fetch a peer's KeyPackage from the AS by identity key |
|
||||||
//!
|
//!
|
||||||
//! # Configuration
|
//! # Configuration
|
||||||
//!
|
//!
|
||||||
@@ -12,19 +14,15 @@
|
|||||||
//! |-----------------|--------------|---------------------|
|
//! |-----------------|--------------|---------------------|
|
||||||
//! | `NOISEML_SERVER`| `--server` | `127.0.0.1:7000` |
|
//! | `NOISEML_SERVER`| `--server` | `127.0.0.1:7000` |
|
||||||
//! | `RUST_LOG` | — | `warn` |
|
//! | `RUST_LOG` | — | `warn` |
|
||||||
//!
|
|
||||||
//! # Keypair lifecycle
|
|
||||||
//!
|
|
||||||
//! A fresh ephemeral X25519 keypair is generated per invocation in M1.
|
|
||||||
//! M2 introduces persistent identity keys stored locally and registered
|
|
||||||
//! with the Authentication Service.
|
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||||
|
|
||||||
use noiseml_core::{NoiseKeypair, handshake_initiator};
|
use noiseml_core::{IdentityKeypair, NoiseKeypair, generate_key_package, handshake_initiator};
|
||||||
use noiseml_proto::{MsgType, ParsedEnvelope};
|
use noiseml_proto::{MsgType, ParsedEnvelope, auth_capnp::authentication_service};
|
||||||
|
|
||||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -43,6 +41,29 @@ enum Command {
|
|||||||
#[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")]
|
#[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")]
|
||||||
server: String,
|
server: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Generate a fresh MLS KeyPackage and upload it to the Authentication Service.
|
||||||
|
///
|
||||||
|
/// Prints the SHA-256 fingerprint of the uploaded package and the raw
|
||||||
|
/// Ed25519 identity public key bytes (hex), which peers need to fetch it.
|
||||||
|
Register {
|
||||||
|
/// Server address (host:port).
|
||||||
|
#[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")]
|
||||||
|
server: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Fetch a peer's KeyPackage from the Authentication Service.
|
||||||
|
///
|
||||||
|
/// IDENTITY_KEY is the peer's Ed25519 public key encoded as 64 lowercase
|
||||||
|
/// hex characters (32 bytes).
|
||||||
|
FetchKey {
|
||||||
|
/// Server address (host:port).
|
||||||
|
#[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")]
|
||||||
|
server: String,
|
||||||
|
|
||||||
|
/// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes).
|
||||||
|
identity_key: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
@@ -60,17 +81,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
match args.command {
|
match args.command {
|
||||||
Command::Ping { server } => cmd_ping(&server).await,
|
Command::Ping { server } => cmd_ping(&server).await,
|
||||||
|
Command::Register { server } => {
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local.run_until(cmd_register(&server)).await
|
||||||
|
}
|
||||||
|
Command::FetchKey {
|
||||||
|
server,
|
||||||
|
identity_key,
|
||||||
|
} => {
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local.run_until(cmd_fetch_key(&server, &identity_key)).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subcommand implementations ────────────────────────────────────────────────
|
// ── Subcommand implementations ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Connect to `server`, complete Noise_XX, send a Ping, and print RTT.
|
/// Connect to `server`, complete Noise_XX, send a Ping, and print RTT.
|
||||||
///
|
|
||||||
/// Exits with status 0 on a valid Pong, non-zero on any error.
|
|
||||||
async fn cmd_ping(server: &str) -> anyhow::Result<()> {
|
async fn cmd_ping(server: &str) -> anyhow::Result<()> {
|
||||||
// Generate a fresh ephemeral keypair for this session.
|
|
||||||
// M2 will load a persistent identity keypair instead.
|
|
||||||
let keypair = NoiseKeypair::generate();
|
let keypair = NoiseKeypair::generate();
|
||||||
|
|
||||||
let stream = TcpStream::connect(server)
|
let stream = TcpStream::connect(server)
|
||||||
@@ -86,12 +114,11 @@ async fn cmd_ping(server: &str) -> anyhow::Result<()> {
|
|||||||
{
|
{
|
||||||
let remote = transport
|
let remote = transport
|
||||||
.remote_static_public_key()
|
.remote_static_public_key()
|
||||||
.map(fmt_key)
|
.map(|k| fmt_hex(&k[..4]))
|
||||||
.unwrap_or_else(|| "unknown".into());
|
.unwrap_or_else(|| "unknown".into());
|
||||||
tracing::debug!(server_key = %remote, "handshake complete");
|
tracing::debug!(server_key = %remote, "handshake complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record send time immediately before writing to minimise measurement skew.
|
|
||||||
let sent_at = current_timestamp_ms();
|
let sent_at = current_timestamp_ms();
|
||||||
|
|
||||||
transport
|
transport
|
||||||
@@ -118,22 +145,143 @@ async fn cmd_ping(server: &str) -> anyhow::Result<()> {
|
|||||||
println!("Pong from {server} rtt={rtt_ms}ms");
|
println!("Pong from {server} rtt={rtt_ms}ms");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => anyhow::bail!(
|
||||||
anyhow::bail!(
|
|
||||||
"protocol error: expected Pong from {server}, got unexpected message type"
|
"protocol error: expected Pong from {server}, got unexpected message type"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a KeyPackage for a fresh identity and upload it to the AS.
|
||||||
|
///
|
||||||
|
/// Must run on a `LocalSet` because capnp-rpc is `!Send`.
|
||||||
|
async fn cmd_register(server: &str) -> anyhow::Result<()> {
|
||||||
|
let noise_keypair = NoiseKeypair::generate();
|
||||||
|
let identity = IdentityKeypair::generate();
|
||||||
|
|
||||||
|
let (tls_bytes, fingerprint) =
|
||||||
|
generate_key_package(&identity).context("KeyPackage generation failed")?;
|
||||||
|
|
||||||
|
let as_client = connect_as(server, &noise_keypair).await?;
|
||||||
|
|
||||||
|
let mut req = as_client.upload_key_package_request();
|
||||||
|
req.get().set_identity_key(&identity.public_key_bytes());
|
||||||
|
req.get().set_package(&tls_bytes);
|
||||||
|
|
||||||
|
let response = req
|
||||||
|
.send()
|
||||||
|
.promise
|
||||||
|
.await
|
||||||
|
.context("upload_key_package RPC failed")?;
|
||||||
|
|
||||||
|
let server_fp = response
|
||||||
|
.get()
|
||||||
|
.context("upload_key_package: bad response")?
|
||||||
|
.get_fingerprint()
|
||||||
|
.context("upload_key_package: missing fingerprint")?
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
// Verify the server echoed the same fingerprint.
|
||||||
|
anyhow::ensure!(
|
||||||
|
server_fp == fingerprint,
|
||||||
|
"fingerprint mismatch: local={} server={}",
|
||||||
|
hex::encode(&fingerprint),
|
||||||
|
hex::encode(&server_fp),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
println!("identity_key : {}", hex::encode(identity.public_key_bytes()));
|
||||||
|
println!("fingerprint : {}", hex::encode(&fingerprint));
|
||||||
|
println!("KeyPackage uploaded successfully.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
/// Fetch a peer's KeyPackage from the AS by their hex-encoded identity key.
|
||||||
|
///
|
||||||
|
/// Must run on a `LocalSet` because capnp-rpc is `!Send`.
|
||||||
|
async fn cmd_fetch_key(server: &str, identity_key_hex: &str) -> anyhow::Result<()> {
|
||||||
|
let identity_key = hex::decode(identity_key_hex)
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))
|
||||||
|
.context("identity_key must be 64 hex characters (32 bytes)")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
identity_key.len() == 32,
|
||||||
|
"identity_key must be exactly 32 bytes, got {}",
|
||||||
|
identity_key.len()
|
||||||
|
);
|
||||||
|
|
||||||
/// Format the first 4 bytes of a key as hex with a trailing ellipsis.
|
let noise_keypair = NoiseKeypair::generate();
|
||||||
fn fmt_key(key: &[u8]) -> String {
|
let as_client = connect_as(server, &noise_keypair).await?;
|
||||||
if key.len() < 4 {
|
|
||||||
return format!("{key:02x?}");
|
let mut req = as_client.fetch_key_package_request();
|
||||||
|
req.get().set_identity_key(&identity_key);
|
||||||
|
|
||||||
|
let response = req
|
||||||
|
.send()
|
||||||
|
.promise
|
||||||
|
.await
|
||||||
|
.context("fetch_key_package RPC failed")?;
|
||||||
|
|
||||||
|
let package = response
|
||||||
|
.get()
|
||||||
|
.context("fetch_key_package: bad response")?
|
||||||
|
.get_package()
|
||||||
|
.context("fetch_key_package: missing package field")?
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
if package.is_empty() {
|
||||||
|
println!("No KeyPackage available for this identity.");
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3])
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let fingerprint = Sha256::digest(&package);
|
||||||
|
|
||||||
|
println!("fingerprint : {}", hex::encode(fingerprint));
|
||||||
|
println!("package_len : {} bytes", package.len());
|
||||||
|
println!("KeyPackage fetched successfully.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Establish a Noise_XX connection and return an `AuthenticationService` client.
|
||||||
|
///
|
||||||
|
/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`.
|
||||||
|
async fn connect_as(
|
||||||
|
server: &str,
|
||||||
|
noise_keypair: &NoiseKeypair,
|
||||||
|
) -> anyhow::Result<authentication_service::Client> {
|
||||||
|
let stream = TcpStream::connect(server)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("could not connect to {server}"))?;
|
||||||
|
|
||||||
|
let transport = handshake_initiator(stream, noise_keypair)
|
||||||
|
.await
|
||||||
|
.context("Noise_XX handshake failed")?;
|
||||||
|
|
||||||
|
let (reader, writer) = transport.into_capnp_io();
|
||||||
|
|
||||||
|
let network = twoparty::VatNetwork::new(
|
||||||
|
reader.compat(),
|
||||||
|
writer.compat_write(),
|
||||||
|
Side::Client,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut rpc_system = RpcSystem::new(Box::new(network), None);
|
||||||
|
let as_client: authentication_service::Client =
|
||||||
|
rpc_system.bootstrap(Side::Server);
|
||||||
|
|
||||||
|
// Drive the RPC system on the local set.
|
||||||
|
tokio::task::spawn_local(rpc_system);
|
||||||
|
|
||||||
|
Ok(as_client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format the first `n` bytes as lowercase hex with a trailing `…`.
|
||||||
|
fn fmt_hex(bytes: &[u8]) -> String {
|
||||||
|
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
format!("{hex}…")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the current Unix timestamp in milliseconds.
|
/// Return the current Unix timestamp in milliseconds.
|
||||||
@@ -143,3 +291,23 @@ fn current_timestamp_ms() -> u64 {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis() as u64
|
.as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hex encoding helper ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// We use a tiny inline module rather than adding `hex` as a dependency.
|
||||||
|
|
||||||
|
mod hex {
|
||||||
|
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
||||||
|
bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(s: &str) -> Result<Vec<u8>, &'static str> {
|
||||||
|
if s.len() % 2 != 0 {
|
||||||
|
return Err("odd-length hex string");
|
||||||
|
}
|
||||||
|
(0..s.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
255
crates/noiseml-client/tests/auth_service.rs
Normal file
255
crates/noiseml-client/tests/auth_service.rs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
//! Integration test: M2 Authentication Service — KeyPackage upload + fetch.
|
||||||
|
//!
|
||||||
|
//! All tests run inside a single `tokio::task::LocalSet` so that `spawn_local`
|
||||||
|
//! can be used for capnp-rpc tasks (which are `!Send` due to internal `Rc` use).
|
||||||
|
|
||||||
|
use std::{collections::VecDeque, sync::Arc};
|
||||||
|
|
||||||
|
use capnp::capability::Promise;
|
||||||
|
use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty};
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use noiseml_core::{
|
||||||
|
IdentityKeypair, NoiseKeypair, generate_key_package, handshake_initiator,
|
||||||
|
handshake_responder,
|
||||||
|
};
|
||||||
|
use noiseml_proto::auth_capnp::authentication_service;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Store = Arc<DashMap<Vec<u8>, VecDeque<Vec<u8>>>>;
|
||||||
|
|
||||||
|
// ── Inline AS server implementation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
struct TestAuthService {
|
||||||
|
store: Store,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl authentication_service::Server for TestAuthService {
|
||||||
|
fn upload_key_package(
|
||||||
|
&mut self,
|
||||||
|
params: authentication_service::UploadKeyPackageParams,
|
||||||
|
mut results: authentication_service::UploadKeyPackageResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let p = match params.get() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return Promise::err(e),
|
||||||
|
};
|
||||||
|
let ik = match p.get_identity_key() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
let pkg = match p.get_package() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
let fp: Vec<u8> = Sha256::digest(&pkg).to_vec();
|
||||||
|
self.store.entry(ik).or_default().push_back(pkg);
|
||||||
|
results.get().set_fingerprint(&fp);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_key_package(
|
||||||
|
&mut self,
|
||||||
|
params: authentication_service::FetchKeyPackageParams,
|
||||||
|
mut results: authentication_service::FetchKeyPackageResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let ik = match params.get() {
|
||||||
|
Ok(p) => match p.get_identity_key() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
},
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
let pkg = self
|
||||||
|
.store
|
||||||
|
.get_mut(&ik)
|
||||||
|
.and_then(|mut q| q.pop_front())
|
||||||
|
.unwrap_or_default();
|
||||||
|
results.get().set_package(&pkg);
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Spawn a server that accepts `n_connections` and returns the bound address.
|
||||||
|
///
|
||||||
|
/// Must be called from within a `LocalSet` context so that the internal
|
||||||
|
/// `spawn_local` calls are associated with the correct LocalSet.
|
||||||
|
async fn spawn_server(
|
||||||
|
n_connections: usize,
|
||||||
|
keypair: Arc<NoiseKeypair>,
|
||||||
|
store: Store,
|
||||||
|
) -> std::net::SocketAddr {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
tokio::task::spawn_local(async move {
|
||||||
|
for _ in 0..n_connections {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let kp = Arc::clone(&keypair);
|
||||||
|
let st = Arc::clone(&store);
|
||||||
|
tokio::task::spawn_local(async move {
|
||||||
|
serve_one(stream, kp, st).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a single Noise + capnp-rpc server connection.
|
||||||
|
async fn serve_one(stream: TcpStream, keypair: Arc<NoiseKeypair>, store: Store) {
|
||||||
|
let transport = handshake_responder(stream, &keypair).await.unwrap();
|
||||||
|
let (reader, writer) = transport.into_capnp_io();
|
||||||
|
let network = twoparty::VatNetwork::new(
|
||||||
|
reader.compat(),
|
||||||
|
writer.compat_write(),
|
||||||
|
Side::Server,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
let svc: authentication_service::Client =
|
||||||
|
capnp_rpc::new_client(TestAuthService { store });
|
||||||
|
let rpc = RpcSystem::new(Box::new(network), Some(svc.client));
|
||||||
|
tokio::task::spawn_local(rpc).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect and return a client stub. Must run inside a LocalSet.
|
||||||
|
async fn connect_client(addr: std::net::SocketAddr) -> authentication_service::Client {
|
||||||
|
let kp = NoiseKeypair::generate();
|
||||||
|
let stream = TcpStream::connect(addr).await.unwrap();
|
||||||
|
let transport = handshake_initiator(stream, &kp).await.unwrap();
|
||||||
|
let (reader, writer) = transport.into_capnp_io();
|
||||||
|
let network = twoparty::VatNetwork::new(
|
||||||
|
reader.compat(),
|
||||||
|
writer.compat_write(),
|
||||||
|
Side::Client,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
let mut rpc = RpcSystem::new(Box::new(network), None);
|
||||||
|
let client: authentication_service::Client = rpc.bootstrap(Side::Server);
|
||||||
|
tokio::task::spawn_local(rpc);
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Alice uploads a KeyPackage; Bob fetches it. Fingerprints must match.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_then_fetch_fingerprints_match() {
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local
|
||||||
|
.run_until(async move {
|
||||||
|
let store: Store = Arc::new(DashMap::new());
|
||||||
|
let server_kp = Arc::new(NoiseKeypair::generate());
|
||||||
|
|
||||||
|
// Server accepts 2 connections: one for Alice (upload), one for Bob (fetch).
|
||||||
|
let addr = spawn_server(2, Arc::clone(&server_kp), Arc::clone(&store)).await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
// Alice: generate KeyPackage and upload it.
|
||||||
|
let alice_identity = IdentityKeypair::generate();
|
||||||
|
let (tls_bytes, local_fp) = generate_key_package(&alice_identity).unwrap();
|
||||||
|
|
||||||
|
let alice = connect_client(addr).await;
|
||||||
|
let mut req = alice.upload_key_package_request();
|
||||||
|
req.get().set_identity_key(&alice_identity.public_key_bytes());
|
||||||
|
req.get().set_package(&tls_bytes);
|
||||||
|
let resp = req.send().promise.await.unwrap();
|
||||||
|
let server_fp = resp.get().unwrap().get_fingerprint().unwrap().to_vec();
|
||||||
|
|
||||||
|
assert_eq!(local_fp, server_fp, "server fingerprint must match local");
|
||||||
|
|
||||||
|
// Bob: fetch Alice's package by her identity key.
|
||||||
|
let bob = connect_client(addr).await;
|
||||||
|
let mut req2 = bob.fetch_key_package_request();
|
||||||
|
req2.get().set_identity_key(&alice_identity.public_key_bytes());
|
||||||
|
let resp2 = req2.send().promise.await.unwrap();
|
||||||
|
let fetched = resp2.get().unwrap().get_package().unwrap().to_vec();
|
||||||
|
|
||||||
|
assert!(!fetched.is_empty(), "fetched package must not be empty");
|
||||||
|
assert_eq!(fetched, tls_bytes, "fetched bytes must match uploaded bytes");
|
||||||
|
|
||||||
|
let fetched_fp: Vec<u8> = Sha256::digest(&fetched).to_vec();
|
||||||
|
assert_eq!(fetched_fp, local_fp, "fetched fingerprint must match uploaded");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetching a non-existent key returns empty bytes.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_nonexistent_key_returns_empty() {
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local
|
||||||
|
.run_until(async move {
|
||||||
|
let store: Store = Arc::new(DashMap::new());
|
||||||
|
let server_kp = Arc::new(NoiseKeypair::generate());
|
||||||
|
let addr = spawn_server(1, server_kp, store).await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
let client = connect_client(addr).await;
|
||||||
|
let mut req = client.fetch_key_package_request();
|
||||||
|
req.get().set_identity_key(&[0xAAu8; 32]);
|
||||||
|
let resp = req.send().promise.await.unwrap();
|
||||||
|
let pkg = resp.get().unwrap().get_package().unwrap().to_vec();
|
||||||
|
|
||||||
|
assert!(pkg.is_empty(), "unknown identity must return empty package");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploading two packages and fetching twice returns them in FIFO order.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn packages_consumed_in_fifo_order() {
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local
|
||||||
|
.run_until(async move {
|
||||||
|
let store: Store = Arc::new(DashMap::new());
|
||||||
|
|
||||||
|
// Pre-populate the store directly.
|
||||||
|
let key = vec![0x01u8; 32];
|
||||||
|
store
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_default()
|
||||||
|
.extend([vec![1u8, 2, 3], vec![4u8, 5, 6]]);
|
||||||
|
|
||||||
|
let server_kp = Arc::new(NoiseKeypair::generate());
|
||||||
|
// Server accepts 2 connections for the 2 fetches.
|
||||||
|
let addr = spawn_server(2, server_kp, Arc::clone(&store)).await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
let client1 = connect_client(addr).await;
|
||||||
|
let mut req1 = client1.fetch_key_package_request();
|
||||||
|
req1.get().set_identity_key(&key);
|
||||||
|
let pkg1 = req1
|
||||||
|
.send()
|
||||||
|
.promise
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get()
|
||||||
|
.unwrap()
|
||||||
|
.get_package()
|
||||||
|
.unwrap()
|
||||||
|
.to_vec();
|
||||||
|
assert_eq!(pkg1, vec![1u8, 2, 3], "first fetch must return first package");
|
||||||
|
|
||||||
|
let client2 = connect_client(addr).await;
|
||||||
|
let mut req2 = client2.fetch_key_package_request();
|
||||||
|
req2.get().set_identity_key(&key);
|
||||||
|
let pkg2 = req2
|
||||||
|
.send()
|
||||||
|
.promise
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.get()
|
||||||
|
.unwrap()
|
||||||
|
.get_package()
|
||||||
|
.unwrap()
|
||||||
|
.to_vec();
|
||||||
|
assert_eq!(pkg2, vec![4u8, 5, 6], "second fetch must return second package");
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -6,9 +6,7 @@ description = "Crypto primitives, Noise_XX transport, MLS state machine, and Cap
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Crypto
|
# Crypto — classical
|
||||||
# openmls / openmls_rust_crypto / openmls_basic_credential — added in M2
|
|
||||||
# ml-kem — added in M5 (hybrid PQ ciphersuite)
|
|
||||||
x25519-dalek = { workspace = true }
|
x25519-dalek = { workspace = true }
|
||||||
ed25519-dalek = { workspace = true }
|
ed25519-dalek = { workspace = true }
|
||||||
snow = { workspace = true }
|
snow = { workspace = true }
|
||||||
@@ -17,12 +15,20 @@ hkdf = { workspace = true }
|
|||||||
zeroize = { workspace = true }
|
zeroize = { workspace = true }
|
||||||
rand = { 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
|
# Serialisation
|
||||||
capnp = { workspace = true }
|
capnp = { workspace = true }
|
||||||
noiseml-proto = { path = "../noiseml-proto" }
|
noiseml-proto = { path = "../noiseml-proto" }
|
||||||
|
|
||||||
# Async codec
|
# Async runtime + codec
|
||||||
|
tokio = { workspace = true }
|
||||||
tokio-util = { workspace = true }
|
tokio-util = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
bytes = { version = "1" }
|
bytes = { version = "1" }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
|
|||||||
@@ -68,4 +68,10 @@ pub enum CoreError {
|
|||||||
/// The limit is [`MAX_PLAINTEXT_LEN`] bytes per frame.
|
/// The limit is [`MAX_PLAINTEXT_LEN`] bytes per frame.
|
||||||
#[error("plaintext {size} B exceeds Noise frame limit of {MAX_PLAINTEXT_LEN} B")]
|
#[error("plaintext {size} B exceeds Noise frame limit of {MAX_PLAINTEXT_LEN} B")]
|
||||||
MessageTooLarge { size: usize },
|
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 layout
|
||||||
//!
|
//!
|
||||||
//! | Module | Responsibility |
|
//! | Module | Responsibility |
|
||||||
//! |------------|----------------------------------------------------------|
|
//! |--------------|------------------------------------------------------------------|
|
||||||
//! | `error` | [`CoreError`] and [`CodecError`] types |
|
//! | `error` | [`CoreError`] and [`CodecError`] types |
|
||||||
//! | `keypair` | [`NoiseKeypair`] — static X25519 key, zeroize-on-drop |
|
//! | `keypair` | [`NoiseKeypair`] — static X25519 key, zeroize-on-drop |
|
||||||
//! | `codec` | [`LengthPrefixedCodec`] — Tokio Encoder + Decoder |
|
//! | `codec` | [`LengthPrefixedCodec`] — Tokio Encoder + Decoder |
|
||||||
//! | `noise` | [`handshake_initiator`], [`handshake_responder`], [`NoiseTransport`] |
|
//! | `noise` | [`handshake_initiator`], [`handshake_responder`], [`NoiseTransport`] |
|
||||||
//!
|
//! | `identity` | [`IdentityKeypair`] — Ed25519 identity key for MLS credentials |
|
||||||
//! # What is NOT in this crate (M1)
|
//! | `keypackage` | [`generate_key_package`] — standalone KeyPackage generation |
|
||||||
//!
|
//! | `group` | [`GroupMember`] — MLS group lifecycle (create/join/send/recv) |
|
||||||
//! - 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)
|
|
||||||
|
|
||||||
mod codec;
|
mod codec;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod group;
|
||||||
|
mod identity;
|
||||||
mod keypair;
|
mod keypair;
|
||||||
|
mod keypackage;
|
||||||
mod noise;
|
mod noise;
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG};
|
pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG};
|
||||||
pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN};
|
pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN};
|
||||||
|
pub use group::GroupMember;
|
||||||
|
pub use identity::IdentityKeypair;
|
||||||
pub use keypair::NoiseKeypair;
|
pub use keypair::NoiseKeypair;
|
||||||
|
pub use keypackage::generate_key_package;
|
||||||
pub use noise::{handshake_initiator, handshake_responder, NoiseTransport};
|
pub use noise::{handshake_initiator, handshake_responder, NoiseTransport};
|
||||||
|
|||||||
@@ -31,7 +31,10 @@
|
|||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::{SinkExt, StreamExt};
|
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 tokio_util::codec::Framed;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -155,6 +158,77 @@ impl NoiseTransport {
|
|||||||
parse_envelope(&bytes).map_err(CoreError::Capnp)
|
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 ──────────────────────────────────────────────────────
|
// ── Session metadata ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Return the remote peer's static X25519 public key (32 bytes), as
|
/// Return the remote peer's static X25519 public key (32 bytes), as
|
||||||
|
|||||||
@@ -31,12 +31,22 @@ fn main() {
|
|||||||
"cargo:rerun-if-changed={}",
|
"cargo:rerun-if-changed={}",
|
||||||
schemas_dir.join("envelope.capnp").display()
|
schemas_dir.join("envelope.capnp").display()
|
||||||
);
|
);
|
||||||
|
println!(
|
||||||
|
"cargo:rerun-if-changed={}",
|
||||||
|
schemas_dir.join("auth.capnp").display()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"cargo:rerun-if-changed={}",
|
||||||
|
schemas_dir.join("delivery.capnp").display()
|
||||||
|
);
|
||||||
|
|
||||||
capnpc::CompilerCommand::new()
|
capnpc::CompilerCommand::new()
|
||||||
// Treat `schemas/` as the include root so that inter-schema imports
|
// Treat `schemas/` as the include root so that inter-schema imports
|
||||||
// (e.g. `using import "/auth.capnp"`) resolve correctly in later milestones.
|
// resolve correctly.
|
||||||
.src_prefix(&schemas_dir)
|
.src_prefix(&schemas_dir)
|
||||||
.file(schemas_dir.join("envelope.capnp"))
|
.file(schemas_dir.join("envelope.capnp"))
|
||||||
|
.file(schemas_dir.join("auth.capnp"))
|
||||||
|
.file(schemas_dir.join("delivery.capnp"))
|
||||||
.run()
|
.run()
|
||||||
.expect(
|
.expect(
|
||||||
"Cap'n Proto schema compilation failed. \
|
"Cap'n Proto schema compilation failed. \
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ pub mod envelope_capnp {
|
|||||||
include!(concat!(env!("OUT_DIR"), "/envelope_capnp.rs"));
|
include!(concat!(env!("OUT_DIR"), "/envelope_capnp.rs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap'n Proto generated types for `schemas/auth.capnp`.
|
||||||
|
///
|
||||||
|
/// Do not edit this module by hand — it is entirely machine-generated.
|
||||||
|
pub mod auth_capnp {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/auth_capnp.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap'n Proto generated types for `schemas/delivery.capnp`.
|
||||||
|
///
|
||||||
|
/// Do not edit this module by hand — it is entirely machine-generated.
|
||||||
|
pub mod delivery_capnp {
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/delivery_capnp.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Re-exports ────────────────────────────────────────────────────────────────
|
// ── Re-exports ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The message-type discriminant from the `Envelope` schema.
|
/// The message-type discriminant from the `Envelope` schema.
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ futures = { workspace = true }
|
|||||||
|
|
||||||
# Server utilities
|
# Server utilities
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { workspace = true }
|
||||||
|
|||||||
@@ -1,33 +1,54 @@
|
|||||||
//! noiseml-server — Delivery Service + Authentication Service binary.
|
//! noiseml-server — Delivery Service + Authentication Service binary.
|
||||||
//!
|
//!
|
||||||
//! # M1 scope
|
//! # M3 scope
|
||||||
//!
|
//!
|
||||||
//! Accepts Noise_XX connections over TCP and replies to `Ping` frames with
|
//! The server exposes two Noise_XX-protected Cap'n Proto RPC endpoints:
|
||||||
//! `Pong`. The AS and DS RPC interfaces (Cap'n Proto RPC) are added in M2+.
|
//!
|
||||||
|
//! * **AS** (`--listen`, default `0.0.0.0:7000`) — `AuthenticationService`:
|
||||||
|
//! upload and fetch single-use MLS KeyPackages.
|
||||||
|
//! * **DS** (`--ds-listen`, default `0.0.0.0:7001`) — `DeliveryService`:
|
||||||
|
//! enqueue and fetch opaque payloads (Welcome messages, Commits, Application
|
||||||
|
//! messages) keyed by recipient Ed25519 public key.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! TcpListener (AS, 7000) TcpListener (DS, 7001)
|
||||||
|
//! └─ Noise_XX handshake └─ Noise_XX handshake
|
||||||
|
//! └─ capnp-rpc VatNetwork (LocalSet, !Send)
|
||||||
|
//! ├─ AuthServiceImpl (shares KeyPackageStore via Arc)
|
||||||
|
//! └─ DeliveryServiceImpl (shares DeliveryStore via Arc)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Because `capnp-rpc` uses `Rc<RefCell<>>` internally it is `!Send`.
|
||||||
|
//! The entire RPC stack lives on a `tokio::task::LocalSet` spawned per
|
||||||
|
//! connection.
|
||||||
//!
|
//!
|
||||||
//! # Configuration
|
//! # Configuration
|
||||||
//!
|
//!
|
||||||
//! | Env var | CLI flag | Default |
|
//! | Env var | CLI flag | Default |
|
||||||
//! |------------------|-------------|-----------------|
|
//! |---------------------|----------------|-----------------|
|
||||||
//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` |
|
//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` |
|
||||||
|
//! | `NOISEML_DS_LISTEN` | `--ds-listen` | `0.0.0.0:7001` |
|
||||||
//! | `RUST_LOG` | — | `info` |
|
//! | `RUST_LOG` | — | `info` |
|
||||||
//!
|
|
||||||
//! # Keypair lifecycle
|
|
||||||
//!
|
|
||||||
//! A fresh static X25519 keypair is generated at startup. The public key is
|
|
||||||
//! logged so clients can optionally pin it. M6 replaces this with persistent
|
|
||||||
//! key loading from SQLite.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::{collections::VecDeque, sync::Arc};
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use capnp::capability::Promise;
|
||||||
|
use capnp_rpc::{RpcSystem, rpc_twoparty_capnp::Side, twoparty};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use noiseml_core::{NoiseKeypair, handshake_responder};
|
||||||
|
use noiseml_proto::{
|
||||||
|
auth_capnp::authentication_service,
|
||||||
|
delivery_capnp::delivery_service,
|
||||||
|
};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||||
use tracing::Instrument;
|
use tracing::Instrument;
|
||||||
|
|
||||||
use noiseml_core::{CodecError, CoreError, NoiseKeypair, handshake_responder};
|
|
||||||
use noiseml_proto::{MsgType, ParsedEnvelope};
|
|
||||||
|
|
||||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -37,9 +58,240 @@ use noiseml_proto::{MsgType, ParsedEnvelope};
|
|||||||
version
|
version
|
||||||
)]
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// TCP address to listen on.
|
/// TCP address for the Authentication Service.
|
||||||
#[arg(long, default_value = "0.0.0.0:7000", env = "NOISEML_LISTEN")]
|
#[arg(long, default_value = "0.0.0.0:7000", env = "NOISEML_LISTEN")]
|
||||||
listen: String,
|
listen: String,
|
||||||
|
|
||||||
|
/// TCP address for the Delivery Service.
|
||||||
|
#[arg(long, default_value = "0.0.0.0:7001", env = "NOISEML_DS_LISTEN")]
|
||||||
|
ds_listen: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared store types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Thread-safe map from Ed25519 identity public key bytes (32 B) to a queue
|
||||||
|
/// of serialised MLS KeyPackage blobs.
|
||||||
|
///
|
||||||
|
/// Each KeyPackage is single-use per RFC 9420: `fetch_key_package` removes
|
||||||
|
/// and returns exactly one entry.
|
||||||
|
type KeyPackageStore = Arc<DashMap<Vec<u8>, VecDeque<Vec<u8>>>>;
|
||||||
|
|
||||||
|
/// Thread-safe message queue for the Delivery Service.
|
||||||
|
///
|
||||||
|
/// Maps recipient Ed25519 public key (32 bytes) to a FIFO queue of opaque
|
||||||
|
/// payload bytes (TLS-encoded MLS messages or other framed data).
|
||||||
|
type DeliveryStore = Arc<DashMap<Vec<u8>, VecDeque<Vec<u8>>>>;
|
||||||
|
|
||||||
|
// ── Authentication Service implementation ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Cap'n Proto RPC server implementation for `AuthenticationService`.
|
||||||
|
struct AuthServiceImpl {
|
||||||
|
store: KeyPackageStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl authentication_service::Server for AuthServiceImpl {
|
||||||
|
/// Upload a single-use KeyPackage and return its SHA-256 fingerprint.
|
||||||
|
fn upload_key_package(
|
||||||
|
&mut self,
|
||||||
|
params: authentication_service::UploadKeyPackageParams,
|
||||||
|
mut results: authentication_service::UploadKeyPackageResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let params = params.get().map_err(|e| {
|
||||||
|
capnp::Error::failed(format!("upload_key_package: bad params: {e}"))
|
||||||
|
});
|
||||||
|
|
||||||
|
let (identity_key, package) = match params {
|
||||||
|
Ok(p) => {
|
||||||
|
let ik = match p.get_identity_key() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
let pkg = match p.get_package() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
(ik, pkg)
|
||||||
|
}
|
||||||
|
Err(e) => return Promise::err(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
if identity_key.len() != 32 {
|
||||||
|
return Promise::err(capnp::Error::failed(format!(
|
||||||
|
"identityKey must be exactly 32 bytes, got {}",
|
||||||
|
identity_key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if package.is_empty() {
|
||||||
|
return Promise::err(capnp::Error::failed(
|
||||||
|
"package must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let fingerprint: Vec<u8> = Sha256::digest(&package).to_vec();
|
||||||
|
|
||||||
|
self.store
|
||||||
|
.entry(identity_key)
|
||||||
|
.or_default()
|
||||||
|
.push_back(package);
|
||||||
|
|
||||||
|
results
|
||||||
|
.get()
|
||||||
|
.set_fingerprint(&fingerprint);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
fingerprint = %fmt_hex(&fingerprint[..4]),
|
||||||
|
"KeyPackage uploaded"
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically remove and return one KeyPackage for the given identity key.
|
||||||
|
fn fetch_key_package(
|
||||||
|
&mut self,
|
||||||
|
params: authentication_service::FetchKeyPackageParams,
|
||||||
|
mut results: authentication_service::FetchKeyPackageResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let identity_key = match params.get() {
|
||||||
|
Ok(p) => match p.get_identity_key() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
},
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if identity_key.len() != 32 {
|
||||||
|
return Promise::err(capnp::Error::failed(format!(
|
||||||
|
"identityKey must be exactly 32 bytes, got {}",
|
||||||
|
identity_key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically pop one package from the front of the queue.
|
||||||
|
let package = self
|
||||||
|
.store
|
||||||
|
.get_mut(&identity_key)
|
||||||
|
.and_then(|mut q| q.pop_front());
|
||||||
|
|
||||||
|
match package {
|
||||||
|
Some(pkg) => {
|
||||||
|
tracing::debug!(
|
||||||
|
identity = %fmt_hex(&identity_key[..4]),
|
||||||
|
"KeyPackage fetched"
|
||||||
|
);
|
||||||
|
results.get().set_package(&pkg);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::debug!(
|
||||||
|
identity = %fmt_hex(&identity_key[..4]),
|
||||||
|
"no KeyPackage available for identity"
|
||||||
|
);
|
||||||
|
// Return empty Data — schema specifies this as the "no package" sentinel.
|
||||||
|
results.get().set_package(&[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delivery Service implementation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Cap'n Proto RPC server implementation for `DeliveryService`.
|
||||||
|
///
|
||||||
|
/// Provides a simple store-and-forward relay for MLS messages:
|
||||||
|
/// * `enqueue` appends an opaque payload to the recipient's FIFO queue.
|
||||||
|
/// * `fetch` atomically drains and returns the entire queue.
|
||||||
|
struct DeliveryServiceImpl {
|
||||||
|
store: DeliveryStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl delivery_service::Server for DeliveryServiceImpl {
|
||||||
|
/// Append `payload` to the queue for `recipient_key`.
|
||||||
|
fn enqueue(
|
||||||
|
&mut self,
|
||||||
|
params: delivery_service::EnqueueParams,
|
||||||
|
_results: delivery_service::EnqueueResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let p = match params.get() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
let recipient_key = match p.get_recipient_key() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
let payload = match p.get_payload() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if recipient_key.len() != 32 {
|
||||||
|
return Promise::err(capnp::Error::failed(format!(
|
||||||
|
"recipientKey must be exactly 32 bytes, got {}",
|
||||||
|
recipient_key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if payload.is_empty() {
|
||||||
|
return Promise::err(capnp::Error::failed(
|
||||||
|
"payload must not be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.store
|
||||||
|
.entry(recipient_key.clone())
|
||||||
|
.or_default()
|
||||||
|
.push_back(payload);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
recipient = %fmt_hex(&recipient_key[..4]),
|
||||||
|
"message enqueued"
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically drain and return all queued payloads for `recipient_key`.
|
||||||
|
fn fetch(
|
||||||
|
&mut self,
|
||||||
|
params: delivery_service::FetchParams,
|
||||||
|
mut results: delivery_service::FetchResults,
|
||||||
|
) -> Promise<(), capnp::Error> {
|
||||||
|
let recipient_key = match params.get() {
|
||||||
|
Ok(p) => match p.get_recipient_key() {
|
||||||
|
Ok(v) => v.to_vec(),
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
},
|
||||||
|
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if recipient_key.len() != 32 {
|
||||||
|
return Promise::err(capnp::Error::failed(format!(
|
||||||
|
"recipientKey must be exactly 32 bytes, got {}",
|
||||||
|
recipient_key.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically drain the entire queue.
|
||||||
|
let messages: Vec<Vec<u8>> = self
|
||||||
|
.store
|
||||||
|
.get_mut(&recipient_key)
|
||||||
|
.map(|mut q| q.drain(..).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
recipient = %fmt_hex(&recipient_key[..4]),
|
||||||
|
count = messages.len(),
|
||||||
|
"messages fetched"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut list = results.get().init_payloads(messages.len() as u32);
|
||||||
|
for (i, msg) in messages.iter().enumerate() {
|
||||||
|
list.set(i as u32, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise::ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
@@ -55,126 +307,154 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
// Generate a fresh static keypair for this server instance.
|
// Generate a fresh static Noise keypair for this server instance.
|
||||||
// M6 will replace this with persistent key loading from SQLite.
|
// M6 replaces this with persistent key loading from SQLite.
|
||||||
let keypair = Arc::new(NoiseKeypair::generate());
|
let keypair = Arc::new(NoiseKeypair::generate());
|
||||||
|
|
||||||
{
|
{
|
||||||
let pub_bytes = keypair.public_bytes();
|
let pub_bytes = keypair.public_bytes();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
listen = %args.listen,
|
listen = %args.listen,
|
||||||
public_key = %fmt_key(&pub_bytes),
|
ds_listen = %args.ds_listen,
|
||||||
"noiseml-server starting — key is ephemeral in M1 (not persisted)"
|
public_key = %fmt_hex(&pub_bytes[..4]),
|
||||||
|
"noiseml-server starting (M3) — keypair is ephemeral"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let listener = TcpListener::bind(&args.listen)
|
// Shared stores — all connections share the same in-memory maps.
|
||||||
|
let kp_store: KeyPackageStore = Arc::new(DashMap::new());
|
||||||
|
let ds_store: DeliveryStore = Arc::new(DashMap::new());
|
||||||
|
|
||||||
|
let as_listener = TcpListener::bind(&args.listen)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("failed to bind to {}", args.listen))?;
|
.with_context(|| format!("failed to bind AS to {}", args.listen))?;
|
||||||
|
|
||||||
tracing::info!(listen = %args.listen, "accepting connections");
|
let ds_listener = TcpListener::bind(&args.ds_listen)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to bind DS to {}", args.ds_listen))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
as_addr = %args.listen,
|
||||||
|
ds_addr = %args.ds_listen,
|
||||||
|
"accepting connections"
|
||||||
|
);
|
||||||
|
|
||||||
|
// capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a
|
||||||
|
// LocalSet. Both accept loops share one LocalSet.
|
||||||
|
let local = tokio::task::LocalSet::new();
|
||||||
|
local
|
||||||
|
.run_until(async move {
|
||||||
loop {
|
loop {
|
||||||
let (stream, peer_addr) = listener.accept().await.context("accept failed")?;
|
tokio::select! {
|
||||||
|
result = as_listener.accept() => {
|
||||||
|
let (stream, peer_addr) = result.context("AS accept failed")?;
|
||||||
let keypair = Arc::clone(&keypair);
|
let keypair = Arc::clone(&keypair);
|
||||||
|
let store = Arc::clone(&kp_store);
|
||||||
tokio::spawn(
|
tokio::task::spawn_local(
|
||||||
async move {
|
async move {
|
||||||
match handle_connection(stream, keypair).await {
|
match handle_as_connection(stream, keypair, store).await {
|
||||||
Ok(()) => tracing::debug!("connection closed cleanly"),
|
Ok(()) => tracing::debug!("AS connection closed"),
|
||||||
Err(e) => tracing::warn!(error = %e, "connection error"),
|
Err(e) => tracing::warn!(error = %e, "AS connection error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.instrument(tracing::info_span!("conn", peer = %peer_addr)),
|
.instrument(tracing::info_span!("as_conn", peer = %peer_addr)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result = ds_listener.accept() => {
|
||||||
|
let (stream, peer_addr) = result.context("DS accept failed")?;
|
||||||
|
let keypair = Arc::clone(&keypair);
|
||||||
|
let store = Arc::clone(&ds_store);
|
||||||
|
tokio::task::spawn_local(
|
||||||
|
async move {
|
||||||
|
match handle_ds_connection(stream, keypair, store).await {
|
||||||
|
Ok(()) => tracing::debug!("DS connection closed"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "DS connection error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.instrument(tracing::info_span!("ds_conn", peer = %peer_addr)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// ── Per-connection handler ────────────────────────────────────────────────────
|
// ── Per-connection handlers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Drive a single client connection through handshake and M1 message loop.
|
/// Handle one Authentication Service connection.
|
||||||
///
|
async fn handle_as_connection(
|
||||||
/// Returns `Ok(())` on any clean or expected disconnection.
|
|
||||||
/// Returns `Err` only for unexpected Noise or decryption failures.
|
|
||||||
async fn handle_connection(
|
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
keypair: Arc<NoiseKeypair>,
|
keypair: Arc<NoiseKeypair>,
|
||||||
) -> Result<(), CoreError> {
|
store: KeyPackageStore,
|
||||||
let mut transport = handshake_responder(stream, &keypair).await?;
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let transport = noise_handshake(stream, &keypair, "AS").await?;
|
||||||
|
let (reader, writer) = transport.into_capnp_io();
|
||||||
|
|
||||||
|
let network = twoparty::VatNetwork::new(
|
||||||
|
reader.compat(),
|
||||||
|
writer.compat_write(),
|
||||||
|
Side::Server,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let service: authentication_service::Client =
|
||||||
|
capnp_rpc::new_client(AuthServiceImpl { store });
|
||||||
|
|
||||||
|
RpcSystem::new(Box::new(network), Some(service.client))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("AS RPC error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle one Delivery Service connection.
|
||||||
|
async fn handle_ds_connection(
|
||||||
|
stream: TcpStream,
|
||||||
|
keypair: Arc<NoiseKeypair>,
|
||||||
|
store: DeliveryStore,
|
||||||
|
) -> Result<(), anyhow::Error> {
|
||||||
|
let transport = noise_handshake(stream, &keypair, "DS").await?;
|
||||||
|
let (reader, writer) = transport.into_capnp_io();
|
||||||
|
|
||||||
|
let network = twoparty::VatNetwork::new(
|
||||||
|
reader.compat(),
|
||||||
|
writer.compat_write(),
|
||||||
|
Side::Server,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let service: delivery_service::Client =
|
||||||
|
capnp_rpc::new_client(DeliveryServiceImpl { store });
|
||||||
|
|
||||||
|
RpcSystem::new(Box::new(network), Some(service.client))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("DS RPC error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform the Noise_XX handshake and log the remote key.
|
||||||
|
async fn noise_handshake(
|
||||||
|
stream: TcpStream,
|
||||||
|
keypair: &NoiseKeypair,
|
||||||
|
label: &str,
|
||||||
|
) -> anyhow::Result<noiseml_core::NoiseTransport> {
|
||||||
|
let transport = handshake_responder(stream, keypair)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{label} Noise handshake failed: {e}"))?;
|
||||||
|
|
||||||
{
|
|
||||||
let remote = transport
|
let remote = transport
|
||||||
.remote_static_public_key()
|
.remote_static_public_key()
|
||||||
.map(fmt_key)
|
.map(|k| fmt_hex(&k[..4]))
|
||||||
.unwrap_or_else(|| "unknown".into());
|
.unwrap_or_else(|| "unknown".into());
|
||||||
tracing::info!(remote_key = %remote, "Noise_XX handshake complete");
|
tracing::info!(remote_key = %remote, "{label} Noise_XX handshake complete");
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
Ok(transport)
|
||||||
let env = match transport.recv_envelope().await {
|
|
||||||
Ok(env) => env,
|
|
||||||
|
|
||||||
// Clean EOF: the peer closed the connection gracefully.
|
|
||||||
Err(CoreError::ConnectionClosed) => {
|
|
||||||
tracing::debug!("peer disconnected");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unclean TCP close (RST / unexpected EOF): treat as normal disconnect.
|
|
||||||
Err(CoreError::Codec(CodecError::Io(ref e)))
|
|
||||||
if matches!(
|
|
||||||
e.kind(),
|
|
||||||
std::io::ErrorKind::ConnectionReset
|
|
||||||
| std::io::ErrorKind::UnexpectedEof
|
|
||||||
| std::io::ErrorKind::BrokenPipe
|
|
||||||
) =>
|
|
||||||
{
|
|
||||||
tracing::debug!(io_kind = %e.kind(), "peer disconnected (unclean)");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
};
|
|
||||||
|
|
||||||
match env.msg_type {
|
|
||||||
MsgType::Ping => {
|
|
||||||
tracing::debug!("ping → pong");
|
|
||||||
transport
|
|
||||||
.send_envelope(&ParsedEnvelope {
|
|
||||||
msg_type: MsgType::Pong,
|
|
||||||
group_id: vec![],
|
|
||||||
sender_id: vec![],
|
|
||||||
payload: vec![],
|
|
||||||
timestamp_ms: current_timestamp_ms(),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All other message types are silently ignored in M1.
|
|
||||||
// M2 adds AS/DS RPC dispatch here.
|
|
||||||
_ => {
|
|
||||||
tracing::warn!("unexpected message type in M1 — ignoring");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Format the first 4 bytes of a key as hex with a trailing ellipsis.
|
/// Format the first `n` bytes of a slice as lowercase hex with a trailing `…`.
|
||||||
fn fmt_key(key: &[u8]) -> String {
|
fn fmt_hex(bytes: &[u8]) -> String {
|
||||||
if key.len() < 4 {
|
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
return format!("{key:02x?}");
|
format!("{hex}…")
|
||||||
}
|
|
||||||
format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the current Unix timestamp in milliseconds.
|
|
||||||
///
|
|
||||||
/// Falls back to 0 if the system clock predates the Unix epoch (pathological).
|
|
||||||
fn current_timestamp_ms() -> u64 {
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis() as u64
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
schemas/auth.capnp
Normal file
31
schemas/auth.capnp
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# auth.capnp — Authentication Service RPC interface.
|
||||||
|
#
|
||||||
|
# Clients call uploadKeyPackage before joining any group so that peers can
|
||||||
|
# fetch their key material to add them. Each KeyPackage is single-use (MLS
|
||||||
|
# requirement): fetchKeyPackage removes and returns one package atomically.
|
||||||
|
#
|
||||||
|
# The server indexes packages by the raw Ed25519 public key bytes (32 bytes),
|
||||||
|
# not a fingerprint, so callers must know the target's identity public key
|
||||||
|
# out-of-band (e.g. from a directory or QR code scan).
|
||||||
|
#
|
||||||
|
# ID generated with: capnp id
|
||||||
|
@0xb3a8f1c2e4d97650;
|
||||||
|
|
||||||
|
interface AuthenticationService {
|
||||||
|
# Upload a single-use KeyPackage for later retrieval by peers.
|
||||||
|
#
|
||||||
|
# identityKey : Ed25519 public key bytes (exactly 32 bytes).
|
||||||
|
# package : openmls-serialised KeyPackage blob (TLS encoding).
|
||||||
|
#
|
||||||
|
# Returns the SHA-256 fingerprint of `package`. Clients should record this
|
||||||
|
# and compare it against the fingerprint returned by a peer's fetchKeyPackage
|
||||||
|
# to detect tampering.
|
||||||
|
uploadKeyPackage @0 (identityKey :Data, package :Data) -> (fingerprint :Data);
|
||||||
|
|
||||||
|
# Fetch and atomically remove one KeyPackage for a given identity key.
|
||||||
|
#
|
||||||
|
# Returns empty Data if no KeyPackage is currently stored for this identity.
|
||||||
|
# Callers should handle the empty case by asking the target to upload more
|
||||||
|
# packages before retrying.
|
||||||
|
fetchKeyPackage @1 (identityKey :Data) -> (package :Data);
|
||||||
|
}
|
||||||
35
schemas/delivery.capnp
Normal file
35
schemas/delivery.capnp
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# delivery.capnp — Delivery Service RPC interface.
|
||||||
|
#
|
||||||
|
# The Delivery Service is a simple store-and-forward relay. It does not parse
|
||||||
|
# MLS messages — all payloads are opaque byte strings routed by recipient key.
|
||||||
|
#
|
||||||
|
# Callers are responsible for:
|
||||||
|
# - Routing Welcome messages to the correct new member after add_members().
|
||||||
|
# - Routing Commit messages to any existing group members (other than self).
|
||||||
|
# - Routing Application messages to the intended recipient(s).
|
||||||
|
#
|
||||||
|
# The DS indexes queues by the recipient's raw Ed25519 public key (32 bytes),
|
||||||
|
# matching the indexing scheme used by the Authentication Service.
|
||||||
|
#
|
||||||
|
# ID generated with: capnp id
|
||||||
|
@0xc5d9e2b4f1a83076;
|
||||||
|
|
||||||
|
interface DeliveryService {
|
||||||
|
# Enqueue an opaque payload for delivery to a recipient.
|
||||||
|
#
|
||||||
|
# recipientKey : Ed25519 public key of the intended recipient (exactly 32 bytes).
|
||||||
|
# payload : Opaque byte string — a TLS-encoded MlsMessageOut blob or any
|
||||||
|
# other framed data the application layer wants to deliver.
|
||||||
|
#
|
||||||
|
# The payload is appended to the recipient's FIFO queue. Returns immediately;
|
||||||
|
# the recipient retrieves it via `fetch`.
|
||||||
|
enqueue @0 (recipientKey :Data, payload :Data) -> ();
|
||||||
|
|
||||||
|
# Fetch and atomically drain all queued payloads for a given recipient.
|
||||||
|
#
|
||||||
|
# recipientKey : Ed25519 public key of the caller (exactly 32 bytes).
|
||||||
|
#
|
||||||
|
# Returns the complete queue in FIFO order and clears it. Returns an empty
|
||||||
|
# list if there are no pending messages.
|
||||||
|
fetch @1 (recipientKey :Data) -> (payloads :List(Data));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user