Files
quicproquo/crates/quicprochat-core/src/sealed_sender.rs
Christian Nennemann a710037dde chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto
package/module paths, ALPN strings, env var prefixes, config filenames,
mDNS service names, and plugin ABI symbols from quicproquo/qpq to
quicprochat/qpc.
2026-03-21 19:14:06 +01:00

156 lines
4.8 KiB
Rust

//! Sealed sender: embed sender identity + Ed25519 signature inside the MLS
//! application payload so recipients can verify the sender from decrypted
//! content, independent of MLS framing.
//!
//! # Wire format
//!
//! ```text
//! [magic: 1 byte (0x53 = 'S')]
//! [sender_identity_key: 32 bytes (Ed25519 public key)]
//! [signature: 64 bytes (Ed25519)]
//! [inner_payload: variable (the original app_message bytes)]
//! ```
//!
//! The signature covers: `magic || sender_identity_key || inner_payload`.
//! Total overhead: 1 + 32 + 64 = 97 bytes per message.
use crate::error::CoreError;
use crate::identity::IdentityKeypair;
/// Magic byte identifying a sealed sender envelope.
pub const SEALED_MAGIC: u8 = 0x53; // 'S'
/// Fixed overhead: magic(1) + sender_key(32) + signature(64).
const SEALED_OVERHEAD: usize = 1 + 32 + 64;
/// Wrap an app_message payload in a sealed sender envelope.
///
/// Signs `magic || sender_key || payload` with the sender's Ed25519 key.
pub fn seal(identity: &IdentityKeypair, app_message_bytes: &[u8]) -> Vec<u8> {
let sender_key = identity.public_key_bytes();
// Build signing input
let mut sign_input = Vec::with_capacity(1 + 32 + app_message_bytes.len());
sign_input.push(SEALED_MAGIC);
sign_input.extend_from_slice(&sender_key);
sign_input.extend_from_slice(app_message_bytes);
let signature = identity.sign_raw(&sign_input);
let mut out = Vec::with_capacity(SEALED_OVERHEAD + app_message_bytes.len());
out.push(SEALED_MAGIC);
out.extend_from_slice(&sender_key);
out.extend_from_slice(&signature);
out.extend_from_slice(app_message_bytes);
out
}
/// Unseal: verify the Ed25519 signature, return `(sender_identity_key, inner_app_message_bytes)`.
pub fn unseal(bytes: &[u8]) -> Result<([u8; 32], Vec<u8>), CoreError> {
if bytes.len() < SEALED_OVERHEAD {
return Err(CoreError::AppMessage(
"sealed sender envelope too short".into(),
));
}
if bytes[0] != SEALED_MAGIC {
return Err(CoreError::AppMessage(format!(
"sealed sender: expected magic 0x{:02X}, got 0x{:02X}",
SEALED_MAGIC, bytes[0]
)));
}
let mut sender_key = [0u8; 32];
sender_key.copy_from_slice(&bytes[1..33]);
let mut signature = [0u8; 64];
signature.copy_from_slice(&bytes[33..97]);
let inner_payload = &bytes[97..];
// Reconstruct signing input: magic || sender_key || inner_payload
let mut sign_input = Vec::with_capacity(1 + 32 + inner_payload.len());
sign_input.push(SEALED_MAGIC);
sign_input.extend_from_slice(&sender_key);
sign_input.extend_from_slice(inner_payload);
IdentityKeypair::verify_raw(&sender_key, &sign_input, &signature)?;
Ok((sender_key, inner_payload.to_vec()))
}
/// Check if bytes start with the sealed sender magic byte.
pub fn is_sealed(bytes: &[u8]) -> bool {
bytes.first() == Some(&SEALED_MAGIC)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn seal_unseal_round_trip() {
let identity = IdentityKeypair::generate();
let payload = b"hello sealed sender";
let sealed = seal(&identity, payload);
assert!(is_sealed(&sealed));
let (sender_key, inner) = unseal(&sealed).unwrap();
assert_eq!(sender_key, identity.public_key_bytes());
assert_eq!(inner, payload);
}
#[test]
fn unseal_tampered_payload_fails() {
let identity = IdentityKeypair::generate();
let payload = b"hello";
let mut sealed = seal(&identity, payload);
// Tamper with the inner payload
if let Some(last) = sealed.last_mut() {
*last ^= 0xFF;
}
assert!(unseal(&sealed).is_err());
}
#[test]
fn unseal_wrong_sender_fails() {
let alice = IdentityKeypair::generate();
let bob = IdentityKeypair::generate();
let payload = b"from alice";
let mut sealed = seal(&alice, payload);
// Replace sender key with Bob's
let bob_key = bob.public_key_bytes();
sealed[1..33].copy_from_slice(&bob_key);
assert!(unseal(&sealed).is_err());
}
#[test]
fn unseal_too_short_fails() {
assert!(unseal(&[SEALED_MAGIC; 10]).is_err());
}
#[test]
fn unseal_wrong_magic_fails() {
let identity = IdentityKeypair::generate();
let mut sealed = seal(&identity, b"test");
sealed[0] = 0x00;
assert!(unseal(&sealed).is_err());
}
#[test]
fn non_sealed_detected() {
assert!(!is_sealed(b"\x01\x01hello"));
assert!(is_sealed(&[SEALED_MAGIC, 0, 0]));
}
#[test]
fn empty_payload_round_trip() {
let identity = IdentityKeypair::generate();
let sealed = seal(&identity, b"");
let (sender_key, inner) = unseal(&sealed).unwrap();
assert_eq!(sender_key, identity.public_key_bytes());
assert!(inner.is_empty());
}
}