lib.rs

38.8 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<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(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),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        self.state = SessionState::Closed;326    }327}328329fn require_key(key: &[u8], label: &str) -> CoreResult<()> {330    if key.len() != 32 {331        return Err(format!("{label} must be 32 bytes"));332    }333    Ok(())334}335336fn require_matching_keypair(private_key: &[u8], public_key: &[u8]) -> CoreResult<()> {337    require_key(private_key, "local private key")?;338    require_key(public_key, "local public key")?;339    // Zeroizing so the stack copy is scrubbed: `StaticSecret::from` takes the340    // array by value and zeroizes its own buffer on drop, but a moved-from341    // `[u8; 32]` on the stack is not cleared by Rust.342    let private = Zeroizing::new(<[u8; 32]>::try_from(private_key).expect("length checked"));343    let derived = PublicKey::from(&StaticSecret::from(*private));344    if derived.as_bytes().ct_eq(public_key).unwrap_u8() != 1 {345        return Err("local public key does not match local private key".into());346    }347    Ok(())348}349350fn display_error(error: impl std::fmt::Display) -> String {351    error.to_string()352}353354fn core_builder(params: NoiseParams) -> Builder<'static> {355    Builder::with_resolver(params, Box::new(RejectZeroResolver))356        .prologue(NOISE_PROLOGUE)357        .expect("fixed Noise prologue is valid")358}359360struct RejectZeroResolver;361362impl CryptoResolver for RejectZeroResolver {363    fn resolve_rng(&self) -> Option<Box<dyn Random>> {364        DefaultResolver.resolve_rng()365    }366367    fn resolve_dh(&self, choice: &DHChoice) -> Option<Box<dyn Dh>> {368        DefaultResolver369            .resolve_dh(choice)370            .map(|inner| Box::new(RejectZeroDh { inner }) as Box<dyn Dh>)371    }372373    fn resolve_hash(&self, choice: &HashChoice) -> Option<Box<dyn Hash>> {374        DefaultResolver.resolve_hash(choice)375    }376377    fn resolve_cipher(&self, choice: &CipherChoice) -> Option<Box<dyn Cipher>> {378        DefaultResolver.resolve_cipher(choice)379    }380}381382struct RejectZeroDh {383    inner: Box<dyn Dh>,384}385386/// Small-order X25519 u-coordinates (little-endian) — the points of order387/// 1, 2, 4 and 8 (libhydrogen's contributory-behaviour blocklist). A DH388/// against one of these yields a constant, low-entropy shared secret, so389/// they are rejected as inputs outright (PB-26-014).390const X25519_SMALL_ORDER_PUBKEYS: [[u8; 32]; 9] = [391    [392        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,393        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,394        0x00, 0x00,395    ],396    [397        0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,398        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,399        0x00, 0x00,400    ],401    [402        0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4,403        0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49,404        0xb8, 0x80,405    ],406    [407        0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef,408        0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f,409        0x11, 0x57,410    ],411    [412        0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,413        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,414        0xff, 0x7f,415    ],416    [417        0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,418        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,419        0xff, 0x7f,420    ],421    [422        0xee, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,423        0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,424        0xff, 0x7f,425    ],426    [427        0xcd, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4,428        0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49,429        0xb8, 0x80,430    ],431    [432        0x4c, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef,433        0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f,434        0x11, 0xd7,435    ],436];437438fn is_small_order_x25519_pubkey(public_key: &[u8]) -> bool {439    // snow 0.10 passes the remote static inside its 56-byte MAXDHLEN buffer440    // (32 key bytes + 24 trailing zeros), so a strict 32-byte try_from always441    // fails and the guard would never fire on a real handshake. Compare the442    // leading 32 bytes whenever a full key is present.443    let Some(key) = public_key.get(..32) else {444        return false;445    };446    let Ok(key) = <[u8; 32]>::try_from(key) else {447        return false;448    };449    X25519_SMALL_ORDER_PUBKEYS450        .iter()451        .any(|low| low.ct_eq(&key).unwrap_u8() == 1)452}453454impl Dh for RejectZeroDh {455    fn name(&self) -> &'static str {456        self.inner.name()457    }458459    fn pub_len(&self) -> usize {460        self.inner.pub_len()461    }462463    fn priv_len(&self) -> usize {464        self.inner.priv_len()465    }466467    fn set(&mut self, private_key: &[u8]) {468        self.inner.set(private_key);469    }470471    fn generate(&mut self, rng: &mut dyn Random) -> Result<(), Error> {472        self.inner.generate(rng)473    }474475    fn pubkey(&self) -> &[u8] {476        self.inner.pubkey()477    }478479    fn privkey(&self) -> &[u8] {480        self.inner.privkey()481    }482483    fn dh(&self, public_key: &[u8], output: &mut [u8]) -> Result<(), Error> {484        if is_small_order_x25519_pubkey(public_key) {485            return Err(Error::Dh);486        }487        self.inner.dh(public_key, output)?;488        let used = self.inner.dh_len();489        if output[..used].ct_eq(&vec![0_u8; used]).unwrap_u8() == 1 {490            output[..used].zeroize();491            return Err(Error::Dh);492        }493        Ok(())494    }495496    fn dh_len(&self) -> usize {497        self.inner.dh_len()498    }499}500501#[cfg(target_arch = "wasm32")]502mod wasm {503    use super::CoreNoiseSession;504    use wasm_bindgen::prelude::*;505506    fn js_error(message: String) -> JsError {507        JsError::new(&message)508    }509510    #[wasm_bindgen]511    pub struct WasmNoiseSession {512        inner: CoreNoiseSession,513    }514515    #[wasm_bindgen]516    impl WasmNoiseSession {517        #[wasm_bindgen(js_name = nkInitiator)]518        pub fn nk_initiator(remote_public_key: &[u8]) -> Result<WasmNoiseSession, JsError> {519            Ok(Self {520                inner: CoreNoiseSession::nk_initiator(remote_public_key).map_err(js_error)?,521            })522        }523524        #[wasm_bindgen(js_name = nkResponder)]525        pub fn nk_responder(526            local_private_key: &[u8],527            local_public_key: &[u8],528        ) -> Result<WasmNoiseSession, JsError> {529            Ok(Self {530                inner: CoreNoiseSession::nk_responder(local_private_key, local_public_key)531                    .map_err(js_error)?,532            })533        }534535        #[cfg(feature = "test-constructors")]536        #[wasm_bindgen(js_name = nkInitiatorWithEphemeralForTest)]537        pub fn nk_initiator_with_ephemeral_for_test(538            remote_public_key: &[u8],539            ephemeral_private_key: &[u8],540        ) -> Result<WasmNoiseSession, JsError> {541            Ok(Self {542                inner: CoreNoiseSession::nk_initiator_with_ephemeral_for_test(543                    remote_public_key,544                    ephemeral_private_key,545                )546                .map_err(js_error)?,547            })548        }549550        #[cfg(feature = "test-constructors")]551        #[wasm_bindgen(js_name = nkResponderWithEphemeralForTest)]552        pub fn nk_responder_with_ephemeral_for_test(553            local_private_key: &[u8],554            local_public_key: &[u8],555            ephemeral_private_key: &[u8],556        ) -> Result<WasmNoiseSession, JsError> {557            Ok(Self {558                inner: CoreNoiseSession::nk_responder_with_ephemeral_for_test(559                    local_private_key,560                    local_public_key,561                    ephemeral_private_key,562                )563                .map_err(js_error)?,564            })565        }566567        #[wasm_bindgen(js_name = kkInitiator)]568        pub fn kk_initiator(569            local_private_key: &[u8],570            local_public_key: &[u8],571            remote_public_key: &[u8],572        ) -> Result<WasmNoiseSession, JsError> {573            Ok(Self {574                inner: CoreNoiseSession::kk_initiator(575                    local_private_key,576                    local_public_key,577                    remote_public_key,578                )579                .map_err(js_error)?,580            })581        }582583        #[wasm_bindgen(js_name = kkResponder)]584        pub fn kk_responder(585            local_private_key: &[u8],586            local_public_key: &[u8],587            remote_public_key: &[u8],588        ) -> Result<WasmNoiseSession, JsError> {589            Ok(Self {590                inner: CoreNoiseSession::kk_responder(591                    local_private_key,592                    local_public_key,593                    remote_public_key,594                )595                .map_err(js_error)?,596            })597        }598599        #[cfg(feature = "test-constructors")]600        #[wasm_bindgen(js_name = kkInitiatorWithEphemeralForTest)]601        pub fn kk_initiator_with_ephemeral_for_test(602            local_private_key: &[u8],603            local_public_key: &[u8],604            remote_public_key: &[u8],605            ephemeral_private_key: &[u8],606        ) -> Result<WasmNoiseSession, JsError> {607            Ok(Self {608                inner: CoreNoiseSession::kk_initiator_with_ephemeral_for_test(609                    local_private_key,610                    local_public_key,611                    remote_public_key,612                    ephemeral_private_key,613                )614                .map_err(js_error)?,615            })616        }617618        #[cfg(feature = "test-constructors")]619        #[wasm_bindgen(js_name = kkResponderWithEphemeralForTest)]620        pub fn kk_responder_with_ephemeral_for_test(621            local_private_key: &[u8],622            local_public_key: &[u8],623            remote_public_key: &[u8],624            ephemeral_private_key: &[u8],625        ) -> Result<WasmNoiseSession, JsError> {626            Ok(Self {627                inner: CoreNoiseSession::kk_responder_with_ephemeral_for_test(628                    local_private_key,629                    local_public_key,630                    remote_public_key,631                    ephemeral_private_key,632                )633                .map_err(js_error)?,634            })635        }636637        #[wasm_bindgen(js_name = writeHandshake)]638        pub fn write_handshake(&mut self, payload: &[u8]) -> Result<Vec<u8>, JsError> {639            self.inner.write_handshake(payload).map_err(js_error)640        }641642        #[wasm_bindgen(js_name = readHandshake)]643        pub fn read_handshake(&mut self, message: &[u8]) -> Result<Vec<u8>, JsError> {644            self.inner.read_handshake(message).map_err(js_error)645        }646647        pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, JsError> {648            self.inner.encrypt(plaintext).map_err(js_error)649        }650651        pub fn decrypt(&mut self, ciphertext: &[u8]) -> Result<Vec<u8>, JsError> {652            self.inner.decrypt(ciphertext).map_err(js_error)653        }654655        #[wasm_bindgen(getter, js_name = handshakeHash)]656        pub fn handshake_hash(&self) -> Result<Vec<u8>, JsError> {657            self.inner.handshake_hash().map_err(js_error)658        }659660        #[wasm_bindgen(js_name = rekeyOutgoing)]661        pub fn rekey_outgoing(&mut self) -> Result<(), JsError> {662            self.inner.rekey_outgoing().map_err(js_error)663        }664665        #[wasm_bindgen(js_name = rekeyIncoming)]666        pub fn rekey_incoming(&mut self) -> Result<(), JsError> {667            self.inner.rekey_incoming().map_err(js_error)668        }669    }670}671672#[cfg(not(target_arch = "wasm32"))]673mod jni_bridge;674675#[cfg(test)]676mod tests {677    use super::{678        CoreNoiseSession, KK, MAX_NOISE_MESSAGE, NK, NOISE_PROLOGUE, RejectZeroResolver,679        X25519_SMALL_ORDER_PUBKEYS, is_small_order_x25519_pubkey,680    };681    use blake2::{682        Blake2sMac256,683        digest::{KeyInit as BlakeKeyInit, Mac},684    };685    use chacha20poly1305::{686        ChaCha20Poly1305, Nonce,687        aead::{Aead, Payload},688    };689    use snow::{Builder, params::NoiseParams};690    use x25519_dalek::{PublicKey, StaticSecret};691692    fn keypair(seed: u8) -> ([u8; 32], [u8; 32]) {693        let private = [seed; 32];694        let public = *PublicKey::from(&StaticSecret::from(private)).as_bytes();695        (private, public)696    }697698    fn hex(value: &str) -> Vec<u8> {699        assert!(value.len().is_multiple_of(2));700        value701            .as_bytes()702            .chunks_exact(2)703            .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())704            .collect()705    }706707    fn official_pair(708        pattern: &str,709        initiator_static: Option<&str>,710        initiator_ephemeral: &str,711        initiator_remote_static: &str,712        responder_static: &str,713        responder_ephemeral: &str,714        responder_remote_static: Option<&str>,715    ) -> (CoreNoiseSession, CoreNoiseSession) {716        let prologue = hex("4a6f686e2047616c74");717        let responder_private = hex(responder_static);718        let responder_public = PublicKey::from(&StaticSecret::from(719            <[u8; 32]>::try_from(&responder_private[..]).unwrap(),720        ));721        assert_eq!(722            responder_public.as_bytes(),723            &hex(initiator_remote_static)[..]724        );725726        let params: NoiseParams = pattern.parse().unwrap();727        let initiator_ephemeral = hex(initiator_ephemeral);728        let initiator_static = initiator_static.map(hex);729        let responder_ephemeral = hex(responder_ephemeral);730        let responder_remote_static = responder_remote_static.map(hex);731        let mut initiator_builder =732            Builder::with_resolver(params.clone(), Box::new(RejectZeroResolver))733                .prologue(&prologue)734                .unwrap()735                .remote_public_key(responder_public.as_bytes())736                .unwrap()737                .fixed_ephemeral_key_for_testing_only(&initiator_ephemeral);738        if let Some(private) = &initiator_static {739            initiator_builder = initiator_builder.local_private_key(private).unwrap();740        }741742        let mut responder_builder = Builder::with_resolver(params, Box::new(RejectZeroResolver))743            .prologue(&prologue)744            .unwrap()745            .local_private_key(&responder_private)746            .unwrap()747            .fixed_ephemeral_key_for_testing_only(&responder_ephemeral);748        if let Some(remote_public) = &responder_remote_static {749            responder_builder = responder_builder.remote_public_key(remote_public).unwrap();750        }751752        (753            CoreNoiseSession::handshake(initiator_builder.build_initiator().unwrap()),754            CoreNoiseSession::handshake(responder_builder.build_responder().unwrap()),755        )756    }757758    fn assert_cacophony_vector(759        mut initiator: CoreNoiseSession,760        mut responder: CoreNoiseSession,761        handshake_hash: &str,762        messages: &[(&str, &str)],763    ) {764        for (index, (payload, expected_ciphertext)) in messages.iter().enumerate() {765            let plaintext = hex(payload);766            let expected = hex(expected_ciphertext);767            if index < 2 {768                let (sender, receiver) = if index % 2 == 0 {769                    (&mut initiator, &mut responder)770                } else {771                    (&mut responder, &mut initiator)772                };773                let actual = sender.write_handshake(&plaintext).unwrap();774                assert_eq!(actual, expected, "handshake message {index}");775                assert_eq!(776                    receiver.read_handshake(&actual).unwrap(),777                    plaintext,778                    "handshake payload {index}",779                );780            } else {781                let (sender, receiver) = if index % 2 == 0 {782                    (&mut initiator, &mut responder)783                } else {784                    (&mut responder, &mut initiator)785                };786                let actual = sender.encrypt(&plaintext).unwrap();787                assert_eq!(actual, expected, "transport message {index}");788                assert_eq!(789                    receiver.decrypt(&actual).unwrap(),790                    plaintext,791                    "transport payload {index}",792                );793            }794        }795        let expected_hash = hex(handshake_hash);796        assert_eq!(initiator.handshake_hash().unwrap(), expected_hash);797        assert_eq!(responder.handshake_hash().unwrap(), expected_hash);798    }799800    fn complete(initiator: &mut CoreNoiseSession, responder: &mut CoreNoiseSession) {801        let message1 = initiator.write_handshake(&[]).unwrap();802        assert!(responder.read_handshake(&message1).unwrap().is_empty());803        let message2 = responder.write_handshake(&[]).unwrap();804        assert!(initiator.read_handshake(&message2).unwrap().is_empty());805        assert_eq!(806            initiator.handshake_hash().unwrap(),807            responder.handshake_hash().unwrap()808        );809    }810811    #[test]812    fn mismatched_prologue_rejects_handshake() {813        let (private, _) = keypair(7);814        let remote = keypair(8).1;815        let params: NoiseParams = NK.parse().unwrap();816        let mut initiator = Builder::with_resolver(params.clone(), Box::new(RejectZeroResolver))817            .prologue(b"peach-sync-old")818            .unwrap()819            .remote_public_key(&remote)820            .unwrap()821            .fixed_ephemeral_key_for_testing_only(&[1; 32])822            .build_initiator()823            .unwrap();824        let mut responder = Builder::with_resolver(params, Box::new(RejectZeroResolver))825            .prologue(NOISE_PROLOGUE)826            .unwrap()827            .local_private_key(&private)828            .unwrap()829            .fixed_ephemeral_key_for_testing_only(&[2; 32])830            .build_responder()831            .unwrap();832        let mut message = [0_u8; MAX_NOISE_MESSAGE];833        let size = initiator.write_message(&[], &mut message).unwrap();834        assert!(responder.read_message(&message[..size], &mut []).is_err());835    }836837    #[test]838    fn nk_round_trip_uses_one_state_machine() {839        let (responder_private, responder_public) = keypair(7);840        let mut initiator = CoreNoiseSession::nk_initiator(&responder_public).unwrap();841        let mut responder =842            CoreNoiseSession::nk_responder(&responder_private, &responder_public).unwrap();843        complete(&mut initiator, &mut responder);844845        let ciphertext = initiator.encrypt(b"peach").unwrap();846        assert_eq!(responder.decrypt(&ciphertext).unwrap(), b"peach");847        let response = responder.encrypt(b"sync").unwrap();848        assert_eq!(initiator.decrypt(&response).unwrap(), b"sync");849    }850851    // PB-26-014: contributory behaviour — a small-order remote static must852    // fail the handshake at the DH step instead of contributing a constant853    // low-entropy secret to the keying material. Drive ALL nine entries:854    // seven have an all-zero DH output (the pre-existing check catches them)855    // but two produce non-zero output and only this guard rejects them.856    #[test]857    fn small_order_remote_static_fails_handshake() {858        let (local_private, local_public) = keypair(11);859        for low in X25519_SMALL_ORDER_PUBKEYS.iter() {860            let mut initiator =861                CoreNoiseSession::kk_initiator(&local_private, &local_public, low).unwrap();862            assert!(863                initiator.write_handshake(&[]).is_err(),864                "handshake against small-order static must fail"865            );866            assert!(is_small_order_x25519_pubkey(low));867        }868        let (_, real_public) = keypair(9);869        assert!(!is_small_order_x25519_pubkey(&real_public));870    }871872    // snow hands the guard the remote static inside its 56-byte MAXDHLEN873    // buffer (32 key bytes + 24 trailing zeros) — the guard must compare the874    // leading 32 bytes, not require exactly 32 (PB-26-014 regression).875    #[test]876    fn small_order_guard_handles_snow_padded_buffers() {877        for low in X25519_SMALL_ORDER_PUBKEYS.iter() {878            let mut padded = [0u8; 56];879            padded[..32].copy_from_slice(low);880            assert!(is_small_order_x25519_pubkey(&padded));881        }882        let (_, real_public) = keypair(13);883        let mut padded = [0u8; 56];884        padded[..32].copy_from_slice(&real_public);885        assert!(!is_small_order_x25519_pubkey(&padded));886    }887888    #[test]889    fn kk_round_trip_authenticates_both_static_keys() {890        let (initiator_private, initiator_public) = keypair(11);891        let (responder_private, responder_public) = keypair(19);892        let mut initiator = CoreNoiseSession::kk_initiator(893            &initiator_private,894            &initiator_public,895            &responder_public,896        )897        .unwrap();898        let mut responder = CoreNoiseSession::kk_responder(899            &responder_private,900            &responder_public,901            &initiator_public,902        )903        .unwrap();904        complete(&mut initiator, &mut responder);905    }906907    #[test]908    fn mismatched_local_keypair_is_rejected() {909        let (private, _) = keypair(2);910        let (_, wrong_public) = keypair(3);911        let result = CoreNoiseSession::nk_responder(&private, &wrong_public);912        assert!(matches!(result, Err(error) if error.contains("does not match")));913    }914915    #[test]916    fn tampered_transport_frame_is_rejected() {917        let (responder_private, responder_public) = keypair(23);918        let mut initiator = CoreNoiseSession::nk_initiator(&responder_public).unwrap();919        let mut responder =920            CoreNoiseSession::nk_responder(&responder_private, &responder_public).unwrap();921        complete(&mut initiator, &mut responder);922        let mut ciphertext = initiator.encrypt(b"vault").unwrap();923        ciphertext[0] ^= 0x80;924        assert!(responder.decrypt(&ciphertext).is_err());925    }926927    #[test]928    fn cacophony_nk_vector_matches_shared_core() {929        let pair = official_pair(930            NK,931            None,932            "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",933            "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62",934            "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",935            "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",936            None,937        );938        assert_cacophony_vector(939            pair.0,940            pair.1,941            "d7244d974066aae2376f7ba5534f60a6e4e82cd7c9751e226cae3928e6b49f14",942            &[943                (944                    "4c756477696720766f6e204d69736573",945                    "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c794454ae7612d1724af42adb130160a9a94e67b5b169b4e00c189f6467cd17eb7cad",946                ),947                (948                    "4d757272617920526f746862617264",949                    "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843986a5c929337e337ac8b4a074af12ab9f76318a5f18c8b599a443af07383ce",950                ),951                (952                    "462e20412e20486179656b",953                    "550027c7a5d450017bcb5e12b8253b1c53fd2213aeda84891d5f95",954                ),955                (956                    "4361726c204d656e676572",957                    "dfbce0c38210ccee35e830aca9dd8b8b3997b933e75bfc8864b759",958                ),959            ],960        );961    }962963    #[test]964    fn cacophony_kk_vector_matches_shared_core() {965        let pair = official_pair(966            KK,967            Some("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"),968            "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a",969            "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62",970            "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893",971            "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b",972            Some("6bc3822a2aa7f4e6981d6538692b3cdf3e6df9eea6ed269eb41d93c22757b75a"),973        );974        assert_cacophony_vector(975            pair.0,976            pair.1,977            "1362b8627a00907ce11e558aba8ce7cbca88e83f0e84ce7db5159b1c3e25ab59",978            &[979                (980                    "4c756477696720766f6e204d69736573",981                    "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944266a5f53784aa3becb0f7485c2759c328937867a4cbaafef07422b0725e098be",982                ),983                (984                    "4d757272617920526f746862617264",985                    "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843008aeea5d76d6abcbab87a18502c8a8352d9933ac11e2a7d228038d721e31e",986                ),987                (988                    "462e20412e20486179656b",989                    "5f92113edf78c3e56e6d67201f5f9e0c8f2930c3e1ffb64ede0358",990                ),991                (992                    "4361726c204d656e676572",993                    "30ebbd9cdcef7f40d99c8cd11e880dac28f5c9e5032c1059b3b56a",994                ),995            ],996        );997    }998999    #[test]1000    fn rfc_8439_chacha20_poly1305_aead_vector_matches_snow_cipher() {1001        let key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f");1002        let nonce = hex("070000004041424344454647");1003        let aad = hex("50515253c0c1c2c3c4c5c6c7");1004        let plaintext = hex(concat!(1005            "4c616469657320616e642047656e746c656d656e206f662074686520636c6173",1006            "73206f66202739393a204966204920636f756c64206f6666657220796f75206f",1007            "6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73",1008            "637265656e20776f756c642062652069742e",1009        ));1010        let expected = hex(concat!(1011            "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d6",1012            "3dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b36",1013            "92ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc",1014            "3ff4def08e4b7a9de576d26586cec64b6116",1015            "1ae10b594f09e26a7e902ecbd0600691",1016        ));1017        let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap();1018        assert_eq!(1019            cipher1020                .encrypt(1021                    Nonce::from_slice(&nonce),1022                    Payload {1023                        msg: &plaintext,1024                        aad: &aad,1025                    },1026                )1027                .unwrap(),1028            expected,1029        );1030    }10311032    // PB-26-608: `require_matching_keypair` copies the private key onto the1033    // stack. Rust does not scrub a moved-from `[u8; 32]`, and stack residue1034    // cannot be observed portably, so this pins the wrapper instead.1035    #[test]1036    fn pb_26_608_private_key_copy_is_zeroized() {1037        let source = include_str!("lib.rs");1038        let body = source1039            .split("fn require_matching_keypair")1040            .nth(1)1041            .expect("require_matching_keypair is present");1042        assert!(1043            body.contains("Zeroizing::new"),1044            "the private-key copy must be Zeroizing-wrapped"1045        );1046    }10471048    #[test]1049    fn blake2_reference_keyed_vector_matches_snow_hash_dependency() {1050        let key: Vec<u8> = (0_u8..32).collect();1051        let mut mac = <Blake2sMac256 as BlakeKeyInit>::new_from_slice(&key).unwrap();1052        Mac::update(&mut mac, &[]);1053        assert_eq!(1054            Mac::finalize(mac).into_bytes().as_slice(),1055            &hex("48a8997da407876b3d79c0d92325ad3b89cbb754d86ab71aee047ad345fd2c49"),1056        );1057    }1058}