use crate::{Result, SealedError};
use alloc::vec::Vec;
use offline_protocol_core::Message;
pub const CTRL_SIG_META_KEY: &str = "__ctrl_sig";
pub const CTRL_PK_META_KEY: &str = "__ctrl_pk";
pub const CTRL_SIGN_DOMAIN: &[u8] = b"offline-ctrl-v1";
pub const CTRL_SIGN_DOMAIN_V2: &[u8] = b"offline-ctrl-v2";
pub fn canonical_payload(domain: &[u8], fields: &[&[u8]]) -> Result<Vec<u8>> {
let mut buf =
Vec::with_capacity(domain.len() + fields.iter().map(|f| 4 + f.len()).sum::<usize>());
buf.extend_from_slice(domain);
for field in fields {
let len: u32 = field
.len()
.try_into()
.map_err(|_| SealedError::FieldTooLarge(field.len()))?;
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(field);
}
Ok(buf)
}
pub fn control_signing_payload(message: &Message) -> Result<Vec<u8>> {
let id = message.id.as_str();
let fields: [&[u8]; 4] = [
message.sender.as_str().as_bytes(),
id.as_bytes(),
message.recipient.as_str().as_bytes(),
message.content.as_bytes(),
];
canonical_payload(CTRL_SIGN_DOMAIN, &fields)
}
pub fn control_signing_payload_v2(message: &Message) -> Result<Vec<u8>> {
let id = message.id.as_str();
let stamped = message.timestamp.as_millis().to_be_bytes();
let fields: [&[u8]; 5] = [
message.sender.as_str().as_bytes(),
id.as_bytes(),
message.recipient.as_str().as_bytes(),
message.content.as_bytes(),
&stamped,
];
canonical_payload(CTRL_SIGN_DOMAIN_V2, &fields)
}
pub const GATEWAY_ADDR_SIGN_DOMAIN: &[u8] = b"offline-gateway-addr-v1";
pub fn gateway_address_proof_payload(address: &str, challenge: &[u8]) -> Result<Vec<u8>> {
let mut payload = canonical_payload(GATEWAY_ADDR_SIGN_DOMAIN, &[address.as_bytes()])?;
payload.extend_from_slice(challenge);
Ok(payload)
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
use offline_protocol_core::{AppId, MessageId, Timestamp, UserId};
#[test]
fn canonical_payload_length_prefixes_every_field() {
let payload = canonical_payload(b"dom", &[b"ab", b"c"]).expect("payload");
assert_eq!(payload, b"dom\x00\x00\x00\x02ab\x00\x00\x00\x01c".to_vec());
}
#[test]
fn canonical_payload_distinguishes_field_splits() {
let a = canonical_payload(b"dom", &[b"ab", b"c"]).expect("payload");
let b = canonical_payload(b"dom", &[b"a", b"bc"]).expect("payload");
assert_ne!(a, b);
}
#[test]
fn canonical_payload_distinguishes_domains() {
let a = canonical_payload(b"dom-a", &[b"x"]).expect("payload");
let b = canonical_payload(b"dom-b", &[b"x"]).expect("payload");
assert_ne!(a, b);
}
#[test]
fn control_domain_has_its_published_spelling() {
assert_eq!(CTRL_SIGN_DOMAIN, b"offline-ctrl-v1");
assert_eq!(CTRL_SIGN_DOMAIN_V2, b"offline-ctrl-v2");
assert_eq!(CTRL_SIG_META_KEY, "__ctrl_sig");
assert_eq!(CTRL_PK_META_KEY, "__ctrl_pk");
}
#[test]
fn the_two_control_domains_are_mutually_non_prefixing() {
assert!(!CTRL_SIGN_DOMAIN_V2.starts_with(CTRL_SIGN_DOMAIN));
assert!(!CTRL_SIGN_DOMAIN.starts_with(CTRL_SIGN_DOMAIN_V2));
}
#[test]
fn the_gateway_domain_has_its_published_spelling() {
assert_eq!(GATEWAY_ADDR_SIGN_DOMAIN, b"offline-gateway-addr-v1");
}
#[test]
fn the_challenge_is_not_length_prefixed() {
let challenge = [9u8; 32];
let payload = gateway_address_proof_payload("off1abc", &challenge).expect("payload");
assert_eq!(
payload.len(),
GATEWAY_ADDR_SIGN_DOMAIN.len() + 4 + "off1abc".len() + challenge.len()
);
assert!(payload.ends_with(&challenge));
let doubly_prefixed =
canonical_payload(GATEWAY_ADDR_SIGN_DOMAIN, &[b"off1abc", &challenge]).expect("alt");
assert_ne!(payload, doubly_prefixed);
}
#[test]
fn the_address_prefix_makes_the_payload_unambiguous() {
let a = gateway_address_proof_payload("ab", b"cX").expect("a");
let b = gateway_address_proof_payload("abc", b"X").expect("b");
assert_ne!(a, b);
}
#[test]
fn the_gateway_and_relay_domains_do_not_collide() {
const RELAY_ADDR_SIGN_DOMAIN: &[u8] = b"offline-relay-addr-v1";
let challenge = [3u8; 32];
let gateway = gateway_address_proof_payload("off1abc", &challenge).expect("gateway");
let mut relay =
canonical_payload(RELAY_ADDR_SIGN_DOMAIN, &[b"off1abc"]).expect("relay payload");
relay.extend_from_slice(&challenge);
assert_ne!(gateway, relay);
assert!(!GATEWAY_ADDR_SIGN_DOMAIN.starts_with(RELAY_ADDR_SIGN_DOMAIN));
assert!(!RELAY_ADDR_SIGN_DOMAIN.starts_with(GATEWAY_ADDR_SIGN_DOMAIN));
}
fn control_frame(stamp_ms: i64) -> Message {
let mut message = Message::from_parts(
MessageId::from_bytes([7u8; 16]),
UserId::new("off1alice").expect("sender"),
UserId::new("off1bob").expect("recipient"),
AppId::new("test").expect("app"),
"__MLS_KEY_PKG__{}",
Timestamp::from_millis(stamp_ms),
);
message
.metadata
.insert("unsigned".into(), "rewritten in flight".into());
message
}
#[test]
fn the_two_payloads_differ_for_one_frame() {
let message = control_frame(1_700_000_000_000);
let v1 = control_signing_payload(&message).expect("v1");
let v2 = control_signing_payload_v2(&message).expect("v2");
assert_ne!(v1, v2);
}
#[test]
fn the_v2_payload_changes_with_the_timestamp() {
let early = control_signing_payload_v2(&control_frame(1_700_000_000_000)).expect("early");
let late = control_signing_payload_v2(&control_frame(1_700_000_000_001)).expect("late");
assert_ne!(early, late);
}
#[test]
fn the_v1_payload_is_blind_to_the_timestamp() {
let early = control_signing_payload(&control_frame(1_700_000_000_000)).expect("early");
let late = control_signing_payload(&control_frame(1_900_000_000_000)).expect("late");
assert_eq!(early, late);
}
#[test]
fn the_v2_payload_ends_with_the_stamp_in_network_order() {
let stamp: i64 = 1_700_000_000_000;
let payload = control_signing_payload_v2(&control_frame(stamp)).expect("v2");
let mut tail = [0u8; 12];
tail[..4].copy_from_slice(&8u32.to_be_bytes());
tail[4..].copy_from_slice(&stamp.to_be_bytes());
assert!(payload.ends_with(&tail));
assert!(payload.starts_with(CTRL_SIGN_DOMAIN_V2));
}
#[test]
fn a_negative_stamp_encodes() {
let payload = control_signing_payload_v2(&control_frame(-1)).expect("v2");
assert!(payload.ends_with(&(-1i64).to_be_bytes()));
}
}
#[cfg(all(test, feature = "std"))]
mod spec_vectors {
use super::*;
use offline_protocol_core::{AppId, Message, MessageId, Timestamp, UserId};
const VECTORS: &str = include_str!("../tests/data/control-signing-v1.vectors.json");
fn vectors() -> serde_json::Value {
serde_json::from_str(VECTORS).expect("the vector file is JSON")
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn the_vector_file_is_the_size_it_was() {
let v = vectors();
assert_eq!(v["payloads"].as_array().expect("payloads").len(), 6);
assert_eq!(v["domains"]["live"].as_array().expect("live").len(), 6);
assert_eq!(
v["domains"]["reserved"].as_array().expect("reserved").len(),
0,
"every registered domain is live; a new reserved one needs a row here"
);
}
#[test]
fn every_payload_matches_its_vector_through_the_primitive() {
for case in vectors()["payloads"].as_array().expect("payloads") {
let name = case["name"].as_str().expect("a name");
let sender = case["sender"].as_str().expect("sender");
let id = case["id"].as_str().expect("id");
let recipient = case["recipient"].as_str().expect("recipient");
let content = case["content"].as_str().expect("content");
let stamp = case["timestamp_ms"].as_i64().expect("timestamp");
let v1 = canonical_payload(
CTRL_SIGN_DOMAIN,
&[
sender.as_bytes(),
id.as_bytes(),
recipient.as_bytes(),
content.as_bytes(),
],
)
.expect("v1 builds");
assert_eq!(
hex(&v1),
case["v1_hex"].as_str().expect("v1"),
"[{name}] v1 payload"
);
let stamped = stamp.to_be_bytes();
let v2 = canonical_payload(
CTRL_SIGN_DOMAIN_V2,
&[
sender.as_bytes(),
id.as_bytes(),
recipient.as_bytes(),
content.as_bytes(),
&stamped,
],
)
.expect("v2 builds");
assert_eq!(
hex(&v2),
case["v2_hex"].as_str().expect("v2"),
"[{name}] v2 payload"
);
}
}
#[test]
fn the_builders_agree_with_the_vectors() {
let mut exercised = 0;
for case in vectors()["payloads"].as_array().expect("payloads") {
let name = case["name"].as_str().expect("a name");
let recipient = case["recipient"].as_str().expect("recipient");
if recipient.is_empty() {
continue;
}
exercised += 1;
let m = Message::from_parts(
MessageId::from_str(case["id"].as_str().expect("id")).expect("a uuid"),
UserId::new(case["sender"].as_str().expect("sender")).expect("a sender"),
UserId::new(recipient).expect("a recipient"),
AppId::new("com.example.chat").expect("an app id"),
case["content"].as_str().expect("content"),
Timestamp::from_millis(case["timestamp_ms"].as_i64().expect("timestamp")),
);
assert_eq!(
hex(&control_signing_payload(&m).expect("v1")),
case["v1_hex"].as_str().expect("v1"),
"[{name}] the v1 builder disagrees with the chapter"
);
assert_eq!(
hex(&control_signing_payload_v2(&m).expect("v2")),
case["v2_hex"].as_str().expect("v2"),
"[{name}] the v2 builder disagrees with the chapter"
);
}
assert!(exercised >= 5, "the builders were barely exercised");
}
#[test]
fn appending_the_stamp_does_not_produce_the_v2_payload() {
let v = vectors();
let case = &v["v1_is_not_v2_with_a_stamp"];
assert_ne!(
case["v1_with_stamp_appended_hex"]
.as_str()
.expect("appended"),
case["v2_hex"].as_str().expect("v2"),
"the two domains have collapsed into one"
);
}
#[test]
fn the_domain_registry_matches_the_code() {
let v = vectors();
assert_eq!(
v["domains"]["v1"].as_str().expect("v1"),
core::str::from_utf8(CTRL_SIGN_DOMAIN).expect("utf8")
);
assert_eq!(
v["domains"]["v2"].as_str().expect("v2"),
core::str::from_utf8(CTRL_SIGN_DOMAIN_V2).expect("utf8")
);
let mut all: Vec<String> = Vec::new();
for key in ["live", "reserved"] {
for d in v["domains"][key].as_array().expect("a domain list") {
all.push(d.as_str().expect("a domain").to_string());
}
}
for a in &all {
for b in &all {
if a != b {
assert!(
!a.starts_with(b.as_str()),
"signing domain {b} is a prefix of {a}, which lets a \
signature made under one verify under the other"
);
}
}
}
}
fn chapter(name: &str) -> Option<String> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/spec")
.join(name);
std::fs::read_to_string(&path).ok().or_else(|| {
eprintln!("spec tree not present, skipping the {name} drift checks");
None
})
}
#[test]
fn the_chapter_states_the_payloads_the_code_builds() {
let Some(text) = chapter("control-messages.md") else {
return;
};
for domain in [CTRL_SIGN_DOMAIN, CTRL_SIGN_DOMAIN_V2] {
let domain = core::str::from_utf8(domain).expect("utf8");
assert!(
text.contains(domain),
"the chapter no longer names the {domain} payload"
);
}
assert!(
text.contains("u32be(len(sender))"),
"the chapter no longer states the length-prefixed construction"
);
}
#[test]
fn the_published_registry_lists_every_domain_this_file_pins() {
let Some(text) = chapter("username-discovery.md") else {
return;
};
let v = vectors();
for key in ["live", "reserved"] {
for d in v["domains"][key].as_array().expect("a domain list") {
let domain = d.as_str().expect("a domain");
assert!(
text.contains(domain),
"the signing-domain registry does not list {domain}"
);
}
}
}
}
#[cfg(all(test, feature = "std"))]
mod gateway_proof_vectors {
use super::*;
const VECTORS: &str = include_str!("../tests/data/gateway-address-proof-v1.vectors.json");
fn vectors() -> serde_json::Value {
serde_json::from_str(VECTORS).expect("the vector file is JSON")
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn unhex(text: &str) -> Vec<u8> {
assert!(text.len().is_multiple_of(2), "hex has an odd length");
(0..text.len())
.step_by(2)
.map(|i| u8::from_str_radix(&text[i..i + 2], 16).expect("hex digit"))
.collect()
}
#[test]
fn the_vector_file_is_the_size_it_was() {
let v = vectors();
assert_eq!(v["proofs"].as_array().expect("proofs").len(), 3);
assert_eq!(
v["domain"].as_str().expect("domain"),
"offline-gateway-addr-v1"
);
assert_eq!(
v["layout"].as_str().expect("layout"),
"domain || u32be(len(address_utf8)) || address_utf8 || challenge"
);
}
#[test]
fn every_proof_matches_its_vector() {
for case in vectors()["proofs"].as_array().expect("proofs") {
let name = case["name"].as_str().expect("a name");
let address = case["address"].as_str().expect("address");
let challenge = unhex(case["challenge_hex"].as_str().expect("challenge"));
let payload = gateway_address_proof_payload(address, &challenge).expect("payload");
assert_eq!(
hex(&payload),
case["payload_hex"].as_str().expect("payload"),
"[{name}] gateway attach proof"
);
}
}
#[test]
fn the_length_prefix_is_pinned_by_a_colliding_pair() {
let v = vectors();
let block = &v["the_length_prefix_makes_the_payload_unambiguous"];
let mut seen = Vec::new();
for key in ["a", "b"] {
let address = block[key]["address"].as_str().expect("address");
let challenge = unhex(block[key]["challenge_hex"].as_str().expect("challenge"));
let payload = gateway_address_proof_payload(address, &challenge).expect("payload");
assert_eq!(
hex(&payload),
block[key]["payload_hex"].as_str().expect("payload"),
"[{key}] colliding-pair payload"
);
seen.push(payload);
}
assert_ne!(seen[0], seen[1], "the prefix is what separates them");
}
#[test]
fn the_domain_separates_the_two_attaches() {
let v = vectors();
let block = &v["the_domain_is_what_stops_a_cross_replay"];
let address = block["address"].as_str().expect("address");
let challenge = unhex(block["challenge_hex"].as_str().expect("challenge"));
let gateway = gateway_address_proof_payload(address, &challenge).expect("gateway");
assert_eq!(
hex(&gateway),
block["gateway_payload_hex"].as_str().expect("gateway hex")
);
assert_ne!(
hex(&gateway),
block["same_fields_under_the_relay_domain_hex"]
.as_str()
.expect("relay hex"),
"a shared domain would make a harvested proof replayable"
);
}
}