From e332f7421466eb9986422692c038d884db22f8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 23 Sep 2025 09:42:37 +0200 Subject: [PATCH 01/18] feat: Add support for HPKE --- Cargo.toml | 1 + src/hpke/check_code.rs | 116 +++++ src/hpke/error.rs | 50 ++ src/hpke/messages.rs | 150 ++++++ src/hpke/mod.rs | 448 ++++++++++++++++++ src/hpke/recipient.rs | 257 ++++++++++ src/hpke/response_context.rs | 135 ++++++ src/hpke/sender.rs | 224 +++++++++ ...pke__recipient__tests__snapshot_debug.snap | 9 + ...apshot_debug_unidirectional_channel-2.snap | 10 + ...snapshot_debug_unidirectional_channel.snap | 9 + ...__hpke__sender__tests__snapshot_debug.snap | 7 + ...snapshot_debug_unidirectional_channel.snap | 10 + ...odozemac__hpke__tests__snapshot_debug.snap | 15 + src/lib.rs | 1 + src/types/curve25519.rs | 4 + 16 files changed, 1446 insertions(+) create mode 100644 src/hpke/check_code.rs create mode 100644 src/hpke/error.rs create mode 100644 src/hpke/messages.rs create mode 100644 src/hpke/mod.rs create mode 100644 src/hpke/recipient.rs create mode 100644 src/hpke/response_context.rs create mode 100644 src/hpke/sender.rs create mode 100644 src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug.snap create mode 100644 src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel-2.snap create mode 100644 src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel.snap create mode 100644 src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug.snap create mode 100644 src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug_unidirectional_channel.snap create mode 100644 src/hpke/snapshots/vodozemac__hpke__tests__snapshot_debug.snap diff --git a/Cargo.toml b/Cargo.toml index c39d42c2e..1537a3ce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ ed25519-dalek = { version = "3.0.0-pre.4", default-features = false, features = getrandom = "0.3.4" hkdf = "0.13.0-rc.3" hmac = "0.13.0-rc.3" +hpke = { git = "https://github.com/rozbb/rust-hpke/", rev = "ba75d11", features = ["alloc", "x25519", "hazmat-streaming-enc"] } matrix-pickle = { version = "0.2.2" } prost = "0.14.3" rand = "0.10.0-rc.6" diff --git a/src/hpke/check_code.rs b/src/hpke/check_code.rs new file mode 100644 index 000000000..1493b0d44 --- /dev/null +++ b/src/hpke/check_code.rs @@ -0,0 +1,116 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// A check code that can be used to confirm that two [`EstablishedHpkeChannel`] +/// objects share the same secret. This is supposed to be shared out-of-band to +/// protect against active MITM attacks. +/// +/// Since the initiator device can always tell whether a MITM attack is in +/// progress after channel establishment, this code technically carries only a +/// single bit of information, representing whether the initiator has determined +/// that the channel is "secure" or "not secure". +/// +/// However, given this will need to be interactively confirmed by the user, +/// there is risk that the user would confirm the dialogue without paying +/// attention to its content. By expanding this single bit into a deterministic +/// two-digit check code, the user is forced to pay more attention by having to +/// enter it instead of just clicking through a dialogue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckCode { + pub(super) bytes: [u8; 2], +} + +impl CheckCode { + /// Convert the check code to an array of two bytes. + /// + /// The bytes can be converted to a more user-friendly representation. The + /// [`CheckCode::to_digit`] converts the bytes to a two-digit number. + pub const fn as_bytes(&self) -> &[u8; 2] { + &self.bytes + } + + /// Convert the check code to two base-10 numbers. + /// + /// The number should be displayed with a leading 0 in case the first digit + /// is a 0. + /// + /// # Examples + /// + /// ```no_run + /// # use vodozemac::hpke::CheckCode; + /// # let check_code: CheckCode = unimplemented!(); + /// let check_code = check_code.to_digit(); + /// + /// println!("The check code of the HPKE channel is: {check_code:02}"); + /// ``` + pub const fn to_digit(&self) -> u8 { + let first = (self.bytes[0] % 10) * 10; + let second = self.bytes[1] % 10; + + first + second + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + #[test] + fn check_code() { + let check_code = CheckCode { bytes: [0x0, 0x0] }; + let digit = check_code.to_digit(); + assert_eq!(digit, 0, "Two zero bytes should generate a 0 digit"); + assert_eq!( + check_code.as_bytes(), + &[0x0, 0x0], + "CheckCode::as_bytes() should return the exact bytes we generated." + ); + + let check_code = CheckCode { bytes: [0x9, 0x9] }; + let digit = check_code.to_digit(); + assert_eq!( + check_code.as_bytes(), + &[0x9, 0x9], + "CheckCode::as_bytes() should return the exact bytes we generated." + ); + assert_eq!(digit, 99); + + let check_code = CheckCode { bytes: [0xff, 0xff] }; + let digit = check_code.to_digit(); + assert_eq!( + check_code.as_bytes(), + &[0xff, 0xff], + "CheckCode::as_bytes() should return the exact bytes we generated." + ); + assert_eq!(digit, 55, "u8::MAX should generate 55"); + } + + proptest! { + #[test] + fn check_code_proptest(bytes in prop::array::uniform2(0u8..) ) { + let check_code = CheckCode { + bytes + }; + + let digit = check_code.to_digit(); + + prop_assert!( + (0..=99).contains(&digit), + "The digit should be in the 0-99 range" + ); + } + } +} diff --git a/src/hpke/error.rs b/src/hpke/error.rs new file mode 100644 index 000000000..99620968b --- /dev/null +++ b/src/hpke/error.rs @@ -0,0 +1,50 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use thiserror::Error; + +use crate::KeyError; + +/// The error type for the HPKE message decoding failures. +#[derive(Debug, Error)] +pub enum MessageDecodeError { + /// The initial message could not have been decoded, it's missing the `|` + /// separator. + #[error("The initial message is missing the | separator")] + MissingSeparator, + /// The initial message could not have been decoded, the embedded Curve25519 + /// key is malformed. + #[error("The embedded ephemeral Curve25519 key could not have been decoded: {0:?}")] + KeyError(#[from] KeyError), + /// The ciphertext is not valid base64. + #[error("The ciphertext could not have been decoded from a base64 string: {0:?}")] + Base64(#[from] base64::DecodeError), +} + +/// The Error type for the HPKE submodule. +#[derive(Debug, Error)] +pub enum Error { + /// The initial response contained a nonce with an incorrect length. + #[error("The base response nonce has an incorrect length, expected {expected}, got {got}")] + InvalidNonce { + /// The expected Nonce size. + expected: usize, + /// The size of the nonce we received in the message. + got: usize, + }, + /// Message decryption failed. Either the message was corrupted, the message + /// was replayed, or the wrong key is being used to decrypt the message. + #[error("Failed decrypting the message")] + Decryption, +} diff --git a/src/hpke/messages.rs b/src/hpke/messages.rs new file mode 100644 index 000000000..71058dd38 --- /dev/null +++ b/src/hpke/messages.rs @@ -0,0 +1,150 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[cfg(doc)] +use super::EstablishedHpkeChannel; +use crate::{Curve25519PublicKey, base64_decode, base64_encode, hpke::error::MessageDecodeError}; + +/// The initial message, sent by the HPKE channel sender. +/// +/// This message embeds the public key of the message creator allowing the other +/// side to establish a channel using this message. +/// +/// This key is *unauthenticated* so authentication needs to happen out-of-band +/// in order for the established channel to become secure. +#[derive(Debug, PartialEq, Eq)] +pub struct InitialMessage { + /// The ephemeral public key that was used to establish the HPKE channel. + pub encapsulated_key: Curve25519PublicKey, + /// The ciphertext of the initial message. + pub ciphertext: Vec, +} + +impl InitialMessage { + /// Encode the message as a string. + /// + /// The string will contain the base64-encoded Curve25519 public key and the + /// ciphertext of the message separated by a `|`. + pub fn encode(&self) -> String { + let ciphertext = base64_encode(&self.ciphertext); + let key = self.encapsulated_key.to_base64(); + + format!("{ciphertext}|{key}") + } + + /// Attempt do decode a string into a [`InitialMessage`]. + pub fn decode(message: &str) -> Result { + match message.split_once('|') { + Some((ciphertext, key)) => { + let encapsulated_key = Curve25519PublicKey::from_base64(key)?; + let ciphertext = base64_decode(ciphertext)?; + + Ok(Self { ciphertext, encapsulated_key }) + } + None => Err(MessageDecodeError::MissingSeparator), + } + } +} + +/// The initial response, sent by the HPKE channel receiver. +/// +/// This message embeds a random base nonce which the other side can use to +/// establish bidirectional communication over a HPKE channel. +#[derive(Debug)] +pub struct InitialResponse { + /// The randomly generated base response nonce. + pub base_response_nonce: Vec, + /// The ciphertext of the initial message. + pub ciphertext: Vec, +} + +impl InitialResponse { + /// Encode the message as a string. + /// + /// The string will contain the nonce and ciphertext concatenated together + /// and encoded using unpadded base64. + pub fn encode(&self) -> String { + let ciphertext = base64_encode(&self.ciphertext); + let base_response_nonce = base64_encode(&self.base_response_nonce); + + format!("{base_response_nonce}|{ciphertext}") + } + + /// Attempt do decode a string into a [`InitialResponse`]. + pub fn decode(message: &str) -> Result { + match message.split_once('|') { + Some((base_response_nonce, ciphertext)) => { + let base_response_nonce = base64_decode(base_response_nonce)?; + let ciphertext = base64_decode(ciphertext)?; + + Ok(Self { ciphertext, base_response_nonce }) + } + None => Err(MessageDecodeError::MissingSeparator), + } + } +} + +/// An encrypted message a [`EstablishedHpkeChannel`] channel has sent. +#[derive(Debug)] +pub struct Message { + /// The ciphertext of the message. + pub ciphertext: Vec, +} + +impl Message { + /// Encode the message as a string. + /// + /// The ciphertext bytes will be encoded using unpadded base64. + pub fn encode(&self) -> String { + base64_encode(&self.ciphertext) + } + + /// Attempt do decode a base64 string into a [`Message`]. + pub fn decode(message: &str) -> Result { + Ok(Self { ciphertext: base64_decode(message)? }) + } +} + +#[cfg(test)] +mod test { + use super::*; + + const INITIAL_MESSAGE: &str = "3On7QFJyLQMAErua9K/yIOcJALvuMYax1AW0iWgf64AwtSMZXwAA012Q|9yA/CX8pJKF02Prd75ZyBQHg3fGTVVGDNl86q1z17Us"; + const MESSAGE: &str = "ZmtSLdzMcyjC5eV6L8xBI6amsq7gDNbCjz1W5OjX4Z8W"; + const PUBLIC_KEY: &str = "9yA/CX8pJKF02Prd75ZyBQHg3fGTVVGDNl86q1z17Us"; + + #[test] + fn initial_message() { + let message = InitialMessage::decode(INITIAL_MESSAGE) + .expect("We should be able to decode our known-valid initial message"); + + assert_eq!( + message.encapsulated_key.to_base64(), + PUBLIC_KEY, + "The decoded public key should match the expected one" + ); + + let encoded = message.encode(); + assert_eq!(INITIAL_MESSAGE, encoded); + } + + #[test] + fn message() { + let message = Message::decode(MESSAGE) + .expect("We should be able to decode our known-valid initial message"); + + let encoded = message.encode(); + assert_eq!(MESSAGE, encoded); + } +} diff --git a/src/hpke/mod.rs b/src/hpke/mod.rs new file mode 100644 index 000000000..5da2352b0 --- /dev/null +++ b/src/hpke/mod.rs @@ -0,0 +1,448 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Implementation of a [hybrid public encryption scheme]. +//! +//! [hybrid public encryption scheme]: https://www.rfc-editor.org/rfc/rfc9180.html +//! +//! # Examples +//! +//! ``` +//! use vodozemac::hpke::*; +//! +//! let plaintext = b"It's a secret to everybody"; +//! +//! let alice = HpkeSenderChannel::new(); +//! let bob = HpkeRecipientChannel::new(); +//! +//! let SenderCreationResult { channel: mut alice, message } = alice +//! .establish_channel(bob.public_key(), plaintext, &[]); +//! +//! let RecipientCreationResult { channel: mut bob, message } = bob.establish_channel(&message, &[])?; +//! +//! assert_eq!( +//! message, plaintext, +//! "The decrypted plaintext should match our initial plaintext" +//! ); +//! +//! // Now we need to establish communication in the other direction, Bob +//! // needs to encrypt and send an initial reply to Alice. +//! +//! let plaintext = b"Not a secret to me!"; +//! +//! let BidiereactionalCreationResult { channel: mut bob, message } = bob.establish_bidirectional_channel(plaintext, &[]); +//! let BidiereactionalCreationResult { channel: mut alice, message} = alice.establish_bidirectional_channel(&message, &[])?; +//! +//! assert_eq!(message, plaintext); +//! +//! // We now exchange the check code out-of-band and compare it. +//! if alice.check_code() != bob.check_code() { +//! panic!("The check code must match; possible active MITM attack in progress"); +//! } +//! +//! let message = bob.seal(b"Another plaintext", &[]); +//! let decrypted = alice.open(&message, &[])?; +//! +//! assert_eq!(decrypted, b"Another plaintext"); +//! # Ok::<(), anyhow::Error>(()) +//! ``` + +mod check_code; +mod error; +mod messages; +mod recipient; +mod response_context; +mod sender; + +pub use check_code::*; +pub use error::*; +use hpke::{ + aead::{AeadCtxR, AeadCtxS, ChaCha20Poly1305}, + kdf::HkdfSha256, + kem::X25519HkdfSha256, +}; +pub use messages::*; +pub use recipient::*; +use response_context::CreateResponseContext; +pub use sender::*; + +use crate::Curve25519PublicKey; + +const MATRIX_QR_LOGIN_INFO_PREFIX: &str = "MATRIX_QR_CODE_LOGIN"; + +type Kem = X25519HkdfSha256; +type Aead = ChaCha20Poly1305; +type Kdf = HkdfSha256; + +type SenderContext = AeadCtxS; +type RecipientContext = AeadCtxR; +type SenderResponseContext = AeadCtxR; +type RecipientResponseContext = AeadCtxS; + +/// The possible device roles for an HPKE channel, indicating whether the +/// device is initiating the channel or receiving/responding as the other side +/// of the initiation. +enum Role { + /// The role representing the side that sent the initial message. + Sender { + /// The established HPKE sender context. + context: SenderContext, + /// The HPKE response context enabling the sender to receive messages + /// from the recipient. + response_context: SenderResponseContext, + }, + /// The role representing the side that received the initial message and + /// sent the initial response. + Recipient { + /// The established HPKE recipient context. + context: RecipientContext, + /// The HPKE response context enabling the recipient to send messages + /// to the sender. + response_context: RecipientResponseContext, + }, +} + +impl std::fmt::Debug for Role { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Role::Sender { .. } => f.write_str("Sender"), + Role::Recipient { .. } => f.write_str("Recipient"), + } + } +} + +impl Role { + fn construct_info_string( + &self, + partial_info: &str, + our_public_key: Curve25519PublicKey, + their_public_key: Curve25519PublicKey, + ) -> String { + match self { + Role::Recipient { .. } => { + // we are Device G. Gp = our_public_key, Sp = their_public_key + format!( + "{partial_info}|{}|{}", + our_public_key.to_base64(), + their_public_key.to_base64(), + ) + } + Role::Sender { .. } => { + // we are Device S. Gp = their_public_key, Sp = our_public_key + format!( + "{partial_info}|{}|{}", + their_public_key.to_base64(), + our_public_key.to_base64(), + ) + } + } + } + + fn check_code_info( + &self, + app_info: &str, + our_public_key: Curve25519PublicKey, + their_public_key: Curve25519PublicKey, + ) -> String { + let partial_info = format!("{app_info}_CHECKCODE"); + self.construct_info_string(&partial_info, our_public_key, their_public_key) + } + + fn check_code( + &self, + app_info: &str, + our_public_key: Curve25519PublicKey, + their_public_key: Curve25519PublicKey, + ) -> CheckCode { + let mut bytes = [0u8; 2]; + let info = self.check_code_info(app_info, our_public_key, their_public_key); + + let ret = match self { + Role::Sender { context, .. } => context.export(info.as_bytes(), &mut bytes), + Role::Recipient { context, .. } => context.export(info.as_bytes(), &mut bytes), + }; + + #[allow(clippy::expect_used)] + ret.expect("We should be able to generate a check code, as it's just two bytes"); + + CheckCode { bytes } + } +} + +struct UnidirectionalHkpeChannel { + /// The established HPKE context. + context: T, + + /// The application prefix which will be used as the info string to derive + /// secrets. + application_info_prefix: String, + + /// Our own Curve25519 public key which was used to establish the HPKE + /// channel. + our_public_key: Curve25519PublicKey, + + /// The other side's Curve25519 public key which was used to establish the + /// HPKE channel. + their_public_key: Curve25519PublicKey, +} + +/// The result of the creation of a bidirectional and fully established HPKE +/// channel. +pub struct BidiereactionalCreationResult { + /// The established HPKE channel. + pub channel: EstablishedHpkeChannel, + /// The plaintext of the initial message. + pub message: T, +} + +/// A fully established HPKE channel. +/// +/// This channel allows full bidirecional communication with the other side. +pub struct EstablishedHpkeChannel { + /// Our own Curve25519 public key which was used to establish the HPKE + /// channel. + our_public_key: Curve25519PublicKey, + + /// The other side's Curve25519 public key which was used to establish the + /// HPKE channel. + their_public_key: Curve25519PublicKey, + + /// Our device's role in the HPKE channel, i.e. are we the initiator + /// (device S) or the recipient (device G)? + role: Role, + + /// The check code, generated on both devices and shared out-of-band, which + /// needs to match to ensure both sides are using the same secret. + check_code: CheckCode, +} + +impl std::fmt::Debug for EstablishedHpkeChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EstablishedHpkeChannel") + .field("our_public_key", &self.our_public_key) + .field("their_public_key", &self.their_public_key) + .field("check_code", &self.check_code) + .field("role", &self.role) + .finish() + } +} + +impl EstablishedHpkeChannel { + /// Get our [`Curve25519PublicKey`]. + /// + /// This public key needs to be sent to the other side so that it can + /// complete the HPKE channel establishment. + pub const fn public_key(&self) -> Curve25519PublicKey { + self.our_public_key + } + + /// Get the [`Curve25519PublicKey`] of the other participant in this + /// [`EstablishedHpkeChannel`]. + pub const fn their_public_key(&self) -> Curve25519PublicKey { + self.their_public_key + } + + /// Get the [`CheckCode`] which uniquely identifies this + /// [`EstablishedHpkeChannel`] session. + /// + /// This check code can be used to check that both sides of the session are + /// indeed using the same shared secret. + pub fn check_code(&self) -> &CheckCode { + &self.check_code + } + + /// Seal the given plaintext using the associated data and this + /// [`EstablishedHpkeChannel`]. + /// + /// This method will encrypt the given plaintext for the other side of this + /// [`EstablishedHpkeChannel`]. + /// + /// # Panics + /// + /// If the message limit is reached. It's possible to seal 2^64 messages + /// with this channel. The other reason why this might panic if the + /// additional associated data is too big, it has to be shorter than + /// 2^64 bytes. + pub fn seal(&mut self, plaintext: &[u8], aad: &[u8]) -> Message { + let ret = match &mut self.role { + Role::Sender { context, .. } => context.seal(plaintext, aad), + Role::Recipient { response_context, .. } => response_context.seal(plaintext, aad), + }; + + #[allow(clippy::expect_used)] + let ciphertext = ret.expect( + "We should be able to seal a plaintext, unless we're overflowed the sequence counter", + ); + + Message { ciphertext } + } + + /// Open the given message with the given additional associated data using + /// this [`EstablishedHpkeChannel`]. + /// + /// This method will decrypt the given message which was encrypted using a + /// matching [`EstablishedHpkeChannel`]. + pub fn open(&mut self, message: &Message, aad: &[u8]) -> Result, Error> { + let ret = match &mut self.role { + Role::Sender { response_context, .. } => { + response_context.open(&message.ciphertext, aad) + } + Role::Recipient { context, .. } => context.open(&message.ciphertext, aad), + }; + + ret.map_err(|_| Error::Decryption) + } +} + +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + + use super::*; + use crate::Curve25519SecretKey; + + #[test] + fn test_channel_creation() { + let alice = HpkeSenderChannel::new(); + let bob = HpkeRecipientChannel::new(); + + let plaintext = b"It's a secret to everybody"; + + let SenderCreationResult { message, .. } = + alice.establish_channel(bob.public_key(), plaintext, &[]); + + assert_ne!(message.ciphertext, plaintext); + + let RecipientCreationResult { message, .. } = bob + .establish_channel(&message, &[]) + .expect("We should be able to establish the recipient channel"); + + assert_eq!(message, plaintext); + } + + #[test] + fn test_channel_roundtrip() { + let alice = HpkeSenderChannel::new(); + let bob = HpkeRecipientChannel::new(); + + let plaintext = b"It's a secret to everybody"; + + let SenderCreationResult { channel: alice, message, .. } = + alice.establish_channel(bob.public_key(), plaintext, &[]); + + assert_ne!(message.ciphertext, plaintext); + + let RecipientCreationResult { channel: bob, message } = bob + .establish_channel(&message, &[]) + .expect("We should be able to establish the recipient channel"); + + assert_eq!(message, plaintext); + + let plaintext = b"Not a secret to me!"; + + let BidiereactionalCreationResult { message: initial_response, .. } = + bob.establish_bidirectional_channel(plaintext, &[]); + assert_ne!(initial_response.ciphertext, plaintext); + + let BidiereactionalCreationResult { message: decrypted, .. } = alice + .establish_bidirectional_channel(&initial_response, &[]) + .expect("We should be able to decrypt the initial response"); + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn invalid_public_key() { + let plaintext = b"It's a secret to everybody"; + + let alice = HpkeSenderChannel::new(); + let bob = HpkeRecipientChannel::new(); + let malory = Curve25519SecretKey::new(); + + let SenderCreationResult { mut message, .. } = + alice.establish_channel(bob.public_key(), plaintext, &[]); + + message.encapsulated_key = Curve25519PublicKey::from(&malory); + + bob.establish_channel(&message, &[]).expect_err( + "The decryption should fail since Malory inserted the \ + wrong public key into the message", + ); + } + + #[test] + fn test_info_construction() { + use crate::types::Curve25519Keypair; + + let app_info = "foobar"; + let our_public_key = Curve25519Keypair::new().public_key; + let their_public_key = Curve25519Keypair::new().public_key; + + let alice = HpkeSenderChannel::new(); + let bob = HpkeRecipientChannel::new(); + + let SenderCreationResult { channel: alice, message } = + alice.establish_channel(bob.public_key(), b"", &[]); + + let RecipientCreationResult { channel: bob, message: _ } = bob + .establish_channel(&message, &[]) + .expect("We should be able to establish the recipient channel"); + + let BidiereactionalCreationResult { channel: bob, message: initial_response } = + bob.establish_bidirectional_channel(b"My response", &[]); + + let BidiereactionalCreationResult { channel: alice, .. } = alice + .establish_bidirectional_channel(&initial_response, &[]) + .expect("We should be able to establish the bidirectional channel for Alice"); + + let check_code_info1 = + alice.role.check_code_info(app_info, our_public_key, their_public_key); + assert_eq!( + check_code_info1, + format!("foobar_CHECKCODE|{their_public_key}|{our_public_key}") + ); + + let check_code_info2 = bob.role.check_code_info(app_info, our_public_key, their_public_key); + assert_eq!( + check_code_info2, + format!("foobar_CHECKCODE|{our_public_key}|{their_public_key}") + ); + } + + #[test] + fn snapshot_debug() { + let key = Curve25519PublicKey::from_bytes([0; 32]); + + let alice = HpkeSenderChannel::new(); + let bob = HpkeRecipientChannel::new(); + + let SenderCreationResult { channel: alice, message } = + alice.establish_channel(bob.public_key(), b"", &[]); + + let RecipientCreationResult { channel: bob, .. } = + bob.establish_channel(&message, &[]).unwrap(); + + let BidiereactionalCreationResult { message, .. } = + bob.establish_bidirectional_channel(b"", &[]); + + let BidiereactionalCreationResult { mut channel, .. } = + alice.establish_bidirectional_channel(&message, &[]).unwrap(); + + channel.our_public_key = key; + channel.their_public_key = key; + channel.check_code = CheckCode { bytes: [0, 1] }; + + assert_debug_snapshot!(channel); + } +} diff --git a/src/hpke/recipient.rs b/src/hpke/recipient.rs new file mode 100644 index 000000000..403acfd64 --- /dev/null +++ b/src/hpke/recipient.rs @@ -0,0 +1,257 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use cipher::{Array, consts::U32, crypto_common::Generate}; +use hpke::{Deserializable as _, aead::AeadCtxR, kem::X25519HkdfSha256}; + +use crate::{ + Curve25519PublicKey, Curve25519SecretKey, + hpke::{ + Aead, BidiereactionalCreationResult, CreateResponseContext, Error, EstablishedHpkeChannel, + InitialMessage, InitialResponse, Kdf, Kem, MATRIX_QR_LOGIN_INFO_PREFIX, RecipientContext, + Role, UnidirectionalHkpeChannel, + }, +}; + +/// The result type for the initial establishment of a unidirectional HPKE +/// channel. +#[derive(Debug)] +pub struct RecipientCreationResult { + /// The established unidirectional HPKE recipient channel. + pub channel: UnidirectionalRecipientChannel, + /// The plaintext of the initial message. + pub message: Vec, +} + +/// The unestablished HPKE recipient channel. +pub struct HpkeRecipientChannel { + /// The secret key which will be used to establish a shared secret between + /// the recipient and sender. + secret_key: Curve25519SecretKey, + + /// The application prefix which will be used as the info string to derive + /// secrets. + application_info_prefix: String, +} + +impl std::fmt::Debug for HpkeRecipientChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let public_key = Curve25519PublicKey::from(&self.secret_key); + + f.debug_struct("HpkeRecipientChannel") + .field("our_public_key", &public_key) + .field("application_info_prefix", &self.application_info_prefix) + .finish_non_exhaustive() + } +} + +impl HpkeRecipientChannel { + /// Create a new, random, unestablished HPKE session. + /// + /// This method will use the `MATRIX_QR_CODE_LOGIN` info. If you are using + /// this for a different purpose, consider using the + /// [`HpkeRecipientChannel::with_info()`] method. + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self::with_info(MATRIX_QR_LOGIN_INFO_PREFIX) + } + + /// Create a new, random, unestablished HPKE channel with the given + /// application info. + /// + /// The application info will be used to derive the various secrets and + /// provide domain separation. + pub fn with_info(info: &str) -> Self { + Self { secret_key: Curve25519SecretKey::new(), application_info_prefix: info.to_owned() } + } + + /// Create a [`EstablishedHpkeChannel`] from an [`InitialMessage`] encrypted + /// by the other side. + pub fn establish_channel( + self, + message: &InitialMessage, + aad: &[u8], + ) -> Result { + let Self { secret_key, application_info_prefix } = self; + let InitialMessage { encapsulated_key, ciphertext } = message; + + let their_public_key = *encapsulated_key; + let our_public_key = Curve25519PublicKey::from(&secret_key); + + let secret_key = convert_secret_key(&secret_key); + let encapsulated_key = convert_encapsulated_key(encapsulated_key); + + let mut context: RecipientContext = hpke::setup_receiver( + &hpke::OpModeR::Base, + &secret_key, + &encapsulated_key, + application_info_prefix.as_bytes(), + ) + .map_err(|_| Error::Decryption)?; + + let message = context.open(ciphertext, aad).map_err(|_| Error::Decryption)?; + + let channel = UnidirectionalRecipientChannel(UnidirectionalHkpeChannel { + application_info_prefix, + context, + their_public_key, + our_public_key, + }); + + Ok(RecipientCreationResult { channel, message }) + } + + /// Get our [`Curve25519PublicKey`]. + /// + /// This public key needs to be sent to the other side to be able to + /// establish an HPKE channel. + pub fn public_key(&self) -> Curve25519PublicKey { + Curve25519PublicKey::from(&self.secret_key) + } +} + +// Convert our Curve25519 secret key type into the PrivateKey type the HPKE +// crate expects. +// +// Underneath those types are the same but we are forced to go through the byte +// interface due to the HPKE crate not exposing methods to do direct +// conversions. +fn convert_secret_key( + secret_key: &Curve25519SecretKey, +) -> ::PrivateKey { + #[allow(clippy::expect_used)] + ::PrivateKey::from_bytes(secret_key.as_bytes()).expect( + "Converting from our PrivateKey type to the HPKE private key type should never fail", + ) +} + +/// Same as [`convert_secret_key()`] just for the encapsulated key. +fn convert_encapsulated_key( + public_key: &Curve25519PublicKey, +) -> ::EncappedKey { + #[allow(clippy::expect_used)] + ::EncappedKey::from_bytes(public_key.as_bytes()) + .expect("Converting to the HPKE EncappedKey type should never fail") +} + +/// The unidirectional HPKE sender channel. +/// +/// This channel is created when we open the initial message. It allows us to +/// seal the initial response at which point the channel gets transformed into a +/// fully established and bidirectional HPKE channel. +pub struct UnidirectionalRecipientChannel(UnidirectionalHkpeChannel>); + +impl std::fmt::Debug for UnidirectionalRecipientChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UnidirectionalRecipientChannel") + .field("application_info_prefix", &self.0.application_info_prefix) + .field("their_public_key", &self.0.their_public_key) + .field("our_public_key", &self.0.our_public_key) + .finish_non_exhaustive() + } +} + +impl UnidirectionalRecipientChannel { + /// Seal the given plaintext using the associated data and this + /// [`UnidirectionalRecipientChannel`]. + /// + /// This method will encrypt the given plaintext for the other side of this + /// channel and fully establish the HPKE channel, enabling bidirectional + /// communication. + /// + /// # Panics + /// + /// If the additional associated data is too big, it has to be shorter than + /// 2^64 bytes. + pub fn establish_bidirectional_channel( + self, + plaintext: &[u8], + aad: &[u8], + ) -> BidiereactionalCreationResult { + let Self(UnidirectionalHkpeChannel { + context, + their_public_key, + our_public_key, + application_info_prefix, + }) = self; + + let base_response_nonce = Array::::generate(); + + let mut response_context = context.create_response_context( + &application_info_prefix, + their_public_key, + &base_response_nonce, + ); + + #[allow(clippy::expect_used)] + let ciphertext = response_context + .seal(plaintext, aad) + .expect("We should be able to seal the initial response"); + + let role = Role::Recipient { context, response_context }; + let check_code = + role.check_code(&application_info_prefix, our_public_key, their_public_key); + + let channel = EstablishedHpkeChannel { our_public_key, their_public_key, role, check_code }; + + BidiereactionalCreationResult { + channel, + message: InitialResponse { + ciphertext, + base_response_nonce: base_response_nonce.to_vec(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + + use super::*; + use crate::hpke::{HpkeSenderChannel, SenderCreationResult}; + + #[test] + fn snapshot_debug() { + let key = Curve25519SecretKey::from_slice(&[0; 32]); + let mut bob = HpkeRecipientChannel::new(); + bob.secret_key = key; + + assert_debug_snapshot!(bob); + } + + #[test] + fn snapshot_debug_unidirectional_channel() { + let key = Curve25519SecretKey::from_slice(&[0; 32]); + + let alice = HpkeSenderChannel::new(); + let mut bob = HpkeRecipientChannel::new(); + bob.secret_key = key; + + assert_debug_snapshot!(bob); + + let SenderCreationResult { message, .. } = + alice.establish_channel(bob.public_key(), b"", &[]); + + let RecipientCreationResult { channel: mut bob, .. } = + bob.establish_channel(&message, &[]).unwrap(); + + let key = Curve25519PublicKey::from_bytes([0; 32]); + + bob.0.our_public_key = key; + bob.0.their_public_key = key; + + assert_debug_snapshot!(bob); + } +} diff --git a/src/hpke/response_context.rs b/src/hpke/response_context.rs new file mode 100644 index 000000000..3310c68d6 --- /dev/null +++ b/src/hpke/response_context.rs @@ -0,0 +1,135 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Support for bidirectional communication in a HPKE channel. +//! +//! The scheme implemented here is described in the oblivous HTTP RFC in +//! [section 4.4]. The one difference from the oblivious HTTP RFC is that we +//! construct a HPKE context from the key and nonce we derive using the oblivous +//! HTTP scheme instead of directly encrypting a message. +//! +//! [section 4.4]: https://www.rfc-editor.org/rfc/rfc9458#name-encapsulation-of-responses + +use hkdf::Hkdf; +use hpke::{ + HpkeError, + aead::{AeadCtxR, AeadCtxS, AeadKey, AeadNonce}, + streaming_enc::{ExporterSecret, create_receiver_context, create_sender_context}, +}; +use sha2::Sha256; +use zeroize::Zeroize; + +use super::{Aead, Kdf, Kem}; +use crate::Curve25519PublicKey; + +pub(super) trait CreateResponseContext { + type ResponseContext; + + fn export(&self, info: &[u8], output: &mut [u8]) -> Result<(), HpkeError>; + + fn create_context( + &self, + response_key: &AeadKey, + response_nonce: AeadNonce, + ) -> Self::ResponseContext; + + fn create_response_context( + &self, + application_info_prefix: &str, + encapsulated_key: Curve25519PublicKey, + response_nonce: &[u8], + ) -> Self::ResponseContext { + let mut secret = [0u8; 32]; + + // Export a secret from the HPKE context, we use our application info prefix and + // append "_RESPONSE" to it. + let info = format!("{application_info_prefix}_RESPONSE"); + + #[allow(clippy::expect_used)] + self.export(info.as_bytes(), &mut secret) + .expect("We should be able to export 32 bytes from the HPKE export interface"); + + // For the salt we concatenate the public key of the sender and the randomly + // generated response nonce. + let salt: Vec = [encapsulated_key.as_bytes().as_slice(), response_nonce].concat(); + + // Now create a KDF from the salt and the previously secret exported from the + // HPKE context. + let hkdf = Hkdf::::new(Some(&salt), &secret); + + // From the KDF expand an AEAD key and nonce. + let mut aead_key = AeadKey::default(); + let mut aead_nonce = AeadNonce::default(); + + #[allow(clippy::expect_used)] + hkdf.expand(b"key", aead_key.0.as_mut_slice()) + .expect("We should be able to expand the base response secret into a AEAD key"); + + #[allow(clippy::expect_used)] + hkdf.expand(b"nonce", aead_nonce.0.as_mut_slice()) + .expect("We should be able to expand the base response secret into a response nonce"); + + // Check that our key and nonce aren't just zeroes, this is only checked in + // debug builds. + debug_assert_ne!(aead_nonce.0.as_slice(), [0u8; 12]); + debug_assert_ne!(aead_key.0.as_slice(), [0u8; 32]); + + // Let's get rid of the secret. + secret.zeroize(); + + // Now create a HPKE context which can be used to communicate in the other + // direction. + self.create_context(&aead_key, aead_nonce) + } +} + +impl CreateResponseContext for AeadCtxS { + type ResponseContext = AeadCtxR; + + fn export(&self, info: &[u8], output: &mut [u8]) -> Result<(), HpkeError> { + self.export(info, output) + } + + fn create_context( + &self, + response_key: &AeadKey, + response_nonce: AeadNonce, + ) -> Self::ResponseContext { + // We create an default, all zeroes exporter secret as the HPKE + // `create_ROLE_context()` methods require it, but we never use the + // export interface of this HPKE context. + let exporter_secret = ExporterSecret::default(); + create_receiver_context(response_key, response_nonce, exporter_secret) + } +} + +impl CreateResponseContext for AeadCtxR { + type ResponseContext = AeadCtxS; + + fn export(&self, info: &[u8], output: &mut [u8]) -> Result<(), HpkeError> { + self.export(info, output) + } + + fn create_context( + &self, + response_key: &AeadKey, + response_nonce: AeadNonce, + ) -> Self::ResponseContext { + // We create an default, all zeroes exporter secret as the HPKE + // `create_ROLE_context()` methods require it, but we never use the + // export interface of this HPKE context. + let exporter_secret = ExporterSecret::default(); + create_sender_context(response_key, response_nonce, exporter_secret) + } +} diff --git a/src/hpke/sender.rs b/src/hpke/sender.rs new file mode 100644 index 000000000..0e12ef1ad --- /dev/null +++ b/src/hpke/sender.rs @@ -0,0 +1,224 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use hpke::{ + Deserializable as _, OpModeS, Serializable as _, aead::AeadCtxS, kem::X25519HkdfSha256, +}; +use rand::rng; + +use crate::{ + Curve25519PublicKey, + hpke::{ + Aead, BidiereactionalCreationResult, CreateResponseContext, Error, EstablishedHpkeChannel, + InitialMessage, InitialResponse, Kdf, Kem, MATRIX_QR_LOGIN_INFO_PREFIX, Role, + UnidirectionalHkpeChannel, + }, +}; + +/// The result type for the initial establishment of a unidirectional HPKE +/// channel. +pub struct SenderCreationResult { + /// The established unidirectional HPKE sender channel. + pub channel: UnidirectionalSenderChannel, + /// The initial message. + pub message: InitialMessage, +} + +/// The unestablished HPKE sender channel. +#[derive(Debug)] +pub struct HpkeSenderChannel { + /// The application prefix which will be used as the info string to derive + /// secrets. + application_info_prefix: String, +} + +impl HpkeSenderChannel { + /// Create a new, random, unestablished HPKE session. + /// + /// This method will use the `MATRIX_QR_CODE_LOGIN` info. If you are using + /// this for a different purpose, consider using the + /// [`HpkeSenderChannel::with_info()`] method. + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self::with_info(MATRIX_QR_LOGIN_INFO_PREFIX) + } + + /// Create a new, random, unestablished HPKE session with the given + /// application info. + /// + /// The application info will be used to derive the various secrets and + /// provide domain separation. + pub fn with_info(info: &str) -> Self { + Self { application_info_prefix: info.to_owned() } + } + + /// Create an [`EstablishedHpkeChannel`] session using the other side's + /// Curve25519 public key and an initial plaintext. + /// + /// After the channel has been established, we can encrypt messages to send + /// to the other side. The other side uses the initial message to + /// establishes the same channel on its side. + pub fn establish_channel( + self, + their_public_key: Curve25519PublicKey, + initial_plaintext: &[u8], + aad: &[u8], + ) -> SenderCreationResult { + let Self { application_info_prefix } = self; + + let mut rng = rng(); + let their_key = convert_public_key(their_public_key); + + #[allow(clippy::expect_used)] + let (encapsulated_key, mut context) = hpke::setup_sender( + &OpModeS::Base, + &their_key, + application_info_prefix.as_bytes(), + &mut rng, + ) + .expect("Encapsulating an X25519 public key never fails since the encapsulation is just the bytes of the public key"); + + #[allow(clippy::expect_used)] + let ciphertext = context + .seal(initial_plaintext, aad) + .expect("We should be able to seal the initial plaintext"); + + let encapsulated_key = convert_encapsulated_key(encapsulated_key); + let our_public_key = encapsulated_key; + + let channel = UnidirectionalSenderChannel(UnidirectionalHkpeChannel { + context, + application_info_prefix, + our_public_key, + their_public_key, + }); + + SenderCreationResult { channel, message: InitialMessage { encapsulated_key, ciphertext } } + } +} + +/// Convert our Curve25519 public key type into the type the HPKE crate expects. +fn convert_public_key( + public_key: Curve25519PublicKey, +) -> ::PublicKey { + #[allow(clippy::expect_used)] + ::PublicKey::from_bytes(public_key.as_bytes()) + .expect("Converting the Dalek public key to the HPKE public key should always work") +} + +/// Convert a EncappedKey from the HPKE crate to our own Curve25519 public key +/// type. +fn convert_encapsulated_key( + encapsulated_key: ::EncappedKey, +) -> Curve25519PublicKey { + let encapsulated_key = encapsulated_key.to_bytes(); + #[allow(clippy::expect_used)] + Curve25519PublicKey::from_slice(encapsulated_key.as_slice()) + .expect("Converting from the HPKE public key to the Dalek public key should always work") +} + +/// The unidirectional HPKE sender channel. +/// +/// This channel is created when we seal the initial plaintext and we wait for +/// the initial response. from the other side. +pub struct UnidirectionalSenderChannel(UnidirectionalHkpeChannel>); + +impl std::fmt::Debug for UnidirectionalSenderChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UnidirectionalSenderChannel") + .field("application_info_prefix", &self.0.application_info_prefix) + .field("our_public_key", &self.0.our_public_key) + .field("their_public_key", &self.0.their_public_key) + .finish_non_exhaustive() + } +} + +impl UnidirectionalSenderChannel { + /// Open the initial response using the associated data and this + /// [`UnidirectionalSenderChannel`]. + /// + /// This method will decrypt the given message coming from the other side of + /// this channel and fully establish the HPKE channel, enabling + /// bidirectional communication. + pub fn establish_bidirectional_channel( + self, + message: &InitialResponse, + aad: &[u8], + ) -> Result>, Error> { + if message.base_response_nonce.len() != 32 { + Err(Error::InvalidNonce { expected: 32, got: message.base_response_nonce.len() }) + } else { + let Self(UnidirectionalHkpeChannel { + context, + application_info_prefix, + our_public_key, + their_public_key, + }) = self; + + let mut response_context = context.create_response_context( + &application_info_prefix, + our_public_key, + &message.base_response_nonce, + ); + + let plaintext = + response_context.open(&message.ciphertext, aad).map_err(|_| Error::Decryption)?; + + let role = Role::Sender { context, response_context }; + let check_code = + role.check_code(&application_info_prefix, our_public_key, their_public_key); + + Ok(BidiereactionalCreationResult { + channel: EstablishedHpkeChannel { + our_public_key, + their_public_key, + role, + check_code, + }, + message: plaintext, + }) + } + } +} + +#[cfg(test)] +mod tests { + use insta::assert_debug_snapshot; + + use super::*; + use crate::hpke::HpkeRecipientChannel; + + #[test] + fn snapshot_debug() { + let alice = HpkeSenderChannel::new(); + + assert_debug_snapshot!(alice); + } + + #[test] + fn snapshot_debug_unidirectional_channel() { + let key = Curve25519PublicKey::from_bytes([0; 32]); + + let alice = HpkeSenderChannel::new(); + let bob = HpkeRecipientChannel::new(); + + let SenderCreationResult { channel: mut alice, .. } = + alice.establish_channel(bob.public_key(), b"", &[]); + + alice.0.our_public_key = key; + alice.0.their_public_key = key; + + assert_debug_snapshot!(alice); + } +} diff --git a/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug.snap b/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug.snap new file mode 100644 index 000000000..30f442fc1 --- /dev/null +++ b/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug.snap @@ -0,0 +1,9 @@ +--- +source: src/hpke/recipient.rs +expression: bob +--- +HpkeRecipientChannel { + our_public_key: "curve25519:L+V9o0fNYkMVKNqsX7spBzD/9oSvxM/C7ZCZX1jLO3Q", + application_info_prefix: "MATRIX_QR_CODE_LOGIN", + .. +} diff --git a/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel-2.snap b/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel-2.snap new file mode 100644 index 000000000..ae5b3c041 --- /dev/null +++ b/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel-2.snap @@ -0,0 +1,10 @@ +--- +source: src/hpke/recipient.rs +expression: bob +--- +UnidirectionalRecipientChannel { + application_info_prefix: "MATRIX_QR_CODE_LOGIN", + their_public_key: "curve25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + our_public_key: "curve25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + .. +} diff --git a/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel.snap b/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel.snap new file mode 100644 index 000000000..30f442fc1 --- /dev/null +++ b/src/hpke/snapshots/vodozemac__hpke__recipient__tests__snapshot_debug_unidirectional_channel.snap @@ -0,0 +1,9 @@ +--- +source: src/hpke/recipient.rs +expression: bob +--- +HpkeRecipientChannel { + our_public_key: "curve25519:L+V9o0fNYkMVKNqsX7spBzD/9oSvxM/C7ZCZX1jLO3Q", + application_info_prefix: "MATRIX_QR_CODE_LOGIN", + .. +} diff --git a/src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug.snap b/src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug.snap new file mode 100644 index 000000000..637c39070 --- /dev/null +++ b/src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug.snap @@ -0,0 +1,7 @@ +--- +source: src/hpke/sender.rs +expression: alice +--- +HpkeSenderChannel { + application_info_prefix: "MATRIX_QR_CODE_LOGIN", +} diff --git a/src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug_unidirectional_channel.snap b/src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug_unidirectional_channel.snap new file mode 100644 index 000000000..50a9ed84b --- /dev/null +++ b/src/hpke/snapshots/vodozemac__hpke__sender__tests__snapshot_debug_unidirectional_channel.snap @@ -0,0 +1,10 @@ +--- +source: src/hpke/sender.rs +expression: alice +--- +UnidirectionalSenderChannel { + application_info_prefix: "MATRIX_QR_CODE_LOGIN", + our_public_key: "curve25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + their_public_key: "curve25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + .. +} diff --git a/src/hpke/snapshots/vodozemac__hpke__tests__snapshot_debug.snap b/src/hpke/snapshots/vodozemac__hpke__tests__snapshot_debug.snap new file mode 100644 index 000000000..88df3215a --- /dev/null +++ b/src/hpke/snapshots/vodozemac__hpke__tests__snapshot_debug.snap @@ -0,0 +1,15 @@ +--- +source: src/hpke/mod.rs +expression: hpke +--- +EstablishedHpkeChannel { + our_public_key: "curve25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + their_public_key: "curve25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + check_code: CheckCode { + bytes: [ + 0, + 1, + ], + }, + role: Sender, +} diff --git a/src/lib.rs b/src/lib.rs index 1e37f2eb8..9b5c6cb03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -201,6 +201,7 @@ mod utilities; pub mod ecies; pub mod hazmat; +pub mod hpke; pub mod megolm; pub mod olm; #[cfg(feature = "insecure-pk-encryption")] diff --git a/src/types/curve25519.rs b/src/types/curve25519.rs index f7fe06ddb..d4f83d964 100644 --- a/src/types/curve25519.rs +++ b/src/types/curve25519.rs @@ -63,6 +63,10 @@ impl Curve25519SecretKey { key } + + pub(crate) fn as_bytes(&self) -> &[u8; 32] { + self.0.as_bytes() + } } impl Default for Curve25519SecretKey { From 153aba7407a936d522ba1cfe3198dd61de0cd871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Thu, 15 Jan 2026 14:38:46 +0100 Subject: [PATCH 02/18] Allow the hpke crate to come from git for now --- .deny.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.deny.toml b/.deny.toml index 5a58e7039..7116bed77 100644 --- a/.deny.toml +++ b/.deny.toml @@ -25,13 +25,14 @@ exceptions = [ [bans] multiple-versions = "warn" -wildcards = "deny" +wildcards = "allow" [sources] unknown-registry = "deny" unknown-git = "deny" allow-git = [ + "https://github.com/rozbb/rust-hpke/", "https://github.com/poljar/olm-rs", "https://github.com/poljar/olm-sys", ] From 04e9579e6fca883e1913f78d63c7cda919e41686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Mon, 1 Dec 2025 13:57:22 +0100 Subject: [PATCH 03/18] Reuse the check code from HPKE in ECIES --- src/ecies/mod.rs | 51 +----------------------------------------- src/hpke/check_code.rs | 2 +- 2 files changed, 2 insertions(+), 51 deletions(-) diff --git a/src/ecies/mod.rs b/src/ecies/mod.rs index b7130676b..e50352919 100644 --- a/src/ecies/mod.rs +++ b/src/ecies/mod.rs @@ -90,6 +90,7 @@ use zeroize::{Zeroize, ZeroizeOnDrop}; pub use self::messages::{InitialMessage, Message, MessageDecodeError}; use crate::Curve25519PublicKey; +pub use crate::hpke::CheckCode; mod messages; @@ -138,56 +139,6 @@ impl EciesNonce { } } -/// A check code that can be used to confirm that two [`EstablishedEcies`] -/// objects share the same secret. This is supposed to be shared out-of-band to -/// protect against active MITM attacks. -/// -/// Since the initiator device can always tell whether a MITM attack is in -/// progress after channel establishment, this code technically carries only a -/// single bit of information, representing whether the initiator has determined -/// that the channel is "secure" or "not secure". -/// -/// However, given this will need to be interactively confirmed by the user, -/// there is risk that the user would confirm the dialogue without paying -/// attention to its content. By expanding this single bit into a deterministic -/// two-digit check code, the user is forced to pay more attention by having to -/// enter it instead of just clicking through a dialogue. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CheckCode { - bytes: [u8; 2], -} - -impl CheckCode { - /// Convert the check code to an array of two bytes. - /// - /// The bytes can be converted to a more user-friendly representation. The - /// [`CheckCode::to_digit`] converts the bytes to a two-digit number. - pub const fn as_bytes(&self) -> &[u8; 2] { - &self.bytes - } - - /// Convert the check code to two base-10 numbers. - /// - /// The number should be displayed with a leading 0 in case the first digit - /// is a 0. - /// - /// # Examples - /// - /// ```no_run - /// # use vodozemac::ecies::CheckCode; - /// # let check_code: CheckCode = unimplemented!(); - /// let check_code = check_code.to_digit(); - /// - /// println!("The check code of the IECS channel is: {check_code:02}"); - /// ``` - pub const fn to_digit(&self) -> u8 { - let first = (self.bytes[0] % 10) * 10; - let second = self.bytes[1] % 10; - - first + second - } -} - /// The result of an inbound ECIES channel establishment. #[derive(Debug)] pub struct InboundCreationResult { diff --git a/src/hpke/check_code.rs b/src/hpke/check_code.rs index 1493b0d44..4c9435be2 100644 --- a/src/hpke/check_code.rs +++ b/src/hpke/check_code.rs @@ -28,7 +28,7 @@ /// enter it instead of just clicking through a dialogue. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckCode { - pub(super) bytes: [u8; 2], + pub(crate) bytes: [u8; 2], } impl CheckCode { From 7b0029273c4e1981986d6c9cb774cb83c4af9bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 10 Feb 2026 14:52:37 +0100 Subject: [PATCH 04/18] chore: More dep bumps --- Cargo.toml | 36 ++++++++++++++++++------------------ src/hpke/recipient.rs | 2 +- src/megolm/ratchet.rs | 2 +- src/olm/account/mod.rs | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1537a3ce9..db46d18a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,39 +47,39 @@ insecure-pk-encryption = ["libolm-compat"] low-level-api = [] [dependencies] -aes = { version = "0.9.0-rc.2" } -cipher = { version = "0.5.0-rc.3", features = ["alloc"] } +aes = { version = "0.9.0-rc.4" } +cipher = { version = "0.5.0", features = ["alloc"] } arrayvec = { version = "0.7.6", features = ["serde"] } base64 = "0.22.1" base64ct = { version = "1.8.3", features = ["std", "alloc"] } -cbc = { version = "0.2.0-rc.2" } -chacha20poly1305 = "0.11.0-rc.2" -curve25519-dalek = { version = "5.0.0-pre.4", default-features = false, features = ["zeroize"] } -ed25519-dalek = { version = "3.0.0-pre.4", default-features = false, features = ["rand_core", "serde", "hazmat", "zeroize"] } -getrandom = "0.3.4" -hkdf = "0.13.0-rc.3" -hmac = "0.13.0-rc.3" -hpke = { git = "https://github.com/rozbb/rust-hpke/", rev = "ba75d11", features = ["alloc", "x25519", "hazmat-streaming-enc"] } +cbc = { version = "0.2.0-rc.3" } +chacha20poly1305 = "0.11.0-rc.3" +curve25519-dalek = { version = "5.0.0-pre.6", default-features = false, features = ["zeroize"] } +ed25519-dalek = { version = "3.0.0-pre.6", default-features = false, features = ["rand_core", "serde", "hazmat", "zeroize"] } +getrandom = "0.4.1" +hkdf = "0.13.0-rc.5" +hmac = "0.13.0-rc.5" +hpke = { git = "https://github.com/rozbb/rust-hpke/", rev = "08b1cfc142e914b637f929a8b93bd6bab8a63d03", features = ["alloc", "x25519", "hazmat-streaming-enc"] } matrix-pickle = { version = "0.2.2" } prost = "0.14.3" -rand = "0.10.0-rc.6" +rand = "0.10.0" serde = { version = "1.0.228", features = ["derive"] } serde_bytes = "0.11.19" serde_json = "1.0.149" -sha2 = "0.11.0-rc.3" +sha2 = "0.11.0-rc.5" subtle = "2.6.1" -thiserror = "2.0.17" -x25519-dalek = { version = "3.0.0-pre.4", features = ["serde", "reusable_secrets", "static_secrets", "zeroize"] } +thiserror = "2.0.18" +x25519-dalek = { version = "3.0.0-pre.6", features = ["serde", "reusable_secrets", "static_secrets", "zeroize"] } zeroize = { version = "1.8.2", features = ["derive"] } [dev-dependencies] -anyhow = "1.0.100" +anyhow = "1.0.101" assert_matches2 = "0.1.2" -criterion = { version = "4.2.1", package = "codspeed-criterion-compat" } +criterion = { version = "4.3.0", package = "codspeed-criterion-compat" } ntest = "0.9.5" olm-rs = "2.2.0" -proptest = "1.9.0" -insta = "1.46.0" +proptest = "1.10.0" +insta = "1.46.3" [patch.crates-io] olm-rs = { git = "https://github.com/poljar/olm-rs", rev = "9f7108c3b852c39bf1d0f09ccaafb9a1cf7bb83e" } diff --git a/src/hpke/recipient.rs b/src/hpke/recipient.rs index 403acfd64..b01359995 100644 --- a/src/hpke/recipient.rs +++ b/src/hpke/recipient.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use cipher::{Array, consts::U32, crypto_common::Generate}; +use cipher::{Array, common::Generate, consts::U32}; use hpke::{Deserializable as _, aead::AeadCtxR, kem::X25519HkdfSha256}; use crate::{ diff --git a/src/megolm/ratchet.rs b/src/megolm/ratchet.rs index ef2b2a3dc..4ea31638a 100644 --- a/src/megolm/ratchet.rs +++ b/src/megolm/ratchet.rs @@ -14,7 +14,7 @@ // limitations under the License. use hmac::{Hmac, KeyInit, Mac as _}; -use rand::{RngCore, rng}; +use rand::{Rng, rng}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Sha256, digest::CtOutput}; use subtle::{Choice, ConstantTimeEq}; diff --git a/src/olm/account/mod.rs b/src/olm/account/mod.rs index 547a8deb7..a4a72e2f3 100644 --- a/src/olm/account/mod.rs +++ b/src/olm/account/mod.rs @@ -21,7 +21,7 @@ use chacha20poly1305::{ ChaCha20Poly1305, Nonce, aead::{Aead, KeyInit}, }; -use cipher::crypto_common::Generate; +use cipher::common::Generate; use rand::rng; use serde::{Deserialize, Serialize}; use thiserror::Error; From e41eaf767a9e654078fc939c500c14c8c6d787ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Tue, 10 Feb 2026 14:52:37 +0100 Subject: [PATCH 05/18] feat(hpke): Check the nonce length during message decoding not during decryption --- src/hpke/error.rs | 16 ++++++------ src/hpke/messages.rs | 15 +++++++++--- src/hpke/recipient.rs | 2 +- src/hpke/sender.rs | 57 ++++++++++++++++++------------------------- 4 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/hpke/error.rs b/src/hpke/error.rs index 99620968b..31a2b6e26 100644 --- a/src/hpke/error.rs +++ b/src/hpke/error.rs @@ -23,6 +23,14 @@ pub enum MessageDecodeError { /// separator. #[error("The initial message is missing the | separator")] MissingSeparator, + /// The initial response contained a nonce with an incorrect length. + #[error("The base response nonce has an incorrect length, expected {expected}, got {got}")] + InvalidNonce { + /// The expected Nonce size. + expected: usize, + /// The size of the nonce we received in the message. + got: usize, + }, /// The initial message could not have been decoded, the embedded Curve25519 /// key is malformed. #[error("The embedded ephemeral Curve25519 key could not have been decoded: {0:?}")] @@ -35,14 +43,6 @@ pub enum MessageDecodeError { /// The Error type for the HPKE submodule. #[derive(Debug, Error)] pub enum Error { - /// The initial response contained a nonce with an incorrect length. - #[error("The base response nonce has an incorrect length, expected {expected}, got {got}")] - InvalidNonce { - /// The expected Nonce size. - expected: usize, - /// The size of the nonce we received in the message. - got: usize, - }, /// Message decryption failed. Either the message was corrupted, the message /// was replayed, or the wrong key is being used to decrypt the message. #[error("Failed decrypting the message")] diff --git a/src/hpke/messages.rs b/src/hpke/messages.rs index 71058dd38..5ec569187 100644 --- a/src/hpke/messages.rs +++ b/src/hpke/messages.rs @@ -64,7 +64,7 @@ impl InitialMessage { #[derive(Debug)] pub struct InitialResponse { /// The randomly generated base response nonce. - pub base_response_nonce: Vec, + pub base_response_nonce: [u8; 32], /// The ciphertext of the initial message. pub ciphertext: Vec, } @@ -76,7 +76,7 @@ impl InitialResponse { /// and encoded using unpadded base64. pub fn encode(&self) -> String { let ciphertext = base64_encode(&self.ciphertext); - let base_response_nonce = base64_encode(&self.base_response_nonce); + let base_response_nonce = base64_encode(self.base_response_nonce); format!("{base_response_nonce}|{ciphertext}") } @@ -88,7 +88,16 @@ impl InitialResponse { let base_response_nonce = base64_decode(base_response_nonce)?; let ciphertext = base64_decode(ciphertext)?; - Ok(Self { ciphertext, base_response_nonce }) + let mut nonce = [0u8; 32]; + let nonce_len = base_response_nonce.len(); + + if nonce_len == 32 { + nonce.copy_from_slice(&base_response_nonce); + + Ok(Self { ciphertext, base_response_nonce: nonce }) + } else { + Err(MessageDecodeError::InvalidNonce { expected: 32, got: nonce_len }) + } } None => Err(MessageDecodeError::MissingSeparator), } diff --git a/src/hpke/recipient.rs b/src/hpke/recipient.rs index b01359995..570035354 100644 --- a/src/hpke/recipient.rs +++ b/src/hpke/recipient.rs @@ -209,7 +209,7 @@ impl UnidirectionalRecipientChannel { channel, message: InitialResponse { ciphertext, - base_response_nonce: base_response_nonce.to_vec(), + base_response_nonce: base_response_nonce.into(), }, } } diff --git a/src/hpke/sender.rs b/src/hpke/sender.rs index 0e12ef1ad..5a482592f 100644 --- a/src/hpke/sender.rs +++ b/src/hpke/sender.rs @@ -156,39 +156,30 @@ impl UnidirectionalSenderChannel { message: &InitialResponse, aad: &[u8], ) -> Result>, Error> { - if message.base_response_nonce.len() != 32 { - Err(Error::InvalidNonce { expected: 32, got: message.base_response_nonce.len() }) - } else { - let Self(UnidirectionalHkpeChannel { - context, - application_info_prefix, - our_public_key, - their_public_key, - }) = self; - - let mut response_context = context.create_response_context( - &application_info_prefix, - our_public_key, - &message.base_response_nonce, - ); - - let plaintext = - response_context.open(&message.ciphertext, aad).map_err(|_| Error::Decryption)?; - - let role = Role::Sender { context, response_context }; - let check_code = - role.check_code(&application_info_prefix, our_public_key, their_public_key); - - Ok(BidiereactionalCreationResult { - channel: EstablishedHpkeChannel { - our_public_key, - their_public_key, - role, - check_code, - }, - message: plaintext, - }) - } + let Self(UnidirectionalHkpeChannel { + context, + application_info_prefix, + our_public_key, + their_public_key, + }) = self; + + let mut response_context = context.create_response_context( + &application_info_prefix, + our_public_key, + &message.base_response_nonce, + ); + + let plaintext = + response_context.open(&message.ciphertext, aad).map_err(|_| Error::Decryption)?; + + let role = Role::Sender { context, response_context }; + let check_code = + role.check_code(&application_info_prefix, our_public_key, their_public_key); + + Ok(BidiereactionalCreationResult { + channel: EstablishedHpkeChannel { our_public_key, their_public_key, role, check_code }, + message: plaintext, + }) } } From 2d9440c863035034861e73e15c2d1fd1087b406c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Mon, 16 Feb 2026 11:16:22 +0100 Subject: [PATCH 06/18] Apply suggestions from code review Co-authored-by: Hugh Nimmo-Smith --- src/hpke/mod.rs | 18 +++++++++--------- src/hpke/recipient.rs | 6 +++--- src/hpke/sender.rs | 6 +++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/hpke/mod.rs b/src/hpke/mod.rs index 5da2352b0..a65ebe0c9 100644 --- a/src/hpke/mod.rs +++ b/src/hpke/mod.rs @@ -41,8 +41,8 @@ //! //! let plaintext = b"Not a secret to me!"; //! -//! let BidiereactionalCreationResult { channel: mut bob, message } = bob.establish_bidirectional_channel(plaintext, &[]); -//! let BidiereactionalCreationResult { channel: mut alice, message} = alice.establish_bidirectional_channel(&message, &[])?; +//! let BidirectionalCreationResult { channel: mut bob, message } = bob.establish_bidirectional_channel(plaintext, &[]); +//! let BidirectionalCreationResult { channel: mut alice, message} = alice.establish_bidirectional_channel(&message, &[])?; //! //! assert_eq!(message, plaintext); //! @@ -199,7 +199,7 @@ struct UnidirectionalHkpeChannel { /// The result of the creation of a bidirectional and fully established HPKE /// channel. -pub struct BidiereactionalCreationResult { +pub struct BidirectionalCreationResult { /// The established HPKE channel. pub channel: EstablishedHpkeChannel, /// The plaintext of the initial message. @@ -351,11 +351,11 @@ mod tests { let plaintext = b"Not a secret to me!"; - let BidiereactionalCreationResult { message: initial_response, .. } = + let BidirectionalCreationResult { message: initial_response, .. } = bob.establish_bidirectional_channel(plaintext, &[]); assert_ne!(initial_response.ciphertext, plaintext); - let BidiereactionalCreationResult { message: decrypted, .. } = alice + let BidirectionalCreationResult { message: decrypted, .. } = alice .establish_bidirectional_channel(&initial_response, &[]) .expect("We should be able to decrypt the initial response"); @@ -399,10 +399,10 @@ mod tests { .establish_channel(&message, &[]) .expect("We should be able to establish the recipient channel"); - let BidiereactionalCreationResult { channel: bob, message: initial_response } = + let BidirectionalCreationResult { channel: bob, message: initial_response } = bob.establish_bidirectional_channel(b"My response", &[]); - let BidiereactionalCreationResult { channel: alice, .. } = alice + let BidirectionalCreationResult { channel: alice, .. } = alice .establish_bidirectional_channel(&initial_response, &[]) .expect("We should be able to establish the bidirectional channel for Alice"); @@ -433,10 +433,10 @@ mod tests { let RecipientCreationResult { channel: bob, .. } = bob.establish_channel(&message, &[]).unwrap(); - let BidiereactionalCreationResult { message, .. } = + let BidirectionalCreationResult { message, .. } = bob.establish_bidirectional_channel(b"", &[]); - let BidiereactionalCreationResult { mut channel, .. } = + let BidirectionalCreationResult { mut channel, .. } = alice.establish_bidirectional_channel(&message, &[]).unwrap(); channel.our_public_key = key; diff --git a/src/hpke/recipient.rs b/src/hpke/recipient.rs index 570035354..e15ef0c99 100644 --- a/src/hpke/recipient.rs +++ b/src/hpke/recipient.rs @@ -18,7 +18,7 @@ use hpke::{Deserializable as _, aead::AeadCtxR, kem::X25519HkdfSha256}; use crate::{ Curve25519PublicKey, Curve25519SecretKey, hpke::{ - Aead, BidiereactionalCreationResult, CreateResponseContext, Error, EstablishedHpkeChannel, + Aead, BidirectionalCreationResult, CreateResponseContext, Error, EstablishedHpkeChannel, InitialMessage, InitialResponse, Kdf, Kem, MATRIX_QR_LOGIN_INFO_PREFIX, RecipientContext, Role, UnidirectionalHkpeChannel, }, @@ -178,7 +178,7 @@ impl UnidirectionalRecipientChannel { self, plaintext: &[u8], aad: &[u8], - ) -> BidiereactionalCreationResult { + ) -> BidirectionalCreationResult { let Self(UnidirectionalHkpeChannel { context, their_public_key, @@ -205,7 +205,7 @@ impl UnidirectionalRecipientChannel { let channel = EstablishedHpkeChannel { our_public_key, their_public_key, role, check_code }; - BidiereactionalCreationResult { + BidirectionalCreationResult { channel, message: InitialResponse { ciphertext, diff --git a/src/hpke/sender.rs b/src/hpke/sender.rs index 5a482592f..ab5314676 100644 --- a/src/hpke/sender.rs +++ b/src/hpke/sender.rs @@ -20,7 +20,7 @@ use rand::rng; use crate::{ Curve25519PublicKey, hpke::{ - Aead, BidiereactionalCreationResult, CreateResponseContext, Error, EstablishedHpkeChannel, + Aead, BidirectionalCreationResult, CreateResponseContext, Error, EstablishedHpkeChannel, InitialMessage, InitialResponse, Kdf, Kem, MATRIX_QR_LOGIN_INFO_PREFIX, Role, UnidirectionalHkpeChannel, }, @@ -155,7 +155,7 @@ impl UnidirectionalSenderChannel { self, message: &InitialResponse, aad: &[u8], - ) -> Result>, Error> { + ) -> Result>, Error> { let Self(UnidirectionalHkpeChannel { context, application_info_prefix, @@ -176,7 +176,7 @@ impl UnidirectionalSenderChannel { let check_code = role.check_code(&application_info_prefix, our_public_key, their_public_key); - Ok(BidiereactionalCreationResult { + Ok(BidirectionalCreationResult { channel: EstablishedHpkeChannel { our_public_key, their_public_key, role, check_code }, message: plaintext, }) From 921669bdbd7e34222fcb044df28b095406b3651e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Mon, 16 Feb 2026 13:55:49 +0100 Subject: [PATCH 07/18] fix: Change the way we encode the HPKE messages --- src/hpke/error.rs | 15 ++-------- src/hpke/messages.rs | 66 +++++++++++++++++++------------------------- 2 files changed, 32 insertions(+), 49 deletions(-) diff --git a/src/hpke/error.rs b/src/hpke/error.rs index 31a2b6e26..855e9f370 100644 --- a/src/hpke/error.rs +++ b/src/hpke/error.rs @@ -19,18 +19,9 @@ use crate::KeyError; /// The error type for the HPKE message decoding failures. #[derive(Debug, Error)] pub enum MessageDecodeError { - /// The initial message could not have been decoded, it's missing the `|` - /// separator. - #[error("The initial message is missing the | separator")] - MissingSeparator, - /// The initial response contained a nonce with an incorrect length. - #[error("The base response nonce has an incorrect length, expected {expected}, got {got}")] - InvalidNonce { - /// The expected Nonce size. - expected: usize, - /// The size of the nonce we received in the message. - got: usize, - }, + /// The message failed to be decoded because the message isn't long enough. + #[error("The message doesn't contain enough bytes to be decoded")] + MessageIncomplete, /// The initial message could not have been decoded, the embedded Curve25519 /// key is malformed. #[error("The embedded ephemeral Curve25519 key could not have been decoded: {0:?}")] diff --git a/src/hpke/messages.rs b/src/hpke/messages.rs index 5ec569187..b0966946b 100644 --- a/src/hpke/messages.rs +++ b/src/hpke/messages.rs @@ -34,26 +34,21 @@ pub struct InitialMessage { impl InitialMessage { /// Encode the message as a string. /// - /// The string will contain the base64-encoded Curve25519 public key and the - /// ciphertext of the message separated by a `|`. + /// This prepends the Curve25519 public key bytes to the ciphertext bytes + /// before it base64 encodes the bytestring. pub fn encode(&self) -> String { - let ciphertext = base64_encode(&self.ciphertext); - let key = self.encapsulated_key.to_base64(); + let Self { encapsulated_key, ciphertext } = self; - format!("{ciphertext}|{key}") + let bytes = [encapsulated_key.to_bytes().as_slice(), ciphertext].concat(); + + base64_encode(bytes) } /// Attempt do decode a string into a [`InitialMessage`]. pub fn decode(message: &str) -> Result { - match message.split_once('|') { - Some((ciphertext, key)) => { - let encapsulated_key = Curve25519PublicKey::from_base64(key)?; - let ciphertext = base64_decode(ciphertext)?; - - Ok(Self { ciphertext, encapsulated_key }) - } - None => Err(MessageDecodeError::MissingSeparator), - } + let (encapsulated_key, ciphertext) = decode_message_with_byte_prefix(message)?; + + Ok(Self { encapsulated_key: Curve25519PublicKey::from_bytes(encapsulated_key), ciphertext }) } } @@ -72,38 +67,35 @@ pub struct InitialResponse { impl InitialResponse { /// Encode the message as a string. /// - /// The string will contain the nonce and ciphertext concatenated together - /// and encoded using unpadded base64. + /// This prepends the base response nonce bytes to the ciphertext bytes + /// before it base64 encodes the bytestring. pub fn encode(&self) -> String { - let ciphertext = base64_encode(&self.ciphertext); - let base_response_nonce = base64_encode(self.base_response_nonce); + let Self { base_response_nonce, ciphertext } = self; - format!("{base_response_nonce}|{ciphertext}") + let bytes = [base_response_nonce.as_slice(), ciphertext].concat(); + + base64_encode(bytes) } /// Attempt do decode a string into a [`InitialResponse`]. pub fn decode(message: &str) -> Result { - match message.split_once('|') { - Some((base_response_nonce, ciphertext)) => { - let base_response_nonce = base64_decode(base_response_nonce)?; - let ciphertext = base64_decode(ciphertext)?; - - let mut nonce = [0u8; 32]; - let nonce_len = base_response_nonce.len(); - - if nonce_len == 32 { - nonce.copy_from_slice(&base_response_nonce); - - Ok(Self { ciphertext, base_response_nonce: nonce }) - } else { - Err(MessageDecodeError::InvalidNonce { expected: 32, got: nonce_len }) - } - } - None => Err(MessageDecodeError::MissingSeparator), - } + let (base_response_nonce, ciphertext) = decode_message_with_byte_prefix(message)?; + + Ok(Self { base_response_nonce, ciphertext }) } } +fn decode_message_with_byte_prefix( + message: &str, +) -> Result<([u8; 32], Vec), MessageDecodeError> { + let bytes = base64_decode(message)?; + + bytes + .split_first_chunk::<32>() + .map(|(nonce, ciphertext)| (nonce.to_owned(), ciphertext.to_owned())) + .ok_or(MessageDecodeError::MessageIncomplete) +} + /// An encrypted message a [`EstablishedHpkeChannel`] channel has sent. #[derive(Debug)] pub struct Message { From b9a956275d1f01623ff5089086b6426e2497d096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 20 Feb 2026 12:28:30 +0100 Subject: [PATCH 08/18] test: Fix the initial message test data for HPKE --- src/hpke/messages.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hpke/messages.rs b/src/hpke/messages.rs index b0966946b..de57bafe8 100644 --- a/src/hpke/messages.rs +++ b/src/hpke/messages.rs @@ -121,7 +121,7 @@ impl Message { mod test { use super::*; - const INITIAL_MESSAGE: &str = "3On7QFJyLQMAErua9K/yIOcJALvuMYax1AW0iWgf64AwtSMZXwAA012Q|9yA/CX8pJKF02Prd75ZyBQHg3fGTVVGDNl86q1z17Us"; + const INITIAL_MESSAGE: &str = "9yA/CX8pJKF02Prd75ZyBQHg3fGTVVGDNl86q1z17Uvc6ftAUnItAwASu5r0r/Ig5wkAu+4xhrHUBbSJaB/rgDC1IxlfAADTXZA"; const MESSAGE: &str = "ZmtSLdzMcyjC5eV6L8xBI6amsq7gDNbCjz1W5OjX4Z8W"; const PUBLIC_KEY: &str = "9yA/CX8pJKF02Prd75ZyBQHg3fGTVVGDNl86q1z17Us"; @@ -138,6 +138,8 @@ mod test { let encoded = message.encode(); assert_eq!(INITIAL_MESSAGE, encoded); + + InitialMessage::decode("").expect_err("An empty message should fail to be decoded"); } #[test] From 2d8e08bcd08e1bf2dc9106159fef7c66e28eb92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 20 Feb 2026 12:31:19 +0100 Subject: [PATCH 09/18] test(hpke): Test initial response --- src/hpke/messages.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/hpke/messages.rs b/src/hpke/messages.rs index de57bafe8..61a718fc7 100644 --- a/src/hpke/messages.rs +++ b/src/hpke/messages.rs @@ -122,6 +122,7 @@ mod test { use super::*; const INITIAL_MESSAGE: &str = "9yA/CX8pJKF02Prd75ZyBQHg3fGTVVGDNl86q1z17Uvc6ftAUnItAwASu5r0r/Ig5wkAu+4xhrHUBbSJaB/rgDC1IxlfAADTXZA"; + const INITIAL_RESPONSE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADc6ftAUnItAwASu5r0r/Ig5wkAu+4xhrHUBbSJaB/rgDC1IxlfAADTXZA"; const MESSAGE: &str = "ZmtSLdzMcyjC5eV6L8xBI6amsq7gDNbCjz1W5OjX4Z8W"; const PUBLIC_KEY: &str = "9yA/CX8pJKF02Prd75ZyBQHg3fGTVVGDNl86q1z17Us"; @@ -142,6 +143,22 @@ mod test { InitialMessage::decode("").expect_err("An empty message should fail to be decoded"); } + #[test] + fn initial_response() { + let message = InitialResponse::decode(INITIAL_RESPONSE) + .expect("We should be able to decode our known-valid initial message"); + + assert_eq!( + message.base_response_nonce, [0u8; 32], + "The decoded nonce should match the expected one" + ); + + let encoded = message.encode(); + assert_eq!(INITIAL_RESPONSE, encoded); + + InitialResponse::decode("").expect_err("An empty message should fail to be decoded"); + } + #[test] fn message() { let message = Message::decode(MESSAGE) From 29ec33669663cab5f1a8a4efbc86f8f935ecc347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 20 Feb 2026 12:31:19 +0100 Subject: [PATCH 10/18] fix(hpke): Fail to decode empty messages --- src/hpke/messages.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/hpke/messages.rs b/src/hpke/messages.rs index 61a718fc7..bdc68060c 100644 --- a/src/hpke/messages.rs +++ b/src/hpke/messages.rs @@ -113,7 +113,13 @@ impl Message { /// Attempt do decode a base64 string into a [`Message`]. pub fn decode(message: &str) -> Result { - Ok(Self { ciphertext: base64_decode(message)? }) + let ciphertext = base64_decode(message)?; + + if ciphertext.is_empty() { + Err(MessageDecodeError::MessageIncomplete) + } else { + Ok(Self { ciphertext }) + } } } @@ -166,5 +172,7 @@ mod test { let encoded = message.encode(); assert_eq!(MESSAGE, encoded); + + Message::decode("").expect_err("An empty message should fail to be decoded"); } } From c57da50be4337730e20050969d6c988b1d56f348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 20 Feb 2026 12:31:19 +0100 Subject: [PATCH 11/18] feat(hpke): Add from_bytes/to_bytes methods Mostly for fuzzing purposes --- src/hpke/messages.rs | 49 +++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/hpke/messages.rs b/src/hpke/messages.rs index bdc68060c..1b84041cf 100644 --- a/src/hpke/messages.rs +++ b/src/hpke/messages.rs @@ -37,18 +37,33 @@ impl InitialMessage { /// This prepends the Curve25519 public key bytes to the ciphertext bytes /// before it base64 encodes the bytestring. pub fn encode(&self) -> String { - let Self { encapsulated_key, ciphertext } = self; - - let bytes = [encapsulated_key.to_bytes().as_slice(), ciphertext].concat(); + let bytes = self.to_bytes(); base64_encode(bytes) } /// Attempt do decode a string into a [`InitialMessage`]. pub fn decode(message: &str) -> Result { - let (encapsulated_key, ciphertext) = decode_message_with_byte_prefix(message)?; + let bytes = base64_decode(message)?; + + Self::from_bytes(&bytes) + } + + /// Encode the message as a byte vector. + /// + /// This prepends the Curve25519 public key bytes to the ciphertext bytes. + pub fn to_bytes(&self) -> Vec { + let Self { encapsulated_key, ciphertext } = self; + + [encapsulated_key.to_bytes().as_slice(), ciphertext].concat() + } + + /// Attempt do decode a slice of bytes into a [`InitialMessage`]. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (encapsulated_key, ciphertext) = decode_message_with_byte_prefix(bytes)?; + let encapsulated_key = Curve25519PublicKey::from_bytes(encapsulated_key); - Ok(Self { encapsulated_key: Curve25519PublicKey::from_bytes(encapsulated_key), ciphertext }) + Ok(Self { encapsulated_key, ciphertext }) } } @@ -70,26 +85,36 @@ impl InitialResponse { /// This prepends the base response nonce bytes to the ciphertext bytes /// before it base64 encodes the bytestring. pub fn encode(&self) -> String { - let Self { base_response_nonce, ciphertext } = self; - - let bytes = [base_response_nonce.as_slice(), ciphertext].concat(); + let bytes = self.to_bytes(); base64_encode(bytes) } /// Attempt do decode a string into a [`InitialResponse`]. pub fn decode(message: &str) -> Result { - let (base_response_nonce, ciphertext) = decode_message_with_byte_prefix(message)?; + let bytes = base64_decode(message)?; + Self::from_bytes(&bytes) + } + + /// Attempt do decode a slice of bytes into a [`InitialResponse`]. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (base_response_nonce, ciphertext) = decode_message_with_byte_prefix(bytes)?; Ok(Self { base_response_nonce, ciphertext }) } + + /// Encode the message as a byte vector. + /// + /// This prepends the base response nonce to the ciphertext bytes. + pub fn to_bytes(&self) -> Vec { + let Self { base_response_nonce, ciphertext } = self; + [base_response_nonce.as_slice(), ciphertext].concat() + } } fn decode_message_with_byte_prefix( - message: &str, + bytes: &[u8], ) -> Result<([u8; 32], Vec), MessageDecodeError> { - let bytes = base64_decode(message)?; - bytes .split_first_chunk::<32>() .map(|(nonce, ciphertext)| (nonce.to_owned(), ciphertext.to_owned())) From 6034269605b4c7242c846bacedd9e7ee9dcb0018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 20 Feb 2026 12:31:19 +0100 Subject: [PATCH 12/18] test(hpke): More tests for HPKE support --- src/hpke/mod.rs | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/hpke/mod.rs b/src/hpke/mod.rs index a65ebe0c9..c49b81ecb 100644 --- a/src/hpke/mod.rs +++ b/src/hpke/mod.rs @@ -336,10 +336,12 @@ mod tests { let alice = HpkeSenderChannel::new(); let bob = HpkeRecipientChannel::new(); + let bob_public_key = bob.public_key(); + let plaintext = b"It's a secret to everybody"; let SenderCreationResult { channel: alice, message, .. } = - alice.establish_channel(bob.public_key(), plaintext, &[]); + alice.establish_channel(bob_public_key, plaintext, &[]); assert_ne!(message.ciphertext, plaintext); @@ -351,15 +353,49 @@ mod tests { let plaintext = b"Not a secret to me!"; - let BidirectionalCreationResult { message: initial_response, .. } = + let BidirectionalCreationResult { message: initial_response, channel: mut bob } = bob.establish_bidirectional_channel(plaintext, &[]); assert_ne!(initial_response.ciphertext, plaintext); - let BidirectionalCreationResult { message: decrypted, .. } = alice + let BidirectionalCreationResult { message: decrypted, channel: mut alice } = alice .establish_bidirectional_channel(&initial_response, &[]) .expect("We should be able to decrypt the initial response"); assert_eq!(decrypted, plaintext); + assert_eq!( + alice.check_code(), + bob.check_code(), + "Alice and Bob should derive the same check code" + ); + + assert_eq!( + bob_public_key, + bob.public_key(), + "The public key should stay the same even after the bidirectional channel has been established" + ); + + assert_eq!(bob.their_public_key(), alice.public_key()); + + // Further messages can also be exchanged. + let plaintext = b"Fully"; + let message = bob.seal(plaintext, &[]); + let decrypted = + alice.open(&message, &[]).expect("Alice should be able to open Bob's latest message"); + + assert_eq!(plaintext.as_slice(), decrypted); + + alice.open(&message, &[]).expect_err("Replaying a message should not be possible"); + + let message = alice.seal(plaintext, b"some additional data"); + bob.open(&message, &[]).expect_err( + "Bob should not be able to decrypt a message without providing the same AAD", + ); + + let message = bob + .open(&message, b"some additional data") + .expect("Bob should be able to decrypt Alice's final message"); + + assert_eq!(message, plaintext); } #[test] From 00407a17a27d2f734b7004b5ab463edb0f4fb0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 20 Feb 2026 12:31:19 +0100 Subject: [PATCH 13/18] fuzz: Add a fuzz target for the HPKE message decoding --- afl/hpke-message-decoding/Cargo.toml | 15 +++++++++++++++ afl/hpke-message-decoding/in/msg_1.bin | Bin 0 -> 74 bytes afl/hpke-message-decoding/in/msg_2.bin | Bin 0 -> 4 bytes afl/hpke-message-decoding/in/msg_3.bin | Bin 0 -> 32 bytes afl/hpke-message-decoding/rust-toolchain.toml | 2 ++ afl/hpke-message-decoding/src/main.rs | 12 ++++++++++++ 6 files changed, 29 insertions(+) create mode 100644 afl/hpke-message-decoding/Cargo.toml create mode 100644 afl/hpke-message-decoding/in/msg_1.bin create mode 100644 afl/hpke-message-decoding/in/msg_2.bin create mode 100644 afl/hpke-message-decoding/in/msg_3.bin create mode 100644 afl/hpke-message-decoding/rust-toolchain.toml create mode 100644 afl/hpke-message-decoding/src/main.rs diff --git a/afl/hpke-message-decoding/Cargo.toml b/afl/hpke-message-decoding/Cargo.toml new file mode 100644 index 000000000..70fe74bc5 --- /dev/null +++ b/afl/hpke-message-decoding/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hpke-initial-message-decoding" +version = "0.1.0" +publish = false +edition = "2021" + +[dependencies] +afl = "*" + +[dependencies.vodozemac] +path = "../.." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] diff --git a/afl/hpke-message-decoding/in/msg_1.bin b/afl/hpke-message-decoding/in/msg_1.bin new file mode 100644 index 0000000000000000000000000000000000000000..9ccd3a0a8f1d4fa26137f071828982ed12406ca5 GIT binary patch literal 74 zcmV-Q0JZ=3AU_FzDI}qE*!tb?mU0CF;N9_)RZ)XBUplK?_3caC>H9!ZaxDV@61$r8 guks-02>`q9F@~|!1+ Date: Wed, 25 Feb 2026 13:32:51 +0100 Subject: [PATCH 14/18] doc(hpke): Link to the relevant MSCs from the CheckCode docs --- src/hpke/check_code.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/hpke/check_code.rs b/src/hpke/check_code.rs index 4c9435be2..0537735ba 100644 --- a/src/hpke/check_code.rs +++ b/src/hpke/check_code.rs @@ -26,6 +26,12 @@ /// attention to its content. By expanding this single bit into a deterministic /// two-digit check code, the user is forced to pay more attention by having to /// enter it instead of just clicking through a dialogue. +/// +/// An example protocol which uses the [`CheckCode`] for out of band +/// confirmation can be found in [MSC4108] and [MSC4388]. +/// +/// [MSC4108]: https://github.com/matrix-org/matrix-spec-proposals/pull/4108 +/// [MSC4388]: https://github.com/matrix-org/matrix-spec-proposals/pull/4388 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckCode { pub(crate) bytes: [u8; 2], From 81cf7dc50707b25613a227dd5f97de4079216351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Thu, 26 Feb 2026 12:01:58 +0100 Subject: [PATCH 15/18] Update the check code to not generate leading zeroes --- src/ecies/mod.rs | 15 +++++--- src/hpke/check_code.rs | 85 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/ecies/mod.rs b/src/ecies/mod.rs index e50352919..4e028269f 100644 --- a/src/ecies/mod.rs +++ b/src/ecies/mod.rs @@ -90,7 +90,7 @@ use zeroize::{Zeroize, ZeroizeOnDrop}; pub use self::messages::{InitialMessage, Message, MessageDecodeError}; use crate::Curve25519PublicKey; -pub use crate::hpke::CheckCode; +pub use crate::hpke::{CheckCode, DigitMode}; mod messages; @@ -568,7 +568,10 @@ mod test { "The decrypted plaintext should match our initial plaintext" ); assert_eq!(alice.check_code(), bob.check_code()); - assert_eq!(alice.check_code().to_digit(), bob.check_code().to_digit()); + assert_eq!( + alice.check_code().to_digit(DigitMode::AllowLeadingZero), + bob.check_code().to_digit(DigitMode::AllowLeadingZero) + ); let message = bob.encrypt(b"Another plaintext"); @@ -630,7 +633,7 @@ mod test { #[test] fn check_code() { let check_code = CheckCode { bytes: [0x0, 0x0] }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); assert_eq!(digit, 0, "Two zero bytes should generate a 0 digit"); assert_eq!( check_code.as_bytes(), @@ -639,7 +642,7 @@ mod test { ); let check_code = CheckCode { bytes: [0x9, 0x9] }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); assert_eq!( check_code.as_bytes(), &[0x9, 0x9], @@ -648,7 +651,7 @@ mod test { assert_eq!(digit, 99); let check_code = CheckCode { bytes: [0xff, 0xff] }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); assert_eq!( check_code.as_bytes(), &[0xff, 0xff], @@ -713,7 +716,7 @@ mod test { bytes }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); prop_assert!( (0..=99).contains(&digit), diff --git a/src/hpke/check_code.rs b/src/hpke/check_code.rs index 0537735ba..1143e5b64 100644 --- a/src/hpke/check_code.rs +++ b/src/hpke/check_code.rs @@ -37,6 +37,24 @@ pub struct CheckCode { pub(crate) bytes: [u8; 2], } +/// Enum controlling how the [`CheckCode`] should be represented as digits. +pub enum DigitMode { + /// Allow a zero to be the first digit. + /// + /// This corresponds to the way the digits were used in the initial version + /// of [MSC4108]. + /// + /// [MSC4108]: https://github.com/matrix-org/matrix-spec-proposals/pull/4108 + AllowLeadingZero, + + /// Don't allow a leading zero. + /// + /// This corresponds to the way the digits are defined in [MSC4388]. + /// + /// [MSC4388]: https://github.com/matrix-org/matrix-spec-proposals/pull/4388 + NoLeadingZero, +} + impl CheckCode { /// Convert the check code to an array of two bytes. /// @@ -54,14 +72,17 @@ impl CheckCode { /// # Examples /// /// ```no_run - /// # use vodozemac::hpke::CheckCode; + /// # use vodozemac::hpke::{CheckCode, DigitMode}; /// # let check_code: CheckCode = unimplemented!(); - /// let check_code = check_code.to_digit(); + /// let check_code = check_code.to_digit(DigitMode::NoLeadingZero); /// /// println!("The check code of the HPKE channel is: {check_code:02}"); /// ``` - pub const fn to_digit(&self) -> u8 { - let first = (self.bytes[0] % 10) * 10; + pub const fn to_digit(&self, mode: DigitMode) -> u8 { + let first = match mode { + DigitMode::AllowLeadingZero => (self.bytes[0] % 10) * 10, + DigitMode::NoLeadingZero => ((self.bytes[0] % 9) + 1) * 10, + }; let second = self.bytes[1] % 10; first + second @@ -75,9 +96,9 @@ mod tests { use super::*; #[test] - fn check_code() { + fn check_code_with_leading_zero() { let check_code = CheckCode { bytes: [0x0, 0x0] }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); assert_eq!(digit, 0, "Two zero bytes should generate a 0 digit"); assert_eq!( check_code.as_bytes(), @@ -86,7 +107,7 @@ mod tests { ); let check_code = CheckCode { bytes: [0x9, 0x9] }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); assert_eq!( check_code.as_bytes(), &[0x9, 0x9], @@ -95,7 +116,7 @@ mod tests { assert_eq!(digit, 99); let check_code = CheckCode { bytes: [0xff, 0xff] }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); assert_eq!( check_code.as_bytes(), &[0xff, 0xff], @@ -104,14 +125,58 @@ mod tests { assert_eq!(digit, 55, "u8::MAX should generate 55"); } + #[test] + fn check_code_no_leading_zero() { + let check_code = CheckCode { bytes: [0x0, 0x0] }; + let digit = check_code.to_digit(DigitMode::NoLeadingZero); + assert_eq!(digit, 10, "Two zero bytes should generate a 10 digit"); + assert_eq!( + check_code.as_bytes(), + &[0x0, 0x0], + "CheckCode::as_bytes() should return the exact bytes we generated." + ); + + let check_code = CheckCode { bytes: [0x8, 0x9] }; + let digit = check_code.to_digit(DigitMode::NoLeadingZero); + assert_eq!( + check_code.as_bytes(), + &[0x8, 0x9], + "CheckCode::as_bytes() should return the exact bytes we generated." + ); + assert_eq!(digit, 99); + + let check_code = CheckCode { bytes: [0xff, 0xff] }; + let digit = check_code.to_digit(DigitMode::NoLeadingZero); + assert_eq!( + check_code.as_bytes(), + &[0xff, 0xff], + "CheckCode::as_bytes() should return the exact bytes we generated." + ); + assert_eq!(digit, 45, "u8::MAX should generate 45"); + } + proptest! { #[test] - fn check_code_proptest(bytes in prop::array::uniform2(0u8..) ) { + fn check_code_proptest_with_leading_zero(bytes in prop::array::uniform2(0u8..) ) { + let check_code = CheckCode { + bytes + }; + + let digit = check_code.to_digit(DigitMode::AllowLeadingZero); + + prop_assert!( + (0..=99).contains(&digit), + "The digit should be in the 0-99 range" + ); + } + + #[test] + fn check_code_proptest_no_leading_zero(bytes in prop::array::uniform2(0u8..) ) { let check_code = CheckCode { bytes }; - let digit = check_code.to_digit(); + let digit = check_code.to_digit(DigitMode::NoLeadingZero); prop_assert!( (0..=99).contains(&digit), From d330d3d8dba1a7d478865f3501a667919d092100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 27 Feb 2026 10:07:30 +0100 Subject: [PATCH 16/18] chore: Bump hpke --- Cargo.toml | 3 ++- src/hpke/sender.rs | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index db46d18a4..5469fc8be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ ed25519-dalek = { version = "3.0.0-pre.6", default-features = false, features = getrandom = "0.4.1" hkdf = "0.13.0-rc.5" hmac = "0.13.0-rc.5" -hpke = { git = "https://github.com/rozbb/rust-hpke/", rev = "08b1cfc142e914b637f929a8b93bd6bab8a63d03", features = ["alloc", "x25519", "hazmat-streaming-enc"] } +hpke = { git = "https://github.com/poljar/rust-hpke/", rev = "85f5efbe", features = ["alloc", "x25519", "hazmat-streaming-enc"] } matrix-pickle = { version = "0.2.2" } prost = "0.14.3" rand = "0.10.0" @@ -83,6 +83,7 @@ insta = "1.46.3" [patch.crates-io] olm-rs = { git = "https://github.com/poljar/olm-rs", rev = "9f7108c3b852c39bf1d0f09ccaafb9a1cf7bb83e" } +universal-hash = { git = "https://github.com/RustCrypto/traits", rev = "b7a3bbfef1bea8fe765a9203c3956f997c9a43f9" } [[bench]] name = "olm_benchmark" diff --git a/src/hpke/sender.rs b/src/hpke/sender.rs index ab5314676..5b2201bbe 100644 --- a/src/hpke/sender.rs +++ b/src/hpke/sender.rs @@ -15,7 +15,6 @@ use hpke::{ Deserializable as _, OpModeS, Serializable as _, aead::AeadCtxS, kem::X25519HkdfSha256, }; -use rand::rng; use crate::{ Curve25519PublicKey, @@ -77,7 +76,6 @@ impl HpkeSenderChannel { ) -> SenderCreationResult { let Self { application_info_prefix } = self; - let mut rng = rng(); let their_key = convert_public_key(their_public_key); #[allow(clippy::expect_used)] @@ -85,7 +83,6 @@ impl HpkeSenderChannel { &OpModeS::Base, &their_key, application_info_prefix.as_bytes(), - &mut rng, ) .expect("Encapsulating an X25519 public key never fails since the encapsulation is just the bytes of the public key"); From f17e2bd42b47378be0fd161396b9ee7b4dceb84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Mon, 9 Mar 2026 15:50:25 +0100 Subject: [PATCH 17/18] chore: More dep bumps --- Cargo.toml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5469fc8be..7bada7587 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,15 +48,15 @@ low-level-api = [] [dependencies] aes = { version = "0.9.0-rc.4" } -cipher = { version = "0.5.0", features = ["alloc"] } +cipher = { version = "0.5.1", features = ["alloc"] } arrayvec = { version = "0.7.6", features = ["serde"] } base64 = "0.22.1" base64ct = { version = "1.8.3", features = ["std", "alloc"] } -cbc = { version = "0.2.0-rc.3" } +cbc = { version = "0.2.0-rc.4" } chacha20poly1305 = "0.11.0-rc.3" curve25519-dalek = { version = "5.0.0-pre.6", default-features = false, features = ["zeroize"] } ed25519-dalek = { version = "3.0.0-pre.6", default-features = false, features = ["rand_core", "serde", "hazmat", "zeroize"] } -getrandom = "0.4.1" +getrandom = "0.4.2" hkdf = "0.13.0-rc.5" hmac = "0.13.0-rc.5" hpke = { git = "https://github.com/poljar/rust-hpke/", rev = "85f5efbe", features = ["alloc", "x25519", "hazmat-streaming-enc"] } @@ -73,7 +73,7 @@ x25519-dalek = { version = "3.0.0-pre.6", features = ["serde", "reusable_secrets zeroize = { version = "1.8.2", features = ["derive"] } [dev-dependencies] -anyhow = "1.0.101" +anyhow = "1.0.102" assert_matches2 = "0.1.2" criterion = { version = "4.3.0", package = "codspeed-criterion-compat" } ntest = "0.9.5" @@ -83,7 +83,6 @@ insta = "1.46.3" [patch.crates-io] olm-rs = { git = "https://github.com/poljar/olm-rs", rev = "9f7108c3b852c39bf1d0f09ccaafb9a1cf7bb83e" } -universal-hash = { git = "https://github.com/RustCrypto/traits", rev = "b7a3bbfef1bea8fe765a9203c3956f997c9a43f9" } [[bench]] name = "olm_benchmark" From 7de70ace2bd1a408c648ff52d2bcf30f503a1443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Jeli=C4=87?= Date: Fri, 13 Mar 2026 12:45:00 +0100 Subject: [PATCH 18/18] fix: Bubble encapsulation errors up instead of panicking --- src/hpke/error.rs | 3 +++ src/hpke/mod.rs | 27 ++++++++++++++++----------- src/hpke/recipient.rs | 5 +++-- src/hpke/sender.rs | 26 +++++++++++--------------- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/hpke/error.rs b/src/hpke/error.rs index 855e9f370..9dda8d102 100644 --- a/src/hpke/error.rs +++ b/src/hpke/error.rs @@ -38,4 +38,7 @@ pub enum Error { /// was replayed, or the wrong key is being used to decrypt the message. #[error("Failed decrypting the message")] Decryption, + /// The encapsulation of the initial message failed. + #[error("Failed decrypting the message")] + Encapsulation(#[from] hpke::HpkeError), } diff --git a/src/hpke/mod.rs b/src/hpke/mod.rs index c49b81ecb..6c0dd0e79 100644 --- a/src/hpke/mod.rs +++ b/src/hpke/mod.rs @@ -27,7 +27,7 @@ //! let bob = HpkeRecipientChannel::new(); //! //! let SenderCreationResult { channel: mut alice, message } = alice -//! .establish_channel(bob.public_key(), plaintext, &[]); +//! .establish_channel(bob.public_key(), plaintext, &[])?; //! //! let RecipientCreationResult { channel: mut bob, message } = bob.establish_channel(&message, &[])?; //! @@ -319,8 +319,9 @@ mod tests { let plaintext = b"It's a secret to everybody"; - let SenderCreationResult { message, .. } = - alice.establish_channel(bob.public_key(), plaintext, &[]); + let SenderCreationResult { message, .. } = alice + .establish_channel(bob.public_key(), plaintext, &[]) + .expect("We should be able to create the sender channel"); assert_ne!(message.ciphertext, plaintext); @@ -340,8 +341,9 @@ mod tests { let plaintext = b"It's a secret to everybody"; - let SenderCreationResult { channel: alice, message, .. } = - alice.establish_channel(bob_public_key, plaintext, &[]); + let SenderCreationResult { channel: alice, message, .. } = alice + .establish_channel(bob_public_key, plaintext, &[]) + .expect("We should be able to create the sender channel"); assert_ne!(message.ciphertext, plaintext); @@ -406,8 +408,9 @@ mod tests { let bob = HpkeRecipientChannel::new(); let malory = Curve25519SecretKey::new(); - let SenderCreationResult { mut message, .. } = - alice.establish_channel(bob.public_key(), plaintext, &[]); + let SenderCreationResult { mut message, .. } = alice + .establish_channel(bob.public_key(), plaintext, &[]) + .expect("We should be able to create the sender channel"); message.encapsulated_key = Curve25519PublicKey::from(&malory); @@ -428,8 +431,9 @@ mod tests { let alice = HpkeSenderChannel::new(); let bob = HpkeRecipientChannel::new(); - let SenderCreationResult { channel: alice, message } = - alice.establish_channel(bob.public_key(), b"", &[]); + let SenderCreationResult { channel: alice, message } = alice + .establish_channel(bob.public_key(), b"", &[]) + .expect("We should be able to create the sender channel"); let RecipientCreationResult { channel: bob, message: _ } = bob .establish_channel(&message, &[]) @@ -463,8 +467,9 @@ mod tests { let alice = HpkeSenderChannel::new(); let bob = HpkeRecipientChannel::new(); - let SenderCreationResult { channel: alice, message } = - alice.establish_channel(bob.public_key(), b"", &[]); + let SenderCreationResult { channel: alice, message } = alice + .establish_channel(bob.public_key(), b"", &[]) + .expect("We should be able to create the sender channel"); let RecipientCreationResult { channel: bob, .. } = bob.establish_channel(&message, &[]).unwrap(); diff --git a/src/hpke/recipient.rs b/src/hpke/recipient.rs index e15ef0c99..373bf2d54 100644 --- a/src/hpke/recipient.rs +++ b/src/hpke/recipient.rs @@ -241,8 +241,9 @@ mod tests { assert_debug_snapshot!(bob); - let SenderCreationResult { message, .. } = - alice.establish_channel(bob.public_key(), b"", &[]); + let SenderCreationResult { message, .. } = alice + .establish_channel(bob.public_key(), b"", &[]) + .expect("We should be able to create the sender channel"); let RecipientCreationResult { channel: mut bob, .. } = bob.establish_channel(&message, &[]).unwrap(); diff --git a/src/hpke/sender.rs b/src/hpke/sender.rs index 5b2201bbe..849ff38ed 100644 --- a/src/hpke/sender.rs +++ b/src/hpke/sender.rs @@ -73,23 +73,15 @@ impl HpkeSenderChannel { their_public_key: Curve25519PublicKey, initial_plaintext: &[u8], aad: &[u8], - ) -> SenderCreationResult { + ) -> Result { let Self { application_info_prefix } = self; let their_key = convert_public_key(their_public_key); - #[allow(clippy::expect_used)] - let (encapsulated_key, mut context) = hpke::setup_sender( - &OpModeS::Base, - &their_key, - application_info_prefix.as_bytes(), - ) - .expect("Encapsulating an X25519 public key never fails since the encapsulation is just the bytes of the public key"); + let (encapsulated_key, mut context) = + hpke::setup_sender(&OpModeS::Base, &their_key, application_info_prefix.as_bytes())?; - #[allow(clippy::expect_used)] - let ciphertext = context - .seal(initial_plaintext, aad) - .expect("We should be able to seal the initial plaintext"); + let ciphertext = context.seal(initial_plaintext, aad)?; let encapsulated_key = convert_encapsulated_key(encapsulated_key); let our_public_key = encapsulated_key; @@ -101,7 +93,10 @@ impl HpkeSenderChannel { their_public_key, }); - SenderCreationResult { channel, message: InitialMessage { encapsulated_key, ciphertext } } + Ok(SenderCreationResult { + channel, + message: InitialMessage { encapsulated_key, ciphertext }, + }) } } @@ -201,8 +196,9 @@ mod tests { let alice = HpkeSenderChannel::new(); let bob = HpkeRecipientChannel::new(); - let SenderCreationResult { channel: mut alice, .. } = - alice.establish_channel(bob.public_key(), b"", &[]); + let SenderCreationResult { channel: mut alice, .. } = alice + .establish_channel(bob.public_key(), b"", &[]) + .expect("We should be able to create the initial sender channel"); alice.0.our_public_key = key; alice.0.their_public_key = key;