feat: verify KeyPackages published by a device #1
@@ -0,0 +1,27 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
# Les images du runner Gitea n'embarquent pas rustup.
|
||||||
|
- run: |
|
||||||
|
command -v rustup || {
|
||||||
|
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain none
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
}
|
||||||
|
- 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
File diff suppressed because it is too large
Load Diff
+14
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
{ id = "RUSTSEC-2026-0173", reason = "proc-macro-error2 unmaintained, transitive via openmls_rust_crypto; no upgrade available" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.98.1"
|
||||||
|
components = ["clippy", "rustfmt", "llvm-tools-preview"]
|
||||||
+145
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user