lib.rs

42.5 KB · raw

1//! Canonical Peach Noise state machine.2//!3//! The browser and Android adapters are deliberately thin: all NK/KK4//! transcript processing, DH, AEAD, nonce handling, and transport rekeying5//! happen here through `snow`'s unmodified named patterns.67use snow::{8    Builder, Error, HandshakeState, TransportState,9    params::{CipherChoice, DHChoice, HashChoice, NoiseParams},10    resolvers::{CryptoResolver, DefaultResolver},11    types::{Cipher, Dh, Hash, Random},12};13use subtle::ConstantTimeEq;14use x25519_dalek::{PublicKey, StaticSecret};15use zeroize::{Zeroize, Zeroizing};1617const NK: &str = "Noise_NK_25519_ChaChaPoly_BLAKE2s";18const KK: &str = "Noise_KK_25519_ChaChaPoly_BLAKE2s";19pub const NOISE_PROLOGUE: &[u8] = b"peach-sync-v1";20const HASH_LEN: usize = 32;21const TAG_LEN: usize = 16;22const MAX_NOISE_MESSAGE: usize = 65_535;2324type CoreResult<T> = Result<T, String>;2526enum SessionState {27    Handshake(Option<Box<HandshakeState>>),28    Transport(TransportState),29    Closed,30}3132pub struct CoreNoiseSession {33    state: SessionState,34    handshake_hash: Option<[u8; HASH_LEN]>,35}3637impl CoreNoiseSession {38    pub fn nk_initiator(remote_public_key: &[u8]) -> CoreResult<Self> {39        Self::nk_initiator_inner(remote_public_key, None)40    }4142    #[cfg(feature = "test-constructors")]43    #[doc(hidden)]44    pub fn nk_initiator_with_ephemeral_for_test(45        remote_public_key: &[u8],46        ephemeral_private_key: &[u8],47    ) -> CoreResult<Self> {48        Self::nk_initiator_inner(remote_public_key, Some(ephemeral_private_key))49    }5051    fn nk_initiator_inner(52        remote_public_key: &[u8],53        ephemeral_private_key: Option<&[u8]>,54    ) -> CoreResult<Self> {55        require_key(remote_public_key, "responder public key")?;56        let params: NoiseParams = NK.parse().map_err(display_error)?;57        let mut builder = core_builder(params)58            .remote_public_key(remote_public_key)59            .map_err(display_error)?;60        if let Some(ephemeral) = ephemeral_private_key {61            require_key(ephemeral, "initiator ephemeral private key")?;62            builder = builder.fixed_ephemeral_key_for_testing_only(ephemeral);63        }64        let handshake = builder.build_initiator().map_err(display_error)?;65        Ok(Self::handshake(handshake))66    }6768    pub fn nk_responder(local_private_key: &[u8], local_public_key: &[u8]) -> CoreResult<Self> {69        Self::nk_responder_inner(local_private_key, local_public_key, None)70    }7172    #[cfg(feature = "test-constructors")]73    #[doc(hidden)]74    pub fn nk_responder_with_ephemeral_for_test(75        local_private_key: &[u8],76        local_public_key: &[u8],77        ephemeral_private_key: &[u8],78    ) -> CoreResult<Self> {79        Self::nk_responder_inner(80            local_private_key,81            local_public_key,82            Some(ephemeral_private_key),83        )84    }8586    fn nk_responder_inner(87        local_private_key: &[u8],88        local_public_key: &[u8],89        ephemeral_private_key: Option<&[u8]>,90    ) -> CoreResult<Self> {91        require_matching_keypair(local_private_key, local_public_key)?;92        let private = Zeroizing::new(local_private_key.to_vec());93        let params: NoiseParams = NK.parse().map_err(display_error)?;94        let mut builder = core_builder(params)95            .local_private_key(private.as_slice())96            .map_err(display_error)?;97        if let Some(ephemeral) = ephemeral_private_key {98            require_key(ephemeral, "responder ephemeral private key")?;99            builder = builder.fixed_ephemeral_key_for_testing_only(ephemeral);100        }101        let handshake = builder.build_responder().map_err(display_error)?;102        Ok(Self::handshake(handshake))103    }104105    pub fn kk_initiator(106        local_private_key: &[u8],107        local_public_key: &[u8],108        remote_public_key: &[u8],109    ) -> CoreResult<Self> {110        Self::kk_initiator_inner(local_private_key, local_public_key, remote_public_key, None)111    }112113    #[cfg(feature = "test-constructors")]114    #[doc(hidden)]115    pub fn kk_initiator_with_ephemeral_for_test(116        local_private_key: &[u8],117        local_public_key: &[u8],118        remote_public_key: &[u8],119        ephemeral_private_key: &[u8],120    ) -> CoreResult<Self> {121        Self::kk_initiator_inner(122            local_private_key,123            local_public_key,124            remote_public_key,125            Some(ephemeral_private_key),126        )127    }128129    fn kk_initiator_inner(130        local_private_key: &[u8],131        local_public_key: &[u8],132        remote_public_key: &[u8],133        ephemeral_private_key: Option<&[u8]>,134    ) -> CoreResult<Self> {135        require_matching_keypair(local_private_key, local_public_key)?;136        require_key(remote_public_key, "responder public key")?;137        let private = Zeroizing::new(local_private_key.to_vec());138        let params: NoiseParams = KK.parse().map_err(display_error)?;139        let mut builder = core_builder(params)140            .local_private_key(private.as_slice())141            .map_err(display_error)?142            .remote_public_key(remote_public_key)143            .map_err(display_error)?;144        if let Some(ephemeral) = ephemeral_private_key {145            require_key(ephemeral, "initiator ephemeral private key")?;146            builder = builder.fixed_ephemeral_key_for_testing_only(ephemeral);147        }148        let handshake = builder.build_initiator().map_err(display_error)?;149        Ok(Self::handshake(handshake))150    }151152    pub fn kk_responder(153        local_private_key: &[u8],154        local_public_key: &[u8],155        remote_public_key: &[u8],156    ) -> CoreResult<Self> {157        Self::kk_responder_inner(local_private_key, local_public_key, remote_public_key, None)158    }159160    #[cfg(feature = "test-constructors")]161    #[doc(hidden)]162    pub fn kk_responder_with_ephemeral_for_test(163        local_private_key: &[u8],164        local_public_key: &[u8],165        remote_public_key: &[u8],166        ephemeral_private_key: &[u8],167    ) -> CoreResult<Self> {168        Self::kk_responder_inner(169            local_private_key,170            local_public_key,171            remote_public_key,172            Some(ephemeral_private_key),173        )174    }175176    fn kk_responder_inner(177        local_private_key: &[u8],178        local_public_key: &[u8],179        remote_public_key: &[u8],180        ephemeral_private_key: Option<&[u8]>,181    ) -> CoreResult<Self> {182        require_matching_keypair(local_private_key, local_public_key)?;183        require_key(remote_public_key, "initiator public key")?;184        let private = Zeroizing::new(local_private_key.to_vec());185        let params: NoiseParams = KK.parse().map_err(display_error)?;186        let mut builder = core_builder(params)187            .local_private_key(private.as_slice())188            .map_err(display_error)?189            .remote_public_key(remote_public_key)190            .map_err(display_error)?;191        if let Some(ephemeral) = ephemeral_private_key {192            require_key(ephemeral, "responder ephemeral private key")?;193            builder = builder.fixed_ephemeral_key_for_testing_only(ephemeral);194        }195        let handshake = builder.build_responder().map_err(display_error)?;196        Ok(Self::handshake(handshake))197    }198199    fn handshake(handshake: HandshakeState) -> Self {200        Self {201            state: SessionState::Handshake(Some(Box::new(handshake))),202            handshake_hash: None,203        }204    }205206    pub fn write_handshake(&mut self, payload: &[u8]) -> CoreResult<Vec<u8>> {207        let handshake = self.handshake_mut()?;208        if !handshake.is_my_turn() {209            return Err("Noise handshake write attempted out of turn".into());210        }211        let mut output = vec![0_u8; MAX_NOISE_MESSAGE];212        let size = handshake213            .write_message(payload, &mut output)214            .map_err(display_error)?;215        output.truncate(size);216        self.finish_if_ready()?;217        Ok(output)218    }219220    pub fn read_handshake(&mut self, message: &[u8]) -> CoreResult<Vec<u8>> {221        if message.len() > MAX_NOISE_MESSAGE {222            return Err("Noise handshake message exceeds 65535 bytes".into());223        }224        let handshake = self.handshake_mut()?;225        if handshake.is_my_turn() {226            return Err("Noise handshake read attempted out of turn".into());227        }228        let mut payload = vec![0_u8; MAX_NOISE_MESSAGE];229        let size = handshake230            .read_message(message, &mut payload)231            .map_err(display_error)?;232        payload.truncate(size);233        self.finish_if_ready()?;234        Ok(payload)235    }236237    pub fn encrypt(&mut self, plaintext: &[u8]) -> CoreResult<Vec<u8>> {238        if plaintext.len() > MAX_NOISE_MESSAGE - TAG_LEN {239            return Err("Noise plaintext exceeds transport limit".into());240        }241        let transport = self.transport_mut()?;242        let mut output = vec![0_u8; plaintext.len() + TAG_LEN];243        let size = transport244            .write_message(plaintext, &mut output)245            .map_err(display_error)?;246        output.truncate(size);247        Ok(output)248    }249250    pub fn decrypt(&mut self, ciphertext: &[u8]) -> CoreResult<Vec<u8>> {251        if ciphertext.len() > MAX_NOISE_MESSAGE {252            return Err("Noise ciphertext exceeds transport limit".into());253        }254        let transport = self.transport_mut()?;255        let mut output = vec![0_u8; ciphertext.len()];256        let size = transport257            .read_message(ciphertext, &mut output)258            .map_err(display_error)?;259        output.truncate(size);260        Ok(output)261    }262263    pub fn handshake_hash(&self) -> CoreResult<Vec<u8>> {264        self.handshake_hash265            .map(|hash| hash.to_vec())266            .ok_or_else(|| "Noise handshake is not complete".into())267    }268269    pub fn rekey_outgoing(&mut self) -> CoreResult<()> {270        self.transport_mut()?.rekey_outgoing();271        Ok(())272    }273274    pub fn rekey_incoming(&mut self) -> CoreResult<()> {275        self.transport_mut()?.rekey_incoming();276        Ok(())277    }278279    fn handshake_mut(&mut self) -> CoreResult<&mut HandshakeState> {280        match &mut self.state {281            SessionState::Handshake(Some(handshake)) => Ok(handshake.as_mut()),282            SessionState::Handshake(None) => Err("Noise handshake is transitioning".into()),283            SessionState::Transport(_) => Err("Noise handshake is already complete".into()),284            SessionState::Closed => Err("Noise session is closed".into()),285        }286    }287288    fn transport_mut(&mut self) -> CoreResult<&mut TransportState> {289        match &mut self.state {290            SessionState::Transport(transport) => Ok(transport),291            SessionState::Handshake(_) => Err("Noise handshake is not complete".into()),292            SessionState::Closed => Err("Noise session is closed".into()),293        }294    }295296    fn finish_if_ready(&mut self) -> CoreResult<()> {297        let ready = matches!(298            &self.state,299            SessionState::Handshake(Some(handshake)) if handshake.is_handshake_finished()300        );301        if !ready {302            return Ok(());303        }304305        let handshake = match std::mem::replace(&mut self.state, SessionState::Closed) {306            SessionState::Handshake(Some(handshake)) => *handshake,307            _ => return Err("Noise session transition failed".into()),308        };309        let hash: [u8; HASH_LEN] = handshake310            .get_handshake_hash()311            .try_into()312            .map_err(|_| "Noise handshake hash must be 32 bytes".to_string())?;313        let transport = handshake.into_transport_mode().map_err(display_error)?;314        self.handshake_hash = Some(hash);315        self.state = SessionState::Transport(transport);316        Ok(())317    }318}319320impl Drop for CoreNoiseSession {321    fn drop(&mut self) {322        if let Some(hash) = &mut self.handshake_hash {323            hash.zeroize();324        }325        // PB-26-609: snow 0.10 has no zeroize dependency at all, and its326        // default resolver keeps the cipher key inline as327        // `CipherChaChaPoly { key: [u8; 32] }` (snow-0.10.0/src/resolvers/328        // default.rs:152) with no Drop or Zeroize impl. Dropping the329        // TransportState therefore leaves both cipher keys in memory, even330        // though the per-operation ChaCha20Poly1305 it builds does scrub331        // itself. Overwrite both directions through snow's public rekey API332        // first; the session is unusable afterwards by construction.333        if let SessionState::Transport(transport) = &mut self.state {334            transport.rekey_manually(Some(&[0_u8; 32]), Some(&[0_u8; 32]));335        }336        self.state = SessionState::Closed;337    }338}339340fn require_key(key: &[u8], label: &str) -> CoreResult<()> {341    if key.len() != 32 {342        return Err(format!("{label} must be 32 bytes"));343    }344    Ok(())345}346347fn require_matching_keypair(private_key: &[u8], public_key: &[u8]) -> CoreResult<()> {348    require_key(private_key, "local private key")?;349    require_key(public_key, "local public key")?;350    // Zeroizing so the stack copy is scrubbed: `StaticSecret::from` takes the351    // array by value and zeroizes its own buffer on drop, but a moved-from352    // `[u8; 32]` on the stack is not cleared by Rust.353    let private = Zeroizing::new(<[u8; 32]>::try_from(private_key).expect("length checked"));354    let derived = PublicKey::from(&StaticSecret::from(*private));355    if derived.as_bytes().ct_eq(public_key).unwrap_u8() != 1 {356        return Err("local public key does not match local private key".into());357    }358    Ok(())359}360361fn display_error(error: impl std::fmt::Display) -> String {362    error.to_string()363}364365fn core_builder(params: NoiseParams) -> Builder<'static> {366    Builder::with_resolver(params, Box::new(RejectZeroResolver))367        .prologue(NOISE_PROLOGUE)368        .expect("fixed Noise prologue is valid")369}370371struct RejectZeroResolver;372373impl CryptoResolver for RejectZeroResolver {374    fn resolve_rng(&self) -> Option<Box<dyn Random>> {375        DefaultResolver.resolve_rng()376    }377378    fn resolve_dh(&self, choice: &DHChoice) -> Option<Box<dyn Dh>> {379        DefaultResolver380            .resolve_dh(choice)381            .map(|inner| Box::new(RejectZeroDh { inner }) as Box<dyn Dh>)382    }383384    fn resolve_hash(&self, choice: &HashChoice) -> Option<Box<dyn Hash>> {385        DefaultResolver.resolve_hash(choice)386    }387388    fn resolve_cipher(&self, choice: &CipherChoice) -> Option<Box<dyn Cipher>> {389        DefaultResolver.resolve_cipher(choice)390    }391}392393struct RejectZeroDh {394    inner: Box<dyn Dh>,395}396397/// Small-order X25519 u-coordinates (little-endian) — the points of order398/// 1, 2, 4 and 8 (libhydrogen's contributory-behaviour blocklist). A DH399/// against one of these yields a constant, low-entropy shared secret, so400/// they are rejected as inputs outright (PB-26-014).401const X25519_SMALL_ORDER_PUBKEYS: [[u8; 32]; 9] = [402    [403        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,404        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,405        0x00, 0x00,406    ],407    [408        0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,409        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,410        0x00, 0x00,411    ],412    [413        0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4,414        0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49,415        0xb8, 0x80,416    ],417    [418        0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef,419        0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f,420        0x11, 0x57,421    ],422    [423        0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,424        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,425        0xff, 0x7f,426    ],427    [428        0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,429        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,430        0xff, 0x7f,431    ],432    [433        0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,434        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,435        0xff, 0x7f,436    ],437    [438        0xcd, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4,439        0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49,440        0xb8, 0x80,441    ],442    [443        0x4c, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef,444        0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f,445        0x11, 0xd7,446    ],447];448449fn is_small_order_x25519_pubkey(public_key: &[u8]) -> bool {450    // snow 0.10 passes the remote static inside its 56-byte MAXDHLEN buffer451    // (32 key bytes + 24 trailing zeros), so a strict 32-byte try_from always452    // fails and the guard would never fire on a real handshake. Compare the453    // leading 32 bytes whenever a full key is present.454    let Some(key) = public_key.get(..32) else {455        return false;456    };457    let Ok(key) = <[u8; 32]>::try_from(key) else {458        return false;459    };460    X25519_SMALL_ORDER_PUBKEYS461        .iter()462        .any(|low| low.ct_eq(&key).unwrap_u8() == 1)463}464465impl Dh for RejectZeroDh {466    fn name(&self) -> &'static str {467        self.inner.name()468    }469470    fn pub_len(&self) -> usize {471        self.inner.pub_len()472    }473474    fn priv_len(&self) -> usize {475        self.inner.priv_len()476    }477478    fn set(&mut self, private_key: &[u8]) {479        self.inner.set(private_key);480    }481482    fn generate(&mut self, rng: &mut dyn Random) -> Result<(), Error> {483        self.inner.generate(rng)484    }485486    fn pubkey(&self) -> &[u8] {487        self.inner.pubkey()488    }489490    fn privkey(&self) -> &[u8] {491        self.inner.privkey()492    }493494    fn dh(&self, public_key: &[u8], output: &mut [u8]) -> Result<(), Error> {495        if is_small_order_x25519_pubkey(public_key) {496            return Err(Error::Dh);497        }498        self.inner.dh(public_key, output)?;499        let used = self.inner.dh_len();500        if output[..used].ct_eq(&vec![0_u8; used]).unwrap_u8() == 1 {501            output[..used].zeroize();502            return Err(Error::Dh);503        }504        Ok(())505    }506507    fn dh_len(&self) -> usize {508        self.inner.dh_len()509    }510}511512#[cfg(target_arch = "wasm32")]513mod wasm {514    use super::CoreNoiseSession;515    use wasm_bindgen::prelude::*;516517    fn js_error(message: String) -> JsError {518        JsError::new(&message)519    }520521    #[wasm_bindgen]522    pub struct WasmNoiseSession {523        inner: CoreNoiseSession,524    }525526    #[wasm_bindgen]527    impl WasmNoiseSession {528        #[wasm_bindgen(js_name = nkInitiator)]529        pub fn nk_initiator(remote_public_key: &[u8]) -> Result<WasmNoiseSession, JsError> {530            Ok(Self {531                inner: CoreNoiseSession::nk_initiator(remote_public_key).map_err(js_error)?,532            })533        }534535        #[wasm_bindgen(js_name = nkResponder)]536        pub fn nk_responder(537            local_private_key: &[u8],538            local_public_key: &[u8],539        ) -> Result<WasmNoiseSession, JsError> {540            Ok(Self {541                inner: CoreNoiseSession::nk_responder(local_private_key, local_public_key)542                    .map_err(js_error)?,543            })544        }545546        #[cfg(feature = "test-constructors")]547        #[wasm_bindgen(js_name = nkInitiatorWithEphemeralForTest)]548        pub fn nk_initiator_with_ephemeral_for_test(549            remote_public_key: &[u8],550            ephemeral_private_key: &[u8],551        ) -> Result<WasmNoiseSession, JsError> {552            Ok(Self {553                inner: CoreNoiseSession::nk_initiator_with_ephemeral_for_test(554                    remote_public_key,555                    ephemeral_private_key,556                )557                .map_err(js_error)?,558            })559        }560561        #[cfg(feature = "test-constructors")]562        #[wasm_bindgen(js_name = nkResponderWithEphemeralForTest)]563        pub fn nk_responder_with_ephemeral_for_test(564            local_private_key: &[u8],565            local_public_key: &[u8],566            ephemeral_private_key: &[u8],567        ) -> Result<WasmNoiseSession, JsError> {568            Ok(Self {569                inner: CoreNoiseSession::nk_responder_with_ephemeral_for_test(570                    local_private_key,571                    local_public_key,572                    ephemeral_private_key,573                )574                .map_err(js_error)?,575            })576        }577578        #[wasm_bindgen(js_name = kkInitiator)]579        pub fn kk_initiator(580            local_private_key: &[u8],581            local_public_key: &[u8],582            remote_public_key: &[u8],583        ) -> Result<WasmNoiseSession, JsError> {584            Ok(Self {585                inner: CoreNoiseSession::kk_initiator(586                    local_private_key,587                    local_public_key,588                    remote_public_key,589                )590                .map_err(js_error)?,591            })592        }593594        #[wasm_bindgen(js_name = kkResponder)]595        pub fn kk_responder(596            local_private_key: &[u8],597            local_public_key: &[u8],598            remote_public_key: &[u8],599        ) -> Result<WasmNoiseSession, JsError> {600            Ok(Self {601                inner: CoreNoiseSession::kk_responder(602                    local_private_key,603                    local_public_key,604                    remote_public_key,605                )606                .map_err(js_error)?,607            })608        }609610        #[cfg(feature = "test-constructors")]611        #[wasm_bindgen(js_name = kkInitiatorWithEphemeralForTest)]612        pub fn kk_initiator_with_ephemeral_for_test(613            local_private_key: &[u8],614            local_public_key: &[u8],615            remote_public_key: &[u8],616            ephemeral_private_key: &[u8],617        ) -> Result<WasmNoiseSession, JsError> {618            Ok(Self {619                inner: CoreNoiseSession::kk_initiator_with_ephemeral_for_test(620                    local_private_key,621                    local_public_key,622                    remote_public_key,623                    ephemeral_private_key,624                )625                .map_err(js_error)?,626            })627        }628629        #[cfg(feature = "test-constructors")]630        #[wasm_bindgen(js_name = kkResponderWithEphemeralForTest)]631        pub fn kk_responder_with_ephemeral_for_test(632            local_private_key: &[u8],633            local_public_key: &[u8],634            remote_public_key: &[u8],635            ephemeral_private_key: &[u8],636        ) -> Result<WasmNoiseSession, JsError> {637            Ok(Self {638                inner: CoreNoiseSession::kk_responder_with_ephemeral_for_test(639                    local_private_key,640                    local_public_key,641                    remote_public_key,642                    ephemeral_private_key,643                )644                .map_err(js_error)?,645            })646        }647648        #[wasm_bindgen(js_name = writeHandshake)]649        pub fn write_handshake(&mut self, payload: &[u8]) -> Result<Vec<u8>, JsError> {650            self.inner.write_handshake(payload).map_err(js_error)651        }652653        #[wasm_bindgen(js_name = readHandshake)]654        pub fn read_handshake(&mut self, message: &[u8]) -> Result<Vec<u8>, JsError> {655            self.inner.read_handshake(message).map_err(js_error)656        }657658        pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, JsError> {659            self.inner.encrypt(plaintext).map_err(js_error)660        }661662        pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, JsError> {663            self.inner.decrypt(ciphertext).map_err(js_error)664        }665666        #[wasm_bindgen(getter, js_name = handshakeHash)]667        pub fn handshake_hash(&self) -> Result<Vec<u8>, JsError> {668            self.inner.handshake_hash().map_err(js_error)669        }670671        #[wasm_bindgen(js_name = rekeyOutgoing)]672        pub fn rekey_outgoing(&mut self) -> Result<(), JsError> {673            self.inner.rekey_outgoing().map_err(js_error)674        }675676        #[wasm_bindgen(js_name = rekeyIncoming)]677        pub fn rekey_incoming(&mut self) -> Result<(), JsError> {678            self.inner.rekey_incoming().map_err(js_error)679        }680    }681}682683#[cfg(not(target_arch = "wasm32"))]684mod jni_bridge;685686#[cfg(test)]687mod tests {688    use super::{689        CoreNoiseSession, KK, MAX_NOISE_MESSAGE, NK, NOISE_PROLOGUE, RejectZeroResolver,690        X25519_SMALL_ORDER_PUBKEYS, is_small_order_x25519_pubkey,691    };692    use blake2::{693        Blake2sMac256,694        digest::{KeyInit as BlakeKeyInit, Mac},695    };696    use chacha20poly1305::{697        ChaCha20Poly1305, Nonce,698        aead::{Aead, Payload},699    };700    use snow::{Builder, params::NoiseParams};701    use x25519_dalek::{PublicKey, StaticSecret};702703    fn keypair(seed: u8) -> ([u8; 32], [u8; 32]) {704        let private = [seed; 32];705        let public = *PublicKey::from(&StaticSecret::from(private)).as_bytes();706        (private, public)707    }708709    fn hex(value: &str) -> Vec<u8> {710        assert!(value.len().is_multiple_of(2));711        value712            .as_bytes()713            .chunks_exact(2)714            .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())715            .collect()716    }717718    fn official_pair(719        pattern: &str,720        initiator_static: Option<&str>,721        initiator_ephemeral: &str,722        initiator_remote_static: &str,723        responder_static: &str,724        responder_ephemeral: &str,725        responder_remote_static: Option<&str>,726    ) -> (CoreNoiseSession, CoreNoiseSession) {727        let prologue = hex("4a6f686e2047616c74");728        let responder_private = hex(responder_static);729        let responder_public = PublicKey::from(&StaticSecret::from(730            <[u8; 32]>::try_from(&responder_private[..]).unwrap(),731        ));732        assert_eq!(733            responder_public.as_bytes(),734            &hex(initiator_remote_static)[..]735        );736737        let params: NoiseParams = pattern.parse().unwrap();738        let initiator_ephemeral = hex(initiator_ephemeral);739        let initiator_static = initiator_static.map(hex);740        let responder_ephemeral = hex(responder_ephemeral);741        let responder_remote_static = responder_remote_static.map(hex);742        let mut initiator_builder =743            Builder::with_resolver(params.clone(), Box::new(RejectZeroResolver))744                .prologue(&prologue)745                .unwrap()746                .remote_public_key(responder_public.as_bytes())747                .unwrap()748                .fixed_ephemeral_key_for_testing_only(&initiator_ephemeral);749        if let Some(private) = &initiator_static {750            initiator_builder = initiator_builder.local_private_key(private).unwrap();751        }752753        let mut responder_builder = Builder::with_resolver(params, Box::new(RejectZeroResolver))754            .prologue(&prologue)755            .unwrap()756            .local_private_key(&responder_private)757            .unwrap()758            .fixed_ephemeral_key_for_testing_only(&responder_ephemeral);759        if let Some(remote_public) = &responder_remote_static {760            responder_builder = responder_builder.remote_public_key(remote_public).unwrap();761        }762763        (764            CoreNoiseSession::handshake(initiator_builder.build_initiator().unwrap()),765            CoreNoiseSession::handshake(responder_builder.build_responder().unwrap()),766        )767    }768769    fn assert_cacophony_vector(770        mut initiator: CoreNoiseSession,771        mut responder: CoreNoiseSession,772        handshake_hash: &str,773        messages: &[(&str, &str)],774    ) {775        for (index, (payload, expected_ciphertext)) in messages.iter().enumerate() {776            let plaintext = hex(payload);777            let expected = hex(expected_ciphertext);778            if index < 2 {779                let (sender, receiver) = if index % 2 == 0 {780                    (&mut initiator, &mut responder)781                } else {782                    (&mut responder, &mut initiator)783                };784                let actual = sender.write_handshake(&plaintext).unwrap();785                assert_eq!(actual, expected, "handshake message {index}");786                assert_eq!(787                    receiver.read_handshake(&actual).unwrap(),788                    plaintext,789                    "handshake payload {index}",790                );791            } else {792                let (sender, receiver) = if index % 2 == 0 {793                    (&mut initiator, &mut responder)794                } else {795                    (&mut responder, &mut initiator)796                };797                let actual = sender.encrypt(&plaintext).unwrap();798                assert_eq!(actual, expected, "transport message {index}");799                assert_eq!(800                    receiver.decrypt(&actual).unwrap(),801                    plaintext,802                    "transport payload {index}",803                );804            }805        }806        let expected_hash = hex(handshake_hash);807        assert_eq!(initiator.handshake_hash().unwrap(), expected_hash);808        assert_eq!(responder.handshake_hash().unwrap(), expected_hash);809    }810811    fn complete(initiator: &mut CoreNoiseSession, responder: &mut CoreNoiseSession) {812        let message1 = initiator.write_handshake(&[]).unwrap();813        assert!(responder.read_handshake(&message1).unwrap().is_empty());814        let message2 = responder.write_handshake(&[]).unwrap();815        assert!(initiator.read_handshake(&message2).unwrap().is_empty());816        assert_eq!(817            initiator.handshake_hash().unwrap(),818            responder.handshake_hash().unwrap()819        );820    }821822    #[test]823    fn mismatched_prologue_rejects_handshake() {824        let (private, _) = keypair(7);825        let remote = keypair(8).1;826        let params: NoiseParams = NK.parse().unwrap();827        let mut initiator = Builder::with_resolver(params.clone(), Box::new(RejectZeroResolver))828            .prologue(b"peach-sync-old")829            .unwrap()830            .remote_public_key(&remote)831            .unwrap()832            .fixed_ephemeral_key_for_testing_only(&[1; 32])833            .build_initiator()834            .unwrap();835        let mut responder = Builder::with_resolver(params, Box::new(RejectZeroResolver))836            .prologue(NOISE_PROLOGUE)837            .unwrap()838            .local_private_key(&private)839            .unwrap()840            .fixed_ephemeral_key_for_testing_only(&[2; 32])841            .build_responder()842            .unwrap();843        let mut message = [0_u8; MAX_NOISE_MESSAGE];844        let size = initiator.write_message(&[], &mut message).unwrap();845        assert!(responder.read_message(&message[..size], &mut []).is_err());846    }847848    #[test]849    fn nk_round_trip_uses_one_state_machine() {850        let (responder_private, responder_public) = keypair(7);851        let mut initiator = CoreNoiseSession::nk_initiator(&responder_public).unwrap();852        let mut responder =853            CoreNoiseSession::nk_responder(&responder_private, &responder_public).unwrap();854        complete(&mut initiator, &mut responder);855856        let ciphertext = initiator.encrypt(b"peach").unwrap();857        assert_eq!(responder.decrypt(&ciphertext).unwrap(), b"peach");858        let response = responder.encrypt(b"sync").unwrap();859        assert_eq!(initiator.decrypt(&response).unwrap(), b"sync");860    }861862    // PB-26-014: contributory behaviour — a small-order remote static must863    // fail the handshake at the DH step instead of contributing a constant864    // low-entropy secret to the keying material. Drive ALL nine entries:865    // seven have an all-zero DH output (the pre-existing check catches them)866    // but two produce non-zero output and only this guard rejects them.867    #[test]868    fn small_order_remote_static_fails_handshake() {869        let (local_private, local_public) = keypair(11);870        for low in X25519_SMALL_ORDER_PUBKEYS.iter() {871            let mut initiator =872                CoreNoiseSession::kk_initiator(&local_private, &local_public, low).unwrap();873            assert!(874                initiator.write_handshake(&[]).is_err(),875                "handshake against small-order static must fail"876            );877            assert!(is_small_order_x25519_pubkey(low));878        }879        let (_, real_public) = keypair(9);880        assert!(!is_small_order_x25519_pubkey(&real_public));881    }882883    // snow hands the guard the remote static inside its 56-byte MAXDHLEN884    // buffer (32 key bytes + 24 trailing zeros) — the guard must compare the885    // leading 32 bytes, not require exactly 32 (PB-26-014 regression).886    #[test]887    fn small_order_guard_handles_snow_padded_buffers() {888        for low in X25519_SMALL_ORDER_PUBKEYS.iter() {889            let mut padded = [0u8; 56];890            padded[..32].copy_from_slice(low);891            assert!(is_small_order_x25519_pubkey(&padded));892        }893        let (_, real_public) = keypair(13);894        let mut padded = [0u8; 56];895        padded[..32].copy_from_slice(&real_public);896        assert!(!is_small_order_x25519_pubkey(&padded));897    }898899    #[test]900    fn kk_round_trip_authenticates_both_static_keys() {901        let (initiator_private, initiator_public) = keypair(11);902        let (responder_private, responder_public) = keypair(19);903        let mut initiator = CoreNoiseSession::kk_initiator(904            &initiator_private,905            &initiator_public,906            &responder_public,907        )908        .unwrap();909        let mut responder = CoreNoiseSession::kk_responder(910            &responder_private,911            &responder_public,912            &initiator_public,913        )914        .unwrap();915        complete(&mut initiator, &mut responder);916    }917918    #[test]919    fn mismatched_local_keypair_is_rejected() {920        let (private, _) = keypair(2);921        let (_, wrong_public) = keypair(3);922        let result = CoreNoiseSession::nk_responder(&private, &wrong_public);923        assert!(matches!(result, Err(error) if error.contains("does not match")));924    }925926    #[test]927    fn tampered_transport_frame_is_rejected() {928        let (responder_private, responder_public) = keypair(23);929        let mut initiator = CoreNoiseSession::nk_initiator(&responder_public).unwrap();930        let mut responder =931            CoreNoiseSession::nk_responder(&responder_private, &responder_public).unwrap();932        complete(&mut initiator, &mut responder);933        let mut ciphertext = initiator.encrypt(b"vault").unwrap();934        ciphertext[0] ^= 0x80;935        assert!(responder.decrypt(&ciphertext).is_err());936    }937938    #[test]939    fn cacophony_nk_vector_matches_shared_core() {940        let pair = official_pair(941            NK,942            None,943            "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",944            "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62",945            "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",946            "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",947            None,948        );949        assert_cacophony_vector(950            pair.0,951            pair.1,952            "d7244d974066aae2376f7ba5534f60a6e4e82cd7c9751e226cae3928e6b49f14",953            &[954                (955                    "4c756477696720766f6e204d69736573",956                    "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c794454ae7612d1724af42adb130160a9a94e67b5b169b4e00c189f6467cd17eb7cad",957                ),958                (959                    "4d757272617920526f746862617264",960                    "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843986a5c929337e337ac8b4a074af12ab9f76318a5f18c8b599a443af07383ce",961                ),962                (963                    "462e20412e20486179656b",964                    "550027c7a5d450017bcb5e12b8253b1c53fd2213aeda84891d5f95",965                ),966                (967                    "4361726c204d656e676572",968                    "dfbce0c38210ccee35e830aca9dd8b8b3997b933e75bfc8864b759",969                ),970            ],971        );972    }973974    #[test]975    fn cacophony_kk_vector_matches_shared_core() {976        let pair = official_pair(977            KK,978            Some("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"),979            "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",980            "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62",981            "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",982            "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",983            Some("6bc3822a2aa7f4e6981d6538692b3cdf3e6df9eea6ed269eb41d93c22757b75a"),984        );985        assert_cacophony_vector(986            pair.0,987            pair.1,988            "1362b8627a00907ce11e558aba8ce7cbca88e83f0e84ce7db5159b1c3e25ab59",989            &[990                (991                    "4c756477696720766f6e204d69736573",992                    "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944266a5f53784aa3becb0f7485c2759c328937867a4cbaafef07422b0725e098be",993                ),994                (995                    "4d757272617920526f746862617264",996                    "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843008aeea5d76d6abcbab87a18502c8a8352d9933ac11e2a7d228038d721e31e",997                ),998                (999                    "462e20412e20486179656b",1000                    "5f92113edf78c3e56e6d67201f5f9e0c8f2930c3e1ffb64ede0358",1001                ),1002                (1003                    "4361726c204d656e676572",1004                    "30ebbd9cdcef7f40d99c8cd11e880dac28f5c9e5032c1059b3b56a",1005                ),1006            ],1007        );1008    }10091010    #[test]1011    fn rfc_8439_chacha20_poly1305_aead_vector_matches_snow_cipher() {1012        let key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f");1013        let nonce = hex("070000004041424344454647");1014        let aad = hex("50515253c0c1c2c3c4c5c6c7");1015        let plaintext = hex(concat!(1016            "4c616469657320616e642047656e746c656d656e206f662074686520636c6173",1017            "73206f66202739393a204966204920636f756c64206f6666657220796f75206f",1018            "6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73",1019            "637265656e20776f756c642062652069742e",1020        ));1021        let expected = hex(concat!(1022            "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d6",1023            "3dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b36",1024            "92ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc",1025            "3ff4def08e4b7a9de576d26586cec64b6116",1026            "1ae10b594f09e26a7e902ecbd0600691",1027        ));1028        let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap();1029        assert_eq!(1030            cipher1031                .encrypt(1032                    Nonce::from_slice(&nonce),1033                    Payload {1034                        msg: &plaintext,1035                        aad: &aad,1036                    },1037                )1038                .unwrap(),1039            expected,1040        );1041    }10421043    /// Source of the item starting at `marker`, cut at the next top-level item1044    /// so the search cannot drift into a later function or into these tests.1045    fn source_after(marker: &str) -> String {1046        let source = include_str!("lib.rs");1047        let after = source.split(marker).nth(1).expect("marker is present");1048        let end = after1049            .find("\nfn ")1050            .or_else(|| after.find("\nimpl "))1051            .unwrap_or(after.len());1052        after[..end].to_string()1053    }10541055    // PB-26-608: `require_matching_keypair` copies the private key onto the1056    // stack. Rust does not scrub a moved-from `[u8; 32]`, and stack residue1057    // cannot be observed portably, so this pins the wrapper instead.1058    #[test]1059    fn pb_26_608_private_key_copy_is_zeroized() {1060        let body = source_after("fn require_matching_keypair");1061        assert!(1062            body.contains("Zeroizing::new"),1063            "the private-key copy must be Zeroizing-wrapped"1064        );1065    }10661067    // PB-26-609: proves the mechanism `CoreNoiseSession::drop` relies on —1068    // rekeying both directions with zeros really does replace the stored1069    // ChaChaPoly key rather than adding a second one.1070    #[test]1071    fn pb_26_609_zero_rekey_replaces_the_transport_cipher_key() {1072        let params: NoiseParams = NK.parse().unwrap();1073        let responder_static = keypair(9);1074        let mut handshake_initiator = Builder::new(params.clone())1075            .remote_public_key(&responder_static.1)1076            .unwrap()1077            .build_initiator()1078            .unwrap();1079        let mut handshake_responder = Builder::new(params)1080            .local_private_key(&responder_static.0)1081            .unwrap()1082            .build_responder()1083            .unwrap();10841085        let mut wire = [0_u8; 1024];1086        let mut out = [0_u8; 1024];1087        let len = handshake_initiator.write_message(&[], &mut wire).unwrap();1088        handshake_responder1089            .read_message(&wire[..len], &mut out)1090            .unwrap();1091        let len = handshake_responder.write_message(&[], &mut wire).unwrap();1092        handshake_initiator1093            .read_message(&wire[..len], &mut out)1094            .unwrap();10951096        let mut initiator = handshake_initiator1097            .into_transport_mode()1098            .expect("handshake must be finished");1099        let mut responder = handshake_responder1100            .into_transport_mode()1101            .expect("handshake must be finished");11021103        let mut frame = [0_u8; 64];1104        let len = initiator.write_message(b"secret", &mut frame).unwrap();1105        let frame = frame[..len].to_vec();11061107        initiator.rekey_manually(Some(&[0_u8; 32]), Some(&[0_u8; 32]));11081109        // A zeroed key cannot decrypt the frame the previous key produced, so1110        // the old key material is gone from the live state.1111        assert!(initiator.read_message(&frame, &mut [0_u8; 64]).is_err());1112        // And a peer holding the real key cannot read what the rekeyed side1113        // writes next, so the replacement is real, not additive.1114        let mut next = [0_u8; 64];1115        let len = initiator.write_message(b"after", &mut next).unwrap();1116        assert!(1117            responder1118                .read_message(&next[..len], &mut [0_u8; 64])1119                .is_err()1120        );1121    }11221123    // PB-26-609: pins that drop performs the zero rekey. Heap residue cannot be1124    // observed portably, so this asserts the call site rather than the bytes.1125    #[test]1126    fn pb_26_609_drop_zeroizes_transport_cipher_keys() {1127        let body = source_after("impl Drop for CoreNoiseSession");1128        assert!(1129            body.contains("rekey_manually"),1130            "drop must overwrite both transport cipher keys before closing"1131        );1132    }11331134    #[test]1135    fn blake2_reference_keyed_vector_matches_snow_hash_dependency() {1136        let key: Vec<u8> = (0_u8..32).collect();1137        let mut mac = <Blake2sMac256 as BlakeKeyInit>::new_from_slice(&key).unwrap();1138        Mac::update(&mut mac, &[]);1139        assert_eq!(1140            Mac::finalize(mac).into_bytes().as_slice(),1141            &hex("48a8997da407876b3d79c0d92325ad3b89cbb754d86ab71aee047ad345fd2c49"),1142        );1143    }1144}