use std::str::FromStr;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use offline_protocol_core::{contains_control_or_format, Address};
use crate::canonical::canonical_payload;
use crate::error::{MlsError, Result};
use crate::manager::MlsManager;
pub const INVITE_SIGN_DOMAIN: &[u8] = b"offline-invite-v1";
pub const INVITE_VERSION: u8 = 1;
pub const MAX_PETNAME_BYTES: usize = 64;
const PUBLIC_KEY_LEN: usize = 32;
const SIGNATURE_LEN: usize = 64;
const FLAG_PETNAME: u8 = 0b0000_0001;
const FLAG_SIGNATURE: u8 = 0b0000_0010;
const KNOWN_FLAGS: u8 = FLAG_PETNAME | FLAG_SIGNATURE;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Invite {
pub address: Address,
pub public_key: Vec<u8>,
pub petname: Option<String>,
pub signed: bool,
}
pub fn encode_invite(
address: &Address,
public_key: &[u8],
petname: Option<&str>,
signature: Option<&[u8]>,
) -> Result<String> {
if public_key.len() != PUBLIC_KEY_LEN {
return Err(MlsError::InvalidPublicKey(format!(
"Ed25519 public key must be {} bytes, got {}",
PUBLIC_KEY_LEN,
public_key.len()
)));
}
let derived = MlsManager::derive_address(public_key)?;
if derived != *address {
return Err(MlsError::Serialization(
"Invite address is not the address this public key derives to".to_string(),
));
}
let petname_bytes = match petname {
Some("") => None,
Some(name) => {
if name.len() > MAX_PETNAME_BYTES {
return Err(MlsError::Serialization(format!(
"Invite petname is {} bytes, maximum is {}",
name.len(),
MAX_PETNAME_BYTES
)));
}
if contains_control_or_format(name) {
return Err(MlsError::Serialization(
"Invite petname contains a control or format character".to_string(),
));
}
Some(name.as_bytes())
}
None => None,
};
if let Some(sig) = signature {
if sig.len() != SIGNATURE_LEN {
return Err(MlsError::Serialization(format!(
"Invite signature must be {} bytes, got {}",
SIGNATURE_LEN,
sig.len()
)));
}
}
let address_string = address.to_string();
let address_bytes = address_string.as_bytes();
let mut flags = 0u8;
if petname_bytes.is_some() {
flags |= FLAG_PETNAME;
}
if signature.is_some() {
flags |= FLAG_SIGNATURE;
}
let mut blob = Vec::with_capacity(
2 + PUBLIC_KEY_LEN
+ 1
+ address_bytes.len()
+ petname_bytes.map_or(0, |p| 1 + p.len())
+ signature.map_or(0, |s| s.len()),
);
blob.push(INVITE_VERSION);
blob.push(flags);
blob.extend_from_slice(public_key);
blob.push(address_bytes.len() as u8);
blob.extend_from_slice(address_bytes);
if let Some(petname) = petname_bytes {
blob.push(petname.len() as u8);
blob.extend_from_slice(petname);
}
if let Some(sig) = signature {
blob.extend_from_slice(sig);
}
Ok(URL_SAFE_NO_PAD.encode(&blob))
}
pub fn invite_signing_payload(
address: &Address,
public_key: &[u8],
petname: Option<&str>,
) -> Result<Vec<u8>> {
let address_string = address.to_string();
let petname_bytes = petname.unwrap_or("").as_bytes();
canonical_payload(
INVITE_SIGN_DOMAIN,
&[
&[INVITE_VERSION],
address_string.as_bytes(),
public_key,
petname_bytes,
],
)
}
pub fn parse_invite(blob: &str) -> Result<Invite> {
let bytes = URL_SAFE_NO_PAD
.decode(blob.trim())
.map_err(|e| MlsError::InvalidMessage(format!("Invite is not base64url: {}", e)))?;
let mut cursor = Reader::new(&bytes);
let version = cursor.take_u8("version")?;
if version != INVITE_VERSION {
return Err(MlsError::InvalidMessage(format!(
"Unsupported invite version {}, expected {}",
version, INVITE_VERSION
)));
}
let flags = cursor.take_u8("flags")?;
if flags & !KNOWN_FLAGS != 0 {
return Err(MlsError::InvalidMessage(format!(
"Invite carries unknown flag bits: {:#010b}",
flags
)));
}
let public_key = cursor.take("public key", PUBLIC_KEY_LEN)?.to_vec();
let address_len = cursor.take_u8("address length")? as usize;
let address_bytes = cursor.take("address", address_len)?;
let address_str = std::str::from_utf8(address_bytes)
.map_err(|_| MlsError::InvalidMessage("Invite address is not UTF-8".to_string()))?;
let address = Address::from_str(address_str)
.map_err(|e| MlsError::InvalidMessage(format!("Invite address is invalid: {}", e)))?;
let petname = if flags & FLAG_PETNAME != 0 {
let len = cursor.take_u8("petname length")? as usize;
if len == 0 {
return Err(MlsError::InvalidMessage(
"Invite sets the petname flag but carries an empty petname".to_string(),
));
}
if len > MAX_PETNAME_BYTES {
return Err(MlsError::InvalidMessage(format!(
"Invite petname is {} bytes, maximum is {}",
len, MAX_PETNAME_BYTES
)));
}
let bytes = cursor.take("petname", len)?;
let name = std::str::from_utf8(bytes)
.map_err(|_| MlsError::InvalidMessage("Invite petname is not UTF-8".to_string()))?;
if contains_control_or_format(name) {
return Err(MlsError::InvalidMessage(
"Invite petname contains a control or format character".to_string(),
));
}
Some(name.to_string())
} else {
None
};
let signature = if flags & FLAG_SIGNATURE != 0 {
Some(cursor.take("signature", SIGNATURE_LEN)?.to_vec())
} else {
None
};
cursor.finish()?;
let derived = MlsManager::derive_address(&public_key)?;
if derived != address {
return Err(MlsError::VerificationFailed(
"Invite address is not the address its public key derives to".to_string(),
));
}
let signed = match signature {
Some(sig) => {
let payload = invite_signing_payload(&address, &public_key, petname.as_deref())?;
if !MlsManager::verify_signature(&public_key, &payload, &sig)? {
return Err(MlsError::VerificationFailed(
"Invite signature does not verify under its public key".to_string(),
));
}
true
}
None => false,
};
Ok(Invite {
address,
public_key,
petname,
signed,
})
}
struct Reader<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> Reader<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
fn take(&mut self, field: &str, len: usize) -> Result<&'a [u8]> {
let end = self.offset.checked_add(len).ok_or_else(|| {
MlsError::InvalidMessage(format!("Invite {} length overflows", field))
})?;
if end > self.bytes.len() {
return Err(MlsError::InvalidMessage(format!(
"Invite is truncated: {} needs {} bytes, {} remain",
field,
len,
self.bytes.len().saturating_sub(self.offset)
)));
}
let slice = &self.bytes[self.offset..end];
self.offset = end;
Ok(slice)
}
fn take_u8(&mut self, field: &str) -> Result<u8> {
Ok(self.take(field, 1)?[0])
}
fn finish(&self) -> Result<()> {
if self.offset != self.bytes.len() {
return Err(MlsError::InvalidMessage(format!(
"Invite has {} trailing bytes",
self.bytes.len() - self.offset
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
fn identity(seed: u8) -> (SigningKey, Vec<u8>, Address) {
let signing = SigningKey::from_bytes(&[seed; 32]);
let public = signing.verifying_key().to_bytes().to_vec();
let address = MlsManager::derive_address(&public).expect("derive");
(signing, public, address)
}
fn sign(signing: &SigningKey, payload: &[u8]) -> Vec<u8> {
signing.sign(payload).to_bytes().to_vec()
}
#[test]
fn invite_round_trips_unsigned_without_a_petname() {
let (_, public, address) = identity(1);
let blob = encode_invite(&address, &public, None, None).expect("encode");
let invite = parse_invite(&blob).expect("parse");
assert_eq!(invite.address, address);
assert_eq!(invite.public_key, public);
assert_eq!(invite.petname, None);
assert!(!invite.signed);
}
#[test]
fn invite_round_trips_with_a_petname() {
let (_, public, address) = identity(2);
let blob = encode_invite(&address, &public, Some("Alice"), None).expect("encode");
let invite = parse_invite(&blob).expect("parse");
assert_eq!(invite.petname.as_deref(), Some("Alice"));
assert!(!invite.signed);
}
#[test]
fn invite_round_trips_signed() {
let (signing, public, address) = identity(3);
let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
let signature = sign(&signing, &payload);
let blob =
encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode");
let invite = parse_invite(&blob).expect("parse");
assert_eq!(invite.petname.as_deref(), Some("Alice"));
assert!(invite.signed);
}
#[test]
fn invite_treats_an_empty_petname_as_absent() {
let (_, public, address) = identity(4);
let with_empty = encode_invite(&address, &public, Some(""), None).expect("encode");
let without = encode_invite(&address, &public, None, None).expect("encode");
assert_eq!(with_empty, without);
assert_eq!(parse_invite(&with_empty).expect("parse").petname, None);
}
#[test]
fn invite_refuses_an_address_that_is_not_the_keys() {
let (_, public_a, _) = identity(5);
let (_, _, address_b) = identity(6);
assert!(encode_invite(&address_b, &public_a, None, None).is_err());
let address_bytes = address_b.to_string();
let mut blob = vec![INVITE_VERSION, 0u8];
blob.extend_from_slice(&public_a);
blob.push(address_bytes.len() as u8);
blob.extend_from_slice(address_bytes.as_bytes());
let encoded = URL_SAFE_NO_PAD.encode(&blob);
assert!(matches!(
parse_invite(&encoded),
Err(MlsError::VerificationFailed(_))
));
}
#[test]
fn invite_refuses_a_petname_swapped_under_a_signature() {
let (signing, public, address) = identity(7);
let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
let signature = sign(&signing, &payload);
let relabeled =
encode_invite(&address, &public, Some("Bob"), Some(&signature)).expect("encode");
assert!(matches!(
parse_invite(&relabeled),
Err(MlsError::VerificationFailed(_))
));
}
#[test]
fn invite_refuses_a_signature_by_a_foreign_key() {
let (_, public, address) = identity(8);
let (foreign_signing, _, _) = identity(9);
let payload = invite_signing_payload(&address, &public, None).expect("payload");
let signature = sign(&foreign_signing, &payload);
let blob = encode_invite(&address, &public, None, Some(&signature)).expect("encode");
assert!(matches!(
parse_invite(&blob),
Err(MlsError::VerificationFailed(_))
));
}
#[test]
fn invite_refuses_an_unknown_version() {
let (_, public, address) = identity(10);
let blob = encode_invite(&address, &public, None, None).expect("encode");
let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
bytes[0] = 2;
assert!(matches!(
parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
Err(MlsError::InvalidMessage(_))
));
}
#[test]
fn invite_refuses_unknown_flag_bits() {
let (_, public, address) = identity(11);
let blob = encode_invite(&address, &public, None, None).expect("encode");
let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
bytes[1] |= 0b1000_0000;
assert!(matches!(
parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
Err(MlsError::InvalidMessage(_))
));
}
#[test]
fn invite_refuses_truncation_at_every_length() {
let (signing, public, address) = identity(12);
let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
let signature = sign(&signing, &payload);
let blob =
encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode");
let bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
for cut in 0..bytes.len() {
let truncated = URL_SAFE_NO_PAD.encode(&bytes[..cut]);
assert!(
parse_invite(&truncated).is_err(),
"a blob truncated to {} bytes must not parse",
cut
);
}
}
#[test]
fn invite_refuses_trailing_bytes() {
let (_, public, address) = identity(13);
let blob = encode_invite(&address, &public, None, None).expect("encode");
let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
bytes.push(0);
assert!(matches!(
parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
Err(MlsError::InvalidMessage(_))
));
}
#[test]
fn invite_refuses_an_over_long_petname() {
let (_, public, address) = identity(14);
let long = "a".repeat(MAX_PETNAME_BYTES + 1);
assert!(encode_invite(&address, &public, Some(&long), None).is_err());
}
#[test]
fn invite_refuses_a_petname_that_renders_as_another_name() {
let (signing, public, address) = identity(15);
for (label, name) in [
("right-to-left override", "ali\u{202E}ce"),
("zero-width joiner", "ali\u{200D}ce"),
("soft hyphen", "ali\u{00AD}ce"),
("byte-order mark", "ali\u{FEFF}ce"),
("newline", "ali\nce"),
] {
assert!(
encode_invite(&address, &public, Some(name), None).is_err(),
"a {label} petname must not be mintable"
);
let payload = invite_signing_payload(&address, &public, Some(name)).expect("payload");
let signature = sign(&signing, &payload);
let address_bytes = address.to_string();
let mut blob = vec![INVITE_VERSION, FLAG_PETNAME | FLAG_SIGNATURE];
blob.extend_from_slice(&public);
blob.push(address_bytes.len() as u8);
blob.extend_from_slice(address_bytes.as_bytes());
blob.push(name.len() as u8);
blob.extend_from_slice(name.as_bytes());
blob.extend_from_slice(&signature);
assert!(
matches!(
parse_invite(&URL_SAFE_NO_PAD.encode(&blob)),
Err(MlsError::InvalidMessage(_))
),
"a {label} petname must be refused at parse even when signed"
);
}
}
#[test]
fn invite_allows_ordinary_international_petnames() {
let (_, public, address) = identity(16);
for name in ["Alice", "José", "上田", "أحمد", "Ann Lee"] {
let blob = encode_invite(&address, &public, Some(name), None).expect("encode");
assert_eq!(
parse_invite(&blob).expect("parse").petname.as_deref(),
Some(name)
);
}
}
#[test]
fn invite_golden_vectors() {
let (signing, public, address) = identity(1);
assert_eq!(
address.to_string(),
"off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0",
"the golden identity's address changed"
);
let unsigned = encode_invite(&address, &public, None, None).expect("encode");
assert_eq!(
unsigned,
"AQCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMA"
);
let named = encode_invite(&address, &public, Some("alice"), None).expect("encode");
assert_eq!(
named,
"AQGKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAVhbGljZQ"
);
let payload = invite_signing_payload(&address, &public, Some("alice")).expect("payload");
assert_eq!(
base64::engine::general_purpose::STANDARD.encode(&payload),
"b2ZmbGluZS1pbnZpdGUtdjEAAAABAQAAACxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAAAACCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXAAAAAVhbGljZQ=="
);
let signature = sign(&signing, &payload);
let signed =
encode_invite(&address, &public, Some("alice"), Some(&signature)).expect("encode");
assert!(parse_invite(&signed).expect("parse").signed);
assert_eq!(signed.len(), 199, "signed invite length changed");
}
}