diff --git a/Cargo.lock b/Cargo.lock index 5749458..dcaaab5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,7 +799,11 @@ dependencies = [ "alloy", "blake3", "bytes", + "chacha20poly1305", "evmlib", + "fips203", + "fips204", + "getrandom 0.2.17", "hex", "postcard", "rand 0.8.6", @@ -808,10 +812,12 @@ dependencies = [ "saorsa-pqc 0.5.1", "serde", "serial_test", + "tiny-keccak", "tokio", "tracing", "url", "xor_name", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 433db65..f4e4485 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,24 +21,37 @@ path = "src/lib.rs" # these through `ant_protocol::{transport, pqc, evm}` re-exports so the # version is pinned in exactly one place. Changing these versions is a # breaking change for both downstream crates. -saorsa-core = "0.26.4" -saorsa-pqc = "0.5" -evmlib = "0.9.0" +saorsa-core = { version = "0.26.4", optional = true } +saorsa-pqc = { version = "0.5", optional = true } +evmlib = { version = "0.9.0", optional = true } # Content addressing (BLAKE3) blake3 = "1" +# Browser WebRTC application-session protection. The complete handshake and +# record layer live here so native nodes and WASM clients share one wire +# implementation instead of duplicating cryptography in JavaScript. +chacha20poly1305 = { version = "0.10.1", default-features = false, features = ["alloc"], optional = true } +fips203 = { version = "0.4.3", default-features = false, features = ["default-rng", "ml-kem-768"], optional = true } +zeroize = { version = "1.8", default-features = false, optional = true } + # Async runtime (used by send_and_await_chunk_response) -tokio = { version = "1.35", features = ["sync", "time", "rt"] } +tokio = { version = "1.35", features = ["sync", "time", "rt"], optional = true } # Serialization serde = { version = "1", features = ["derive"] } postcard = { version = "1.1.3", features = ["use-std"] } -rmp-serde = "1" +rmp-serde = { version = "1", optional = true } # Byte utilities bytes = { version = "1", features = ["serde"] } hex = "0.4" +tiny-keccak = { version = "2", features = ["keccak"] } + +# Verification-only ML-DSA backend for targets where the native Saorsa stack +# is unavailable (notably wasm32 browsers). The wrapper lives in this crate so +# downstream protocol consumers never select crypto implementations themselves. +fips204 = { version = "0.4.6", default-features = false, features = ["ml-dsa-65"], optional = true } # Logging (optional — behind `logging` feature flag, mirroring ant-node) tracing = { version = "0.1", optional = true } @@ -52,11 +65,21 @@ serial_test = "3" tokio = { version = "1.35", features = ["full"] } [features] -# Default: logging enabled so downstream tests and dev builds emit diagnostics. -# Release consumers can strip logging with `--no-default-features`. -default = ["logging"] +# Preserve the existing native API by default. Portable consumers can compile +# the wire types and verification logic without Tokio, EVM, or Saorsa transport +# using `--no-default-features --features portable`. +default = ["native", "logging"] +# Native networking, EVM payment, and signing APIs. +native = ["dep:chacha20poly1305", "dep:evmlib", "dep:fips203", "dep:rmp-serde", "dep:saorsa-core", "dep:saorsa-pqc", "dep:tokio", "dep:zeroize"] +# Cross-platform verification backend used by browser/WASM clients. +portable = ["dep:chacha20poly1305", "dep:fips203", "dep:fips204", "dep:zeroize"] # Enable `tracing` macros inside the crate. -logging = ["tracing"] +logging = ["dep:tracing"] + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# fips203's default RNG reaches getrandom 0.2. Enable its Web Crypto backend +# when ant-protocol is built directly for a browser target. +getrandom = { version = "0.2", features = ["js"] } [lints.rust] unsafe_code = "deny" diff --git a/README.md b/README.md index 5ffc998..3059807 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ ML-DSA-65 signing scheme. | Module | Contents | |---|---| | `chunk` | Chunk protocol messages (`ChunkMessage`, PUT/GET/Quote/MerkleCandidateQuote request+response), protocol constants (`CHUNK_PROTOCOL_ID`, `MAX_CHUNK_SIZE`, `MAX_WIRE_MESSAGE_SIZE`, `CLOSE_GROUP_SIZE`, `CLOSE_GROUP_MAJORITY`), `ProtocolError`, proof type tags | +| `crypto` | Cross-platform ML-DSA-65 verification with native and portable backends | | `data_types` | Address helpers — `compute_address` (BLAKE3), `xor_distance`, `peer_id_to_xor_name` — and `DataChunk` | | `chunk_protocol` | `send_and_await_chunk_response`: the subscribe/send/poll helper used to exchange chunk messages on a `P2PNode` | | `payment` | On-wire payment artifacts: `PaymentProof`, `SingleNodePayment` (with `pay` and `verify`), and ML-DSA-65 signature verification for quotes and merkle candidates | @@ -39,7 +40,20 @@ ant-protocol = "2" | Feature | Default | Description | |---|---|---| -| `logging` | yes | Re-exports the `tracing` macros. Disable with `--no-default-features` for minimum-overhead builds; the macros then expand to no-ops. | +| `native` | yes | Enables EVM payments, Saorsa transport/PQC re-exports, and the Tokio request helper. | +| `logging` | yes | Re-exports the `tracing` macros; without it the macros expand to no-ops. | +| `portable` | no | Enables verification-only FIPS-204 crypto without Tokio, EVM, Saorsa transport, or `saorsa-pqc`; intended for browser/WASM and other portable clients. | + +For a browser build: + +```toml +[dependencies] +ant-protocol = { version = "2", default-features = false, features = ["portable"] } +``` + +The portable surface includes chunk wire types, address helpers, storage +commitments, canonical commitment/quote signing bytes, quote hashes, the `u128` +pricing curve, and ML-DSA-65 verification. ## Compatibility diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..4a9ddd7 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,62 @@ +//! Cross-platform post-quantum verification used by protocol consumers. +//! +//! Native builds retain the Saorsa PQC facade. Portable builds use the same +//! underlying FIPS-204 primitive directly, behind this stable protocol API. +//! Downstream crates therefore share one verifier and do not need target- +//! specific cryptography branches. + +#[cfg(all(not(feature = "native"), feature = "portable"))] +use fips204::{ + ml_dsa_65, + traits::{SerDes as _, Verifier as _}, +}; +#[cfg(feature = "native")] +use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant}; + +/// Verify an ML-DSA-65 signature and return `false` for malformed input. +/// +/// `context` is the FIPS-204 context string. Pass an empty slice for protocol +/// signatures that do not use domain separation. +#[must_use] +pub fn verify_ml_dsa_65( + public_key: &[u8], + signature: &[u8], + message: &[u8], + context: &[u8], +) -> bool { + verify_ml_dsa_65_inner(public_key, signature, message, context).unwrap_or(false) +} + +#[cfg(feature = "native")] +fn verify_ml_dsa_65_inner( + public_key: &[u8], + signature: &[u8], + message: &[u8], + context: &[u8], +) -> Result { + let public_key = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, public_key) + .map_err(|error| error.to_string())?; + let signature = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, signature) + .map_err(|error| error.to_string())?; + ml_dsa_65() + .verify_with_context(&public_key, message, &signature, context) + .map_err(|error| error.to_string()) +} + +#[cfg(all(not(feature = "native"), feature = "portable"))] +fn verify_ml_dsa_65_inner( + public_key: &[u8], + signature: &[u8], + message: &[u8], + context: &[u8], +) -> Result { + let public_key: [u8; ml_dsa_65::PK_LEN] = public_key + .try_into() + .map_err(|_| "invalid ML-DSA-65 public key length".to_string())?; + let signature: [u8; ml_dsa_65::SIG_LEN] = signature + .try_into() + .map_err(|_| "invalid ML-DSA-65 signature length".to_string())?; + let public_key = + ml_dsa_65::PublicKey::try_from_bytes(public_key).map_err(ToString::to_string)?; + Ok(public_key.verify(message, &signature, context)) +} diff --git a/src/lib.rs b/src/lib.rs index cd47dbc..9d363c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,9 @@ //! //! This crate is the contract between `ant-client` and `ant-node`: //! wire message types, serialization, content addressing, and the -//! pure-verification halves of the post-quantum signing scheme. Both -//! crates depend on `ant-protocol` and on nothing else from each other. +//! pure-verification halves of the post-quantum signing scheme. The portable +//! surface compiles for browsers without pulling in Tokio, EVM, or a native +//! transport runtime. //! //! ## Scope //! @@ -42,12 +43,17 @@ #![cfg_attr(not(feature = "logging"), allow(unused_variables, unused_assignments))] pub mod chunk; +#[cfg(feature = "native")] pub mod chunk_protocol; +#[cfg(any(feature = "native", feature = "portable"))] +pub mod crypto; pub mod data_types; +#[cfg(feature = "native")] pub mod devnet_manifest; pub mod error; pub mod logging; pub mod payment; +pub mod web_rtc; // ============================================================================= // Public surface re-exports @@ -60,10 +66,13 @@ pub use chunk::{ CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE, PROTOCOL_VERSION, XORNAME_LEN, }; +#[cfg(feature = "native")] pub use chunk_protocol::send_and_await_chunk_response; pub use data_types::{compute_address, peer_id_to_xor_name, xor_distance, ChunkStats, DataChunk}; +#[cfg(feature = "native")] pub use devnet_manifest::{DevnetEvmInfo, DevnetManifest}; pub use error::{Error, Result}; +#[cfg(feature = "native")] pub use payment::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, serialize_single_node_proof, verify_merkle_candidate_signature, verify_quote_content, @@ -90,6 +99,7 @@ pub use payment::{ /// Use `ant_protocol::evm::…` in downstream crates instead of a direct /// `evmlib` dependency. This guarantees client and node always link the /// same `evmlib` major version. +#[cfg(feature = "native")] pub mod evm { pub use evmlib::common::{Address, Amount, QuoteHash, TxHash, U256}; pub use evmlib::merkle_batch_payment::PoolCommitment; @@ -130,6 +140,7 @@ pub mod evm { /// /// Use `ant_protocol::transport::…` in downstream crates instead of a /// direct `saorsa-core` dependency. +#[cfg(feature = "native")] pub mod transport { pub use saorsa_core::identity::{NodeIdentity, PeerId}; pub use saorsa_core::{ @@ -145,6 +156,7 @@ pub mod transport { /// by the node and by this crate's own verification code. /// - `ant_protocol::pqc::api::*` (higher-level `api::sig::*` module) — /// used by the client's binary-update signature verification. +#[cfg(feature = "native")] pub mod pqc { /// Lower-level `pqc::*` API (types + `MlDsaOperations` trait). pub mod ops { diff --git a/src/payment/commitment.rs b/src/payment/commitment.rs index 959e5fc..067225f 100644 --- a/src/payment/commitment.rs +++ b/src/payment/commitment.rs @@ -17,9 +17,11 @@ //! the client never builds or signs a commitment, it only verifies one. use blake3::Hasher; -use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant}; use serde::{Deserialize, Serialize}; +#[cfg(any(feature = "native", feature = "portable"))] +use crate::crypto::verify_ml_dsa_65; + /// Domain-separation tag for the commitment signature. /// /// Signed payload is verified under this context tag. @@ -100,7 +102,8 @@ pub fn commitment_hash(c: &StorageCommitment) -> Option<[u8; 32]> { /// /// `sender_public_key` is length-prefixed and included so an adversary cannot /// keep the body and re-sign under a different key. -fn commitment_signed_payload( +#[must_use] +pub fn storage_commitment_bytes_for_signing( root: &[u8; 32], key_count: u32, sender_peer_id: &[u8; 32], @@ -121,39 +124,37 @@ fn commitment_signed_payload( /// callers that need it (the client, the node) check it separately so the same /// function serves both the "trust the embedded key" and "bind to a peer" uses. #[must_use] +#[cfg(any(feature = "native", feature = "portable"))] pub fn verify_commitment_signature(c: &StorageCommitment) -> bool { - let Ok(public_key) = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, &c.sender_public_key) - else { - return false; - }; - let payload = commitment_signed_payload( + let payload = storage_commitment_bytes_for_signing( &c.root, c.key_count, &c.sender_peer_id, &c.sender_public_key, ); - let Ok(sig) = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &c.signature) else { - return false; - }; - ml_dsa_65() - .verify_with_context(&public_key, &payload, &sig, DOMAIN_COMMITMENT) - .unwrap_or(false) + verify_ml_dsa_65( + &c.sender_public_key, + &c.signature, + &payload, + DOMAIN_COMMITMENT, + ) } -#[cfg(test)] +#[cfg(all(test, feature = "native"))] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; use saorsa_pqc::api::sig::ml_dsa_65; /// Build a genuinely-signed commitment (fresh ML-DSA-65 keypair, signed over - /// the exact `commitment_signed_payload` under `DOMAIN_COMMITMENT`) — the same + /// the exact `storage_commitment_bytes_for_signing` under + /// `DOMAIN_COMMITMENT`) — the same /// thing the node produces. Returns the commitment and the keypair's public /// bytes so tamper tests can key-swap. fn signed_commitment(root: [u8; 32], key_count: u32, peer_id: [u8; 32]) -> StorageCommitment { let (pk, sk) = ml_dsa_65().generate_keypair().unwrap(); let pk_bytes = pk.to_bytes(); - let payload = commitment_signed_payload(&root, key_count, &peer_id, &pk_bytes); + let payload = storage_commitment_bytes_for_signing(&root, key_count, &peer_id, &pk_bytes); let sig = ml_dsa_65() .sign_with_context(&sk, &payload, DOMAIN_COMMITMENT) .unwrap(); diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 68f10e0..29b6c72 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -13,19 +13,33 @@ pub mod commitment; /// Quadratic storage-pricing formula shared by node and client (ADR-0004). pub mod pricing; /// Payment proof serialization and type tagging. +#[cfg(feature = "native")] pub mod proof; +/// Portable quote signing bytes and EVM hash construction. +pub mod quote; /// `SingleNodePayment` construction, on-chain payment, and verification. +#[cfg(feature = "native")] pub mod single_node; /// Pure ML-DSA-65 verification helpers for quotes and merkle candidates. +#[cfg(feature = "native")] pub mod verify; +#[cfg(any(feature = "native", feature = "portable"))] +pub use commitment::verify_commitment_signature; pub use commitment::{ - commitment_hash, verify_commitment_signature, StorageCommitment, MAX_COMMITMENT_KEY_COUNT, + commitment_hash, storage_commitment_bytes_for_signing, StorageCommitment, DOMAIN_COMMITMENT, + DOMAIN_COMMITMENT_HASH, MAX_COMMITMENT_KEY_COUNT, MAX_COMMITMENT_SIDECAR_BYTES, }; +pub use pricing::calculate_price_wei; +#[cfg(feature = "native")] pub use pricing::{calculate_price, derive_records_stored_from_price}; +#[cfg(feature = "native")] pub use proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, serialize_single_node_proof, PaymentProof, ProofType, }; +pub use quote::{payment_quote_bytes_for_signing, payment_quote_hash}; +#[cfg(feature = "native")] pub use single_node::{QuotePaymentInfo, SingleNodePayment}; +#[cfg(feature = "native")] pub use verify::{verify_merkle_candidate_signature, verify_quote_content, verify_quote_signature}; diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index dd731b5..75f6c16 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -22,6 +22,7 @@ //! | K | 0.03515625 ANT| Quadratic coefficient | //! | D | 6000 | Lower stable boundary (records stored) | +#[cfg(feature = "native")] use evmlib::common::Amount; /// Lower stable boundary of the quadratic curve, in records stored. @@ -41,6 +42,7 @@ const PRICE_BASELINE_WEI: u128 = 3_906_250_000_000_000; const PRICE_COEFFICIENT_WEI: u128 = 35_156_250_000_000_000; /// Price increment per squared record after simplifying `PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED`. +#[cfg(feature = "native")] const PRICE_PER_RECORD_SQUARED_WEI: u128 = PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED; /// Derive the quoted record count from a quote price. @@ -54,6 +56,7 @@ const PRICE_PER_RECORD_SQUARED_WEI: u128 = PRICE_COEFFICIENT_WEI / DIVISOR_SQUAR /// pre-auth crash vector. Saturating leaves the delta check to reject the /// quote as out-of-range without aborting the process. #[must_use] +#[cfg(feature = "native")] pub fn derive_records_stored_from_price(price: Amount) -> u64 { let baseline = Amount::from(PRICE_BASELINE_WEI); if price <= baseline { @@ -80,6 +83,7 @@ pub fn derive_records_stored_from_price(price: Amount) -> u64 { /// where `BASELINE = 0.00390625 ANT`, `K = 0.03515625 ANT`, and `D = 6000`. /// U256 arithmetic prevents overflow for large record counts. #[must_use] +#[cfg(feature = "native")] pub fn calculate_price(close_records_stored: usize) -> Amount { let n = Amount::from(close_records_stored); let n_squared = n.saturating_mul(n); @@ -88,7 +92,19 @@ pub fn calculate_price(close_records_stored: usize) -> Amount { Amount::from(PRICE_BASELINE_WEI).saturating_add(quadratic_wei) } -#[cfg(test)] +/// Calculate storage price in wei using only portable integer primitives. +/// +/// The protocol limits committed key counts to `u32`, for which every +/// intermediate in the pricing formula fits in `u128`. Browser clients use +/// this function to validate the exact same curve as native nodes without +/// depending on EVM integer types. +#[must_use] +pub fn calculate_price_wei(close_records_stored: u32) -> u128 { + let n = u128::from(close_records_stored); + PRICE_BASELINE_WEI + n.saturating_mul(n).saturating_mul(PRICE_COEFFICIENT_WEI) / DIVISOR_SQUARED +} + +#[cfg(all(test, feature = "native"))] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; @@ -189,6 +205,16 @@ mod tests { assert_eq!(price1, price2); } + #[test] + fn portable_u128_curve_matches_native_amount_curve() { + for records in [0_u32, 1, 23, 6_000, 12_000, 1_000_000, u32::MAX] { + assert_eq!( + Amount::from(calculate_price_wei(records)), + calculate_price(records as usize) + ); + } + } + #[test] fn test_quadratic_growth_excluding_baseline() { let base = Amount::from(PRICE_BASELINE_WEI); diff --git a/src/payment/quote.rs b/src/payment/quote.rs new file mode 100644 index 0000000..674392d --- /dev/null +++ b/src/payment/quote.rs @@ -0,0 +1,101 @@ +//! Portable construction of payment-quote signing bytes and EVM hashes. + +use tiny_keccak::{Hasher as _, Keccak}; + +/// Construct the canonical bytes covered by a storage quote signature. +/// +/// This is byte-for-byte equivalent to `evmlib::PaymentQuote::bytes_for_signing` +/// while accepting only portable fixed-width primitives. +#[must_use] +pub fn payment_quote_bytes_for_signing( + content: &[u8; 32], + timestamp_secs: u64, + price_wei: u128, + rewards_address: &[u8; 20], + committed_key_count: u32, + commitment_pin: Option<&[u8; 32]>, +) -> Vec { + let mut bytes = Vec::with_capacity(32 + 8 + 32 + 20 + 4 + 33); + bytes.extend_from_slice(content); + bytes.extend_from_slice(×tamp_secs.to_le_bytes()); + bytes.extend_from_slice(&price_wei.to_le_bytes()); + // EVM Amount is U256; browser quote prices are currently bounded to u128. + bytes.extend_from_slice(&[0u8; 16]); + bytes.extend_from_slice(rewards_address); + bytes.extend_from_slice(&committed_key_count.to_le_bytes()); + if let Some(pin) = commitment_pin { + bytes.push(1); + bytes.extend_from_slice(pin); + } else { + bytes.push(0); + } + bytes +} + +/// Compute the Keccak-256 hash used as the EVM payment quote identifier. +#[must_use] +pub fn payment_quote_hash(signed_bytes: &[u8], public_key: &[u8], signature: &[u8]) -> [u8; 32] { + let mut hasher = Keccak::v256(); + hasher.update(signed_bytes); + hasher.update(public_key); + hasher.update(signature); + let mut output = [0u8; 32]; + hasher.finalize(&mut output); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payment_hash_matches_evmlib_vector() { + assert_eq!( + hex::encode(payment_quote_hash(&[0, 1], &[2], &[3])), + "d98f2e8134922f73748703c8e7084d42f13d2fa1439936ef5a3abcf5646fe83f" + ); + } + + #[cfg(feature = "native")] + #[test] + fn portable_quote_encoding_matches_evmlib() { + use evmlib::common::Amount; + use evmlib::{PaymentQuote, RewardsAddress}; + use std::time::{Duration, SystemTime}; + + let content = [0x31; 32]; + let timestamp_secs = 1_775_000_001; + let timestamp = SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp_secs); + let price_wei = 7_654_321_u128; + let price = Amount::from(price_wei); + let rewards = [0x42; 20]; + let rewards_address = RewardsAddress::from(rewards); + let commitment_pin = Some([0x53; 32]); + let public_key = vec![0x64; 17]; + let signature = vec![0x75; 23]; + let native = PaymentQuote { + content: xor_name::XorName(content), + timestamp, + price, + rewards_address, + pub_key: public_key.clone(), + signature: signature.clone(), + committed_key_count: 23, + commitment_pin, + }; + let portable = payment_quote_bytes_for_signing( + &content, + timestamp_secs, + price_wei, + &rewards, + 23, + commitment_pin.as_ref(), + ); + + assert_eq!(portable, native.bytes_for_sig()); + assert_eq!( + payment_quote_hash(&portable, &public_key, &signature).as_slice(), + native.hash().as_slice() + ); + } +} diff --git a/src/web_rtc.rs b/src/web_rtc.rs new file mode 100644 index 0000000..a18f474 --- /dev/null +++ b/src/web_rtc.rs @@ -0,0 +1,68 @@ +//! Shared WebRTC Direct transfer timing policy. +//! +//! WebRTC requests are split across ordered SCTP messages. A fixed deadline is +//! appropriate for headers and small control requests, but not for a full +//! [`crate::MAX_CHUNK_SIZE`] body competing with other replica uploads. Both +//! sides use this module so the sender never waits longer than the receiver is +//! willing to accept the same frame. + +use std::time::Duration; + +#[cfg(any(feature = "native", feature = "portable"))] +mod session; +#[cfg(any(feature = "native", feature = "portable"))] +pub use session::{ + accept_pq_session, decode_pq_frame, encode_pq_frame, pq_frame_length, PqClientHandshake, + PqSession, PqSessionError, PQ_CLIENT_HELLO_BYTES, PQ_ENCRYPTED_OVERHEAD_BYTES, + PQ_FRAME_PREFIX_BYTES, PQ_SERVER_ACCEPT_BYTES, +}; + +/// Time allowed for a header-only WebRTC Direct request. +pub const WEBRTC_TRANSFER_BASE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Slowest sustained per-DataChannel frame rate accommodated by the protocol. +/// +/// Uploads deliberately fan out to several peers. A conservative per-channel +/// floor keeps those parallel streams viable on ordinary residential uplinks. +pub const WEBRTC_MIN_TRANSFER_RATE_BYTES_PER_SEC: u64 = 32 * 1024; + +/// Upper bound for one complete WebRTC Direct request or response transfer. +pub const WEBRTC_TRANSFER_MAX_TIMEOUT: Duration = Duration::from_secs(180); + +/// Return the transfer deadline for a frame containing `frame_bytes` bytes. +/// +/// The fixed base covers connection scheduling and latency. Transfer time is +/// added at [`WEBRTC_MIN_TRANSFER_RATE_BYTES_PER_SEC`] and clamped so a +/// permanently stalled channel is still discarded. +#[must_use] +pub fn transfer_timeout(frame_bytes: usize) -> Duration { + let frame_bytes = u64::try_from(frame_bytes).unwrap_or(u64::MAX); + let transfer_seconds = frame_bytes.div_ceil(WEBRTC_MIN_TRANSFER_RATE_BYTES_PER_SEC); + WEBRTC_TRANSFER_BASE_TIMEOUT + .saturating_add(Duration::from_secs(transfer_seconds)) + .min(WEBRTC_TRANSFER_MAX_TIMEOUT) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transfer_timeout_scales_with_body_size() { + assert_eq!(transfer_timeout(0), Duration::from_secs(10)); + assert_eq!(transfer_timeout(32 * 1024), Duration::from_secs(11)); + assert_eq!( + transfer_timeout(crate::MAX_CHUNK_SIZE), + Duration::from_secs(138) + ); + assert_eq!( + transfer_timeout(crate::MAX_CHUNK_SIZE + 1), + Duration::from_secs(139) + ); + } + + #[test] + fn transfer_timeout_is_capped() { + assert_eq!(transfer_timeout(usize::MAX), WEBRTC_TRANSFER_MAX_TIMEOUT); + } +} diff --git a/src/web_rtc/session.rs b/src/web_rtc/session.rs new file mode 100644 index 0000000..f74701a --- /dev/null +++ b/src/web_rtc/session.rs @@ -0,0 +1,600 @@ +//! Shared post-quantum application session for browser WebRTC connections. +//! +//! WebRTC authenticates the certificate pinned in the direct multiaddress and +//! encrypts transport traffic with DTLS. This layer additionally authenticates +//! the ANT node identity with ML-DSA-65, establishes fresh ML-KEM-768 key +//! material, and protects every subsequent application frame with +//! ChaCha20-Poly1305. + +use crate::crypto::verify_ml_dsa_65; +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + ChaCha20Poly1305, Key, Nonce, +}; +use fips203::{ + ml_kem_768, + traits::{Decaps as _, Encaps as _, KeyGen as _, SerDes as _}, +}; +use std::fmt; +use zeroize::Zeroize; + +const PQ_SESSION_VERSION: u16 = 1; +const CLIENT_HELLO_TAG: u8 = 1; +const SERVER_ACCEPT_TAG: u8 = 2; +const ENCRYPTED_RECORD_TAG: u8 = 3; +const HANDSHAKE_DOMAIN: &[u8] = b"autonomi-webrtc-pq-handshake-v1\0"; +const CLIENT_TO_SERVER_KDF: &str = "autonomi webrtc pq session v1 client to server"; +const SERVER_TO_CLIENT_KDF: &str = "autonomi webrtc pq session v1 server to client"; +const RECORD_AAD_DOMAIN: &[u8] = b"autonomi-webrtc-pq-record-v1\0"; + +/// ML-DSA-65 public-key length from FIPS 204. +const ML_DSA_65_PUBLIC_KEY_BYTES: usize = 1_952; +/// ML-DSA-65 signature length from FIPS 204. +const ML_DSA_65_SIGNATURE_BYTES: usize = 3_309; +const PEER_ID_BYTES: usize = 32; +const TAG_AND_VERSION_BYTES: usize = 3; +const RECORD_HEADER_BYTES: usize = 1 + 8; +const AEAD_TAG_BYTES: usize = 16; + +/// Bytes in a serialized ML-KEM-768 client hello. +pub const PQ_CLIENT_HELLO_BYTES: usize = TAG_AND_VERSION_BYTES + ml_kem_768::EK_LEN; +/// Bytes in a serialized node accept message. +pub const PQ_SERVER_ACCEPT_BYTES: usize = TAG_AND_VERSION_BYTES + + ml_kem_768::CT_LEN + + PEER_ID_BYTES + + ML_DSA_65_PUBLIC_KEY_BYTES + + ML_DSA_65_SIGNATURE_BYTES; +/// Bytes added by the encrypted-record envelope. +pub const PQ_ENCRYPTED_OVERHEAD_BYTES: usize = RECORD_HEADER_BYTES + AEAD_TAG_BYTES; +/// Bytes in the outer length prefix used to delimit `DataChannel` streams. +pub const PQ_FRAME_PREFIX_BYTES: usize = 4; + +/// Error returned by the WebRTC post-quantum handshake or record layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PqSessionError { + /// A frame or handshake message has an invalid shape or value. + InvalidFrame(String), + /// ML-KEM key generation, parsing, encapsulation, or decapsulation failed. + KeyExchange(String), + /// The node identity did not authenticate the KEM transcript. + Authentication(String), + /// AEAD encryption or authentication failed. + Encryption(String), + /// The ordered `DataChannel` delivered an unexpected record sequence. + UnexpectedSequence { + /// Sequence number required by the receiver. + expected: u64, + /// Sequence number carried by the rejected record. + received: u64, + }, + /// A per-direction record sequence was exhausted. + SequenceExhausted, +} + +impl fmt::Display for PqSessionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFrame(message) => write!(formatter, "invalid PQ session frame: {message}"), + Self::KeyExchange(message) => write!(formatter, "ML-KEM session failed: {message}"), + Self::Authentication(message) => { + write!(formatter, "PQ session authentication failed: {message}") + } + Self::Encryption(message) => { + write!(formatter, "PQ session encryption failed: {message}") + } + Self::UnexpectedSequence { expected, received } => write!( + formatter, + "unexpected PQ record sequence {received}; expected {expected}" + ), + Self::SequenceExhausted => formatter.write_str("PQ record sequence exhausted"), + } + } +} + +impl std::error::Error for PqSessionError {} + +/// Client-side state retained between the ML-KEM hello and server accept. +pub struct PqClientHandshake { + client_hello: Vec, + decapsulation_key: ml_kem_768::DecapsKey, +} + +impl PqClientHandshake { + /// Generate an ephemeral ML-KEM-768 keypair and its wire hello. + /// + /// # Errors + /// + /// Returns an error if secure key generation fails. + pub fn start() -> Result<(Self, Vec), PqSessionError> { + let (encapsulation_key, decapsulation_key) = ml_kem_768::KG::try_keygen() + .map_err(|error| PqSessionError::KeyExchange(error.to_string()))?; + let mut client_hello = Vec::with_capacity(PQ_CLIENT_HELLO_BYTES); + client_hello.push(CLIENT_HELLO_TAG); + client_hello.extend_from_slice(&PQ_SESSION_VERSION.to_be_bytes()); + client_hello.extend_from_slice(&encapsulation_key.into_bytes()); + Ok(( + Self { + client_hello: client_hello.clone(), + decapsulation_key, + }, + client_hello, + )) + } + + /// Authenticate the server accept, decapsulate its ML-KEM ciphertext, and + /// construct the client half of the encrypted session. + /// + /// # Errors + /// + /// Returns an error for a malformed accept, an unauthenticated node, or a + /// failed ML-KEM decapsulation. + pub fn finish( + self, + server_accept: &[u8], + expected_peer_id: &[u8; PEER_ID_BYTES], + ) -> Result { + let parsed = parse_server_accept(server_accept)?; + if &parsed.peer_id != expected_peer_id { + return Err(PqSessionError::Authentication(format!( + "endpoint names peer {}, server authenticated as {}", + hex::encode(expected_peer_id), + hex::encode(parsed.peer_id) + ))); + } + if blake3::hash(parsed.public_key).as_bytes() != expected_peer_id { + return Err(PqSessionError::Authentication( + "ML-DSA public key does not match the endpoint peer ID".to_string(), + )); + } + let transcript = + handshake_transcript(&self.client_hello, parsed.ciphertext, &parsed.peer_id); + if !verify_ml_dsa_65(parsed.public_key, parsed.signature, &transcript, b"") { + return Err(PqSessionError::Authentication( + "node returned an invalid ML-DSA-65 signature".to_string(), + )); + } + let ciphertext = + ml_kem_768::CipherText::try_from_bytes(parsed.ciphertext.try_into().map_err(|_| { + PqSessionError::InvalidFrame("wrong ciphertext length".to_string()) + })?) + .map_err(|error| PqSessionError::KeyExchange(error.to_string()))?; + let shared_secret = self + .decapsulation_key + .try_decaps(&ciphertext) + .map_err(|error| PqSessionError::KeyExchange(error.to_string()))?; + Ok(PqSession::from_shared_secret( + shared_secret.into_bytes(), + &transcript, + SessionRole::Client, + )) + } +} + +/// Accept an ephemeral client hello and build the server half of the session. +/// +/// `sign` must sign the supplied transcript with the node's persistent +/// ML-DSA-65 identity key and return the serialized signature. +/// +/// # Errors +/// +/// Returns an error for malformed key material, an identity mismatch, a +/// failed ML-KEM encapsulation, or a signing failure. +pub fn accept_pq_session( + client_hello: &[u8], + peer_id: &[u8; PEER_ID_BYTES], + public_key: &[u8], + sign: F, +) -> Result<(Vec, PqSession), PqSessionError> +where + E: fmt::Display, + F: FnOnce(&[u8]) -> Result, E>, +{ + validate_client_hello(client_hello)?; + if public_key.len() != ML_DSA_65_PUBLIC_KEY_BYTES { + return Err(PqSessionError::Authentication(format!( + "expected a {ML_DSA_65_PUBLIC_KEY_BYTES}-byte ML-DSA-65 public key" + ))); + } + if blake3::hash(public_key).as_bytes() != peer_id { + return Err(PqSessionError::Authentication( + "node public key does not match its peer ID".to_string(), + )); + } + let encapsulation_key = ml_kem_768::EncapsKey::try_from_bytes( + client_hello[TAG_AND_VERSION_BYTES..] + .try_into() + .map_err(|_| { + PqSessionError::InvalidFrame("wrong encapsulation-key length".to_string()) + })?, + ) + .map_err(|error| PqSessionError::KeyExchange(error.to_string()))?; + let (shared_secret, ciphertext) = encapsulation_key + .try_encaps() + .map_err(|error| PqSessionError::KeyExchange(error.to_string()))?; + let ciphertext = ciphertext.into_bytes(); + let transcript = handshake_transcript(client_hello, &ciphertext, peer_id); + let signature = + sign(&transcript).map_err(|error| PqSessionError::Authentication(error.to_string()))?; + if signature.len() != ML_DSA_65_SIGNATURE_BYTES { + return Err(PqSessionError::Authentication(format!( + "signer returned {} bytes; expected {ML_DSA_65_SIGNATURE_BYTES}", + signature.len() + ))); + } + + let mut server_accept = Vec::with_capacity(PQ_SERVER_ACCEPT_BYTES); + server_accept.push(SERVER_ACCEPT_TAG); + server_accept.extend_from_slice(&PQ_SESSION_VERSION.to_be_bytes()); + server_accept.extend_from_slice(&ciphertext); + server_accept.extend_from_slice(peer_id); + server_accept.extend_from_slice(public_key); + server_accept.extend_from_slice(&signature); + let session = + PqSession::from_shared_secret(shared_secret.into_bytes(), &transcript, SessionRole::Server); + Ok((server_accept, session)) +} + +/// Ordered, authenticated application-record session. +/// +/// The type is deliberately role-specific at construction time. Its outbound +/// and inbound keys are independently derived, so equal sequence numbers in +/// opposite directions never reuse an AEAD nonce/key pair. +pub struct PqSession { + outbound_key: [u8; 32], + inbound_key: [u8; 32], + outbound_sequence: u64, + inbound_sequence: u64, +} + +impl PqSession { + fn from_shared_secret( + mut shared_secret: [u8; 32], + transcript: &[u8], + role: SessionRole, + ) -> Self { + let transcript_hash = blake3::hash(transcript); + let mut key_material = [0u8; 64]; + key_material[..32].copy_from_slice(&shared_secret); + key_material[32..].copy_from_slice(transcript_hash.as_bytes()); + let client_to_server = blake3::derive_key(CLIENT_TO_SERVER_KDF, &key_material); + let server_to_client = blake3::derive_key(SERVER_TO_CLIENT_KDF, &key_material); + shared_secret.zeroize(); + key_material.zeroize(); + let (outbound_key, inbound_key) = match role { + SessionRole::Client => (client_to_server, server_to_client), + SessionRole::Server => (server_to_client, client_to_server), + }; + Self { + outbound_key, + inbound_key, + outbound_sequence: 0, + inbound_sequence: 0, + } + } + + /// Encrypt and authenticate the next outbound application frame. + /// + /// # Errors + /// + /// Returns an error if the sequence is exhausted or encryption fails. + pub fn seal(&mut self, plaintext: &[u8]) -> Result, PqSessionError> { + let sequence = self.outbound_sequence; + let next = sequence + .checked_add(1) + .ok_or(PqSessionError::SequenceExhausted)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.outbound_key)); + let sequence_bytes = sequence.to_be_bytes(); + let nonce = record_nonce(sequence_bytes); + let aad = record_aad(sequence_bytes); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| PqSessionError::Encryption("could not seal record".to_string()))?; + let mut record = Vec::with_capacity(RECORD_HEADER_BYTES + ciphertext.len()); + record.push(ENCRYPTED_RECORD_TAG); + record.extend_from_slice(&sequence_bytes); + record.extend_from_slice(&ciphertext); + self.outbound_sequence = next; + Ok(record) + } + + /// Authenticate and decrypt the next inbound application frame. + /// + /// # Errors + /// + /// Returns an error for malformed, out-of-order, replayed, or + /// unauthenticated records, or if the sequence is exhausted. + pub fn open(&mut self, record: &[u8]) -> Result, PqSessionError> { + if record.len() < PQ_ENCRYPTED_OVERHEAD_BYTES { + return Err(PqSessionError::InvalidFrame( + "encrypted record is truncated".to_string(), + )); + } + if record[0] != ENCRYPTED_RECORD_TAG { + return Err(PqSessionError::InvalidFrame( + "expected an encrypted record".to_string(), + )); + } + let sequence_bytes: [u8; 8] = record[1..RECORD_HEADER_BYTES].try_into().map_err(|_| { + PqSessionError::InvalidFrame("record sequence is truncated".to_string()) + })?; + let sequence = u64::from_be_bytes(sequence_bytes); + if sequence != self.inbound_sequence { + return Err(PqSessionError::UnexpectedSequence { + expected: self.inbound_sequence, + received: sequence, + }); + } + let next = sequence + .checked_add(1) + .ok_or(PqSessionError::SequenceExhausted)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.inbound_key)); + let nonce = record_nonce(sequence_bytes); + let aad = record_aad(sequence_bytes); + let plaintext = cipher + .decrypt( + &nonce, + Payload { + msg: &record[RECORD_HEADER_BYTES..], + aad: &aad, + }, + ) + .map_err(|_| PqSessionError::Encryption("record authentication failed".to_string()))?; + self.inbound_sequence = next; + Ok(plaintext) + } +} + +impl Drop for PqSession { + fn drop(&mut self) { + self.outbound_key.zeroize(); + self.inbound_key.zeroize(); + self.outbound_sequence.zeroize(); + self.inbound_sequence.zeroize(); + } +} + +/// Add a big-endian payload length so chunked `DataChannel` messages can be +/// reassembled without exposing an inner JSON header. +/// +/// # Errors +/// +/// Returns an error if the payload length does not fit the wire prefix. +pub fn encode_pq_frame(payload: &[u8]) -> Result, PqSessionError> { + let length = u32::try_from(payload.len()) + .map_err(|_| PqSessionError::InvalidFrame("payload length does not fit u32".to_string()))?; + let mut frame = Vec::with_capacity(PQ_FRAME_PREFIX_BYTES + payload.len()); + frame.extend_from_slice(&length.to_be_bytes()); + frame.extend_from_slice(payload); + Ok(frame) +} + +/// Return the complete outer-frame length once its prefix is available. +/// +/// # Errors +/// +/// Returns an error if the declared payload violates the caller's limit or +/// overflows the platform frame size. +pub fn pq_frame_length( + frame: &[u8], + max_payload_bytes: usize, +) -> Result, PqSessionError> { + if frame.len() < PQ_FRAME_PREFIX_BYTES { + return Ok(None); + } + let payload_length = u32::from_be_bytes( + frame[..PQ_FRAME_PREFIX_BYTES] + .try_into() + .map_err(|_| PqSessionError::InvalidFrame("frame prefix is truncated".to_string()))?, + ) as usize; + if payload_length == 0 || payload_length > max_payload_bytes { + return Err(PqSessionError::InvalidFrame(format!( + "payload length {payload_length} is outside 1..={max_payload_bytes}" + ))); + } + PQ_FRAME_PREFIX_BYTES + .checked_add(payload_length) + .map(Some) + .ok_or_else(|| PqSessionError::InvalidFrame("frame length overflow".to_string())) +} + +/// Remove and validate a complete outer frame. +/// +/// # Errors +/// +/// Returns an error if the frame is incomplete, oversized, or has trailing +/// bytes. +pub fn decode_pq_frame(frame: &[u8], max_payload_bytes: usize) -> Result, PqSessionError> { + let expected = pq_frame_length(frame, max_payload_bytes)? + .ok_or_else(|| PqSessionError::InvalidFrame("frame prefix is truncated".to_string()))?; + if frame.len() != expected { + return Err(PqSessionError::InvalidFrame(format!( + "received {} bytes; expected {expected}", + frame.len() + ))); + } + Ok(frame[PQ_FRAME_PREFIX_BYTES..].to_vec()) +} + +#[derive(Clone, Copy)] +enum SessionRole { + Client, + Server, +} + +struct ParsedServerAccept<'a> { + ciphertext: &'a [u8], + peer_id: [u8; PEER_ID_BYTES], + public_key: &'a [u8], + signature: &'a [u8], +} + +fn validate_client_hello(client_hello: &[u8]) -> Result<(), PqSessionError> { + if client_hello.len() != PQ_CLIENT_HELLO_BYTES { + return Err(PqSessionError::InvalidFrame(format!( + "client hello is {} bytes; expected {PQ_CLIENT_HELLO_BYTES}", + client_hello.len() + ))); + } + validate_handshake_prefix(client_hello, CLIENT_HELLO_TAG, "client hello") +} + +fn parse_server_accept(server_accept: &[u8]) -> Result, PqSessionError> { + if server_accept.len() != PQ_SERVER_ACCEPT_BYTES { + return Err(PqSessionError::InvalidFrame(format!( + "server accept is {} bytes; expected {PQ_SERVER_ACCEPT_BYTES}", + server_accept.len() + ))); + } + validate_handshake_prefix(server_accept, SERVER_ACCEPT_TAG, "server accept")?; + let ciphertext_start = TAG_AND_VERSION_BYTES; + let peer_id_start = ciphertext_start + ml_kem_768::CT_LEN; + let public_key_start = peer_id_start + PEER_ID_BYTES; + let signature_start = public_key_start + ML_DSA_65_PUBLIC_KEY_BYTES; + Ok(ParsedServerAccept { + ciphertext: &server_accept[ciphertext_start..peer_id_start], + peer_id: server_accept[peer_id_start..public_key_start] + .try_into() + .map_err(|_| PqSessionError::InvalidFrame("peer ID is truncated".to_string()))?, + public_key: &server_accept[public_key_start..signature_start], + signature: &server_accept[signature_start..], + }) +} + +fn validate_handshake_prefix( + message: &[u8], + expected_tag: u8, + name: &str, +) -> Result<(), PqSessionError> { + if message.first().copied() != Some(expected_tag) { + return Err(PqSessionError::InvalidFrame(format!( + "{name} has the wrong message type" + ))); + } + let version = u16::from_be_bytes( + message[1..TAG_AND_VERSION_BYTES] + .try_into() + .map_err(|_| PqSessionError::InvalidFrame(format!("{name} version is truncated")))?, + ); + if version != PQ_SESSION_VERSION { + return Err(PqSessionError::InvalidFrame(format!( + "{name} uses PQ session version {version}; expected {PQ_SESSION_VERSION}" + ))); + } + Ok(()) +} + +fn handshake_transcript(client_hello: &[u8], ciphertext: &[u8], peer_id: &[u8; 32]) -> Vec { + let mut transcript = + Vec::with_capacity(HANDSHAKE_DOMAIN.len() + client_hello.len() + ciphertext.len() + 32); + transcript.extend_from_slice(HANDSHAKE_DOMAIN); + transcript.extend_from_slice(client_hello); + transcript.extend_from_slice(ciphertext); + transcript.extend_from_slice(peer_id); + transcript +} + +fn record_nonce(sequence: [u8; 8]) -> Nonce { + let mut bytes = [0u8; 12]; + bytes[4..].copy_from_slice(&sequence); + *Nonce::from_slice(&bytes) +} + +fn record_aad(sequence: [u8; 8]) -> Vec { + let mut aad = Vec::with_capacity(RECORD_AAD_DOMAIN.len() + sequence.len()); + aad.extend_from_slice(RECORD_AAD_DOMAIN); + aad.extend_from_slice(&sequence); + aad +} + +#[cfg(all(test, feature = "native"))] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use saorsa_pqc::api::sig::ml_dsa_65; + + fn session_pair() -> (PqSession, PqSession) { + let dsa = ml_dsa_65(); + let (public_key, secret_key) = dsa.generate_keypair().unwrap(); + let public_key = public_key.to_bytes(); + let peer_id = *blake3::hash(&public_key).as_bytes(); + let (client_handshake, client_hello) = PqClientHandshake::start().unwrap(); + let (server_accept, server_session) = + accept_pq_session(&client_hello, &peer_id, &public_key, |transcript| { + dsa.sign(&secret_key, transcript) + .map(|signature| signature.to_bytes()) + }) + .unwrap(); + let client_session = client_handshake.finish(&server_accept, &peer_id).unwrap(); + (client_session, server_session) + } + + #[test] + fn handshake_and_records_round_trip_in_both_directions() { + let (mut client, mut server) = session_pair(); + let request = client.seal(b"private request").unwrap(); + assert_eq!(server.open(&request).unwrap(), b"private request"); + let response = server.seal(b"private response").unwrap(); + assert_eq!(client.open(&response).unwrap(), b"private response"); + } + + #[test] + fn records_reject_tampering_and_replay() { + let (mut client, mut server) = session_pair(); + let record = client.seal(b"payload").unwrap(); + let mut tampered = record.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 1; + assert!(server.open(&tampered).is_err()); + assert_eq!(server.open(&record).unwrap(), b"payload"); + assert!(server.open(&record).is_err()); + } + + #[test] + fn server_identity_is_bound_to_expected_peer() { + let dsa = ml_dsa_65(); + let (public_key, secret_key) = dsa.generate_keypair().unwrap(); + let public_key = public_key.to_bytes(); + let peer_id = *blake3::hash(&public_key).as_bytes(); + let (client_handshake, client_hello) = PqClientHandshake::start().unwrap(); + let (server_accept, _) = + accept_pq_session(&client_hello, &peer_id, &public_key, |transcript| { + dsa.sign(&secret_key, transcript) + .map(|signature| signature.to_bytes()) + }) + .unwrap(); + assert!(client_handshake.finish(&server_accept, &[9u8; 32]).is_err()); + } + + #[test] + fn server_accept_rejects_a_tampered_signature() { + let dsa = ml_dsa_65(); + let (public_key, secret_key) = dsa.generate_keypair().unwrap(); + let public_key = public_key.to_bytes(); + let peer_id = *blake3::hash(&public_key).as_bytes(); + let (client_handshake, client_hello) = PqClientHandshake::start().unwrap(); + let (mut server_accept, _) = + accept_pq_session(&client_hello, &peer_id, &public_key, |transcript| { + dsa.sign(&secret_key, transcript) + .map(|signature| signature.to_bytes()) + }) + .unwrap(); + let last = server_accept.len() - 1; + server_accept[last] ^= 1; + assert!(client_handshake.finish(&server_accept, &peer_id).is_err()); + } + + #[test] + fn outer_frame_round_trip_and_limits() { + let frame = encode_pq_frame(b"hello").unwrap(); + assert_eq!(pq_frame_length(&frame[..3], 5).unwrap(), None); + assert_eq!(pq_frame_length(&frame, 5).unwrap(), Some(frame.len())); + assert_eq!(decode_pq_frame(&frame, 5).unwrap(), b"hello"); + assert!(decode_pq_frame(&frame, 4).is_err()); + } +}