feat: verify KeyPackages published by a device

This commit is contained in:
Gabriel Lopez
2026-09-24 17:25:13 +02:00
parent 7d6cce18f6
commit 8044d04d9e
5 changed files with 2179 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: rustup toolchain install
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all --check
- run: cargo clippy --all-targets -- -D warnings
- uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov,cargo-deny
- run: cargo llvm-cov --fail-under-lines 80 --ignore-filename-regex 'main\.rs$'
- run: cargo deny check advisories
Generated
+1996
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "whispr-mls"
version = "0.1.0"
edition = "2024"
[dependencies]
openmls = "0.9"
openmls_rust_crypto = "0.6"
thiserror = "2.0.21"
[dev-dependencies]
openmls_basic_credential = "0.6"
[lints.rust]
unsafe_code = "forbid"
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.98.1"
components = ["clippy", "rustfmt", "llvm-tools-preview"]
+145
View File
@@ -0,0 +1,145 @@
//! MLS for Whispr, shared by the mobile app and the servers (ADR 0007 of `whispr-architecture`).
use openmls::prelude::{tls_codec::Deserialize, *};
use openmls_rust_crypto::OpenMlsRustCrypto;
/// The only ciphersuite Whispr accepts.
pub const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
/// What the Key directory keeps from a KeyPackage it accepted.
#[derive(Debug)]
pub struct VerifiedKeyPackage {
pub key_package_ref: Vec<u8>,
pub signature_key: Vec<u8>,
pub last_resort: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum KeyPackageError {
#[error("not a valid KeyPackage")]
Invalid,
#[error("the KeyPackage credential names another Device")]
ForeignCredential,
}
/// Checks a KeyPackage published by `device_id` (invariant K4).
pub fn verify_key_package(
bytes: &[u8],
device_id: &[u8],
) -> Result<VerifiedKeyPackage, KeyPackageError> {
let provider = OpenMlsRustCrypto::default();
let key_package = KeyPackageIn::tls_deserialize_exact(bytes)
.map_err(|_| KeyPackageError::Invalid)?
.validate(provider.crypto(), ProtocolVersion::Mls10)
.map_err(|_| KeyPackageError::Invalid)?;
if key_package.ciphersuite() != CIPHERSUITE {
return Err(KeyPackageError::Invalid);
}
let credential = BasicCredential::try_from(key_package.leaf_node().credential().clone())
.map_err(|_| KeyPackageError::Invalid)?;
if credential.identity() != device_id {
return Err(KeyPackageError::ForeignCredential);
}
Ok(VerifiedKeyPackage {
key_package_ref: key_package
.hash_ref(provider.crypto())
.map_err(|_| KeyPackageError::Invalid)?
.as_slice()
.to_vec(),
signature_key: key_package.leaf_node().signature_key().as_slice().to_vec(),
last_resort: key_package.last_resort(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use openmls::prelude::tls_codec::Serialize;
use openmls_basic_credential::SignatureKeyPair;
use openmls_rust_crypto::OpenMlsRustCrypto;
/// Serialized KeyPackage whose credential names `device_id`, as a Device would publish it.
fn key_package(device_id: &[u8], last_resort: bool) -> (Vec<u8>, Vec<u8>) {
key_package_with(CIPHERSUITE, device_id, last_resort)
}
fn key_package_with(
ciphersuite: Ciphersuite,
device_id: &[u8],
last_resort: bool,
) -> (Vec<u8>, Vec<u8>) {
let provider = OpenMlsRustCrypto::default();
let signer = SignatureKeyPair::new(ciphersuite.signature_algorithm()).unwrap();
let credential = CredentialWithKey {
credential: BasicCredential::new(device_id.to_vec()).into(),
signature_key: signer.to_public_vec().into(),
};
let mut builder = KeyPackage::builder().leaf_node_capabilities(Capabilities::new(
None,
None,
Some(&[ExtensionType::LastResort]),
None,
None,
));
if last_resort {
builder = builder.mark_as_last_resort();
}
let bytes = builder
.build(ciphersuite, &provider, &signer, credential)
.unwrap()
.key_package()
.tls_serialize_detached()
.unwrap();
(bytes, signer.to_public_vec())
}
#[test]
fn accepts_a_key_package_of_its_device() {
let (bytes, signature_key) = key_package(b"device-1", false);
let verified = verify_key_package(&bytes, b"device-1").unwrap();
assert_eq!(verified.signature_key, signature_key);
assert!(!verified.last_resort);
assert!(!verified.key_package_ref.is_empty());
}
#[test]
fn k4_rejects_a_key_package_naming_another_device() {
let (bytes, _) = key_package(b"device-2", false);
let error = verify_key_package(&bytes, b"device-1").unwrap_err();
assert!(matches!(error, KeyPackageError::ForeignCredential));
}
#[test]
fn recognises_a_last_resort_key_package() {
let (bytes, _) = key_package(b"device-1", true);
assert!(verify_key_package(&bytes, b"device-1").unwrap().last_resort);
}
#[test]
fn rejects_a_tampered_key_package() {
let (mut bytes, _) = key_package(b"device-1", false);
*bytes.last_mut().unwrap() ^= 1;
let error = verify_key_package(&bytes, b"device-1").unwrap_err();
assert!(matches!(error, KeyPackageError::Invalid));
}
#[test]
fn rejects_another_ciphersuite() {
let (bytes, _) = key_package_with(
Ciphersuite::MLS_128_DHKEMP256_AES128GCM_SHA256_P256,
b"device-1",
false,
);
let error = verify_key_package(&bytes, b"device-1").unwrap_err();
assert!(matches!(error, KeyPackageError::Invalid));
}
}