mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-28 03:59:27 +00:00
243 lines
9.9 KiB
Rust
243 lines
9.9 KiB
Rust
//! Account ↔ LocalIdentity binding carried inside the MLS leaf credential.
|
|
//!
|
|
//! A group message in MLS is signed by a per-device key — the **LocalIdentity**
|
|
//! (a device/installation). On its own that key says nothing about which
|
|
//! **Account** the device belongs to, so multiple LocalIdentities of one Account
|
|
//! cannot be collapsed back to that Account on the receiving side.
|
|
//!
|
|
//! This module binds the two together *inside the credential itself*, so a
|
|
//! receiver resolves both without any network round-trip or trusted directory:
|
|
//!
|
|
//! - the MLS leaf's signature key is the LocalIdentity device key (MLS already
|
|
//! proves the sender holds its private half), and
|
|
//! - the credential content carries the Account key plus the Account's signature
|
|
//! endorsing that exact device key.
|
|
//!
|
|
//! [`resolve_sender`] verifies the endorsement against the device key the leaf
|
|
//! actually signs with, yielding a [`MessageSender`] the application can trust.
|
|
//! This is the inverse of the account → device directory in `libchat`
|
|
//! (`account_directory`): that resolves an Account *to* its devices for invites;
|
|
//! this resolves a device *back to* its Account on receipt.
|
|
|
|
use crypto::{Ed25519Signature, Ed25519VerifyingKey};
|
|
use shared_traits::IdentId;
|
|
use thiserror::Error;
|
|
|
|
/// Current credential content version. Bump when [`encode_credential`] changes.
|
|
pub const CREDENTIAL_VERSION: u8 = 1;
|
|
|
|
/// Domain-separation tag prepended to the credential content and folded into the
|
|
/// endorsement message. The account key may sign other things (e.g. the device
|
|
/// bundle in `account_directory`), so binding to this exact purpose stops a
|
|
/// signature obtained elsewhere from being replayed here. The trailing NUL keeps
|
|
/// it from being a prefix of any other domain.
|
|
pub const CREDENTIAL_DOMAIN: &[u8] = b"libchat:account-local-identity\0";
|
|
|
|
/// The verified sender of a group message: which Account, and which device
|
|
/// (LocalIdentity) of that Account it was sent from. Both are hex-encoded
|
|
/// Ed25519 verifying keys.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct MessageSender {
|
|
/// The Account the sending device belongs to.
|
|
pub account: IdentId,
|
|
/// The specific LocalIdentity (device/installation) that sent the message.
|
|
pub local_identity: IdentId,
|
|
}
|
|
|
|
/// Failures decoding or verifying an account-bound credential.
|
|
#[derive(Debug, Error)]
|
|
pub enum CredentialError {
|
|
#[error("credential is missing the account-local-identity domain prefix")]
|
|
Domain,
|
|
#[error("credential shorter than its declared layout")]
|
|
Short,
|
|
#[error("unsupported credential version {0}")]
|
|
Version(u8),
|
|
#[error("credential carries a malformed account key")]
|
|
AccountKey,
|
|
#[error("account endorsement of the local identity failed verification")]
|
|
SignatureInvalid,
|
|
}
|
|
|
|
/// The bytes the Account signs to endorse `device` as one of its LocalIdentities.
|
|
///
|
|
/// `account` is folded in alongside the device key so the signature is bound to
|
|
/// the specific (account, device) pair, not just the device key in isolation.
|
|
fn endorsement_message(account: &Ed25519VerifyingKey, device: &Ed25519VerifyingKey) -> Vec<u8> {
|
|
let mut msg = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 32 + 32);
|
|
msg.extend_from_slice(CREDENTIAL_DOMAIN);
|
|
msg.extend_from_slice(account.as_ref());
|
|
msg.extend_from_slice(device.as_ref());
|
|
msg
|
|
}
|
|
|
|
/// Produce the Account's endorsement of `device`, using the Account's signing
|
|
/// capability. `sign` is the account authority's signer (a local key on testnet,
|
|
/// or an external wallet/enclave), so the closure is fallible.
|
|
pub fn endorse_local_identity<E>(
|
|
account: &Ed25519VerifyingKey,
|
|
device: &Ed25519VerifyingKey,
|
|
sign: impl FnOnce(&[u8]) -> Result<Ed25519Signature, E>,
|
|
) -> Result<Ed25519Signature, E> {
|
|
sign(&endorsement_message(account, device))
|
|
}
|
|
|
|
/// Encode the MLS credential content that binds a LocalIdentity to its Account.
|
|
///
|
|
/// The device key is *not* embedded: it is the leaf's signature key, supplied
|
|
/// out-of-band by MLS on the receiving side and passed to [`resolve_sender`].
|
|
///
|
|
/// ```text
|
|
/// domain : CREDENTIAL_DOMAIN (constant prefix, NUL-terminated)
|
|
/// version : u8 (1 byte)
|
|
/// account_pub : [u8; 32] (32 bytes)
|
|
/// endorsement : [u8; 64] (64 bytes) — account signature over endorsement_message
|
|
/// ```
|
|
pub fn encode_credential(account: &Ed25519VerifyingKey, endorsement: &Ed25519Signature) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 1 + 32 + 64);
|
|
out.extend_from_slice(CREDENTIAL_DOMAIN);
|
|
out.push(CREDENTIAL_VERSION);
|
|
out.extend_from_slice(account.as_ref());
|
|
out.extend_from_slice(endorsement.as_ref());
|
|
out
|
|
}
|
|
|
|
/// Decode `credential_content` and verify the Account's endorsement against
|
|
/// `device_key` — the key the MLS leaf actually signs with. On success the
|
|
/// caller learns both the LocalIdentity (`device_key`) and the Account that
|
|
/// vouches for it.
|
|
pub fn resolve_sender(
|
|
credential_content: &[u8],
|
|
device_key: &Ed25519VerifyingKey,
|
|
) -> Result<MessageSender, CredentialError> {
|
|
const HEADER: usize = 1 + 32 + 64;
|
|
let rest = credential_content
|
|
.strip_prefix(CREDENTIAL_DOMAIN)
|
|
.ok_or(CredentialError::Domain)?;
|
|
if rest.len() < HEADER {
|
|
return Err(CredentialError::Short);
|
|
}
|
|
let version = rest[0];
|
|
if version != CREDENTIAL_VERSION {
|
|
return Err(CredentialError::Version(version));
|
|
}
|
|
|
|
let account_bytes: [u8; 32] = rest[1..33].try_into().expect("33 - 1 == 32");
|
|
let account =
|
|
Ed25519VerifyingKey::from_bytes(&account_bytes).map_err(|_| CredentialError::AccountKey)?;
|
|
let sig_bytes: [u8; 64] = rest[33..97].try_into().expect("97 - 33 == 64");
|
|
let endorsement = Ed25519Signature::from(sig_bytes);
|
|
|
|
// Verifying under the account key, over a message that includes the *leaf's*
|
|
// device key, is what binds this device to this account. A credential lifted
|
|
// from another sender won't verify against the device key MLS hands us here.
|
|
account
|
|
.verify(&endorsement_message(&account, device_key), &endorsement)
|
|
.map_err(|_| CredentialError::SignatureInvalid)?;
|
|
|
|
Ok(MessageSender {
|
|
account: IdentId::new(hex::encode(account.as_ref())),
|
|
local_identity: IdentId::new(hex::encode(device_key.as_ref())),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crypto::Ed25519SigningKey;
|
|
use std::convert::Infallible;
|
|
|
|
/// Sign with the account key, resolve under the device key: both identities
|
|
/// come back, hex-encoded.
|
|
#[test]
|
|
fn resolves_well_formed_credential() {
|
|
let account_key = Ed25519SigningKey::generate();
|
|
let account_pub = account_key.verifying_key();
|
|
let device_key = Ed25519SigningKey::generate();
|
|
let device_pub = device_key.verifying_key();
|
|
|
|
let endorsement = endorse_local_identity::<Infallible>(&account_pub, &device_pub, |m| {
|
|
Ok(account_key.sign(m))
|
|
})
|
|
.unwrap();
|
|
let content = encode_credential(&account_pub, &endorsement);
|
|
|
|
let sender = resolve_sender(&content, &device_pub).unwrap();
|
|
assert_eq!(sender.account.as_str(), hex::encode(account_pub.as_ref()));
|
|
assert_eq!(
|
|
sender.local_identity.as_str(),
|
|
hex::encode(device_pub.as_ref())
|
|
);
|
|
}
|
|
|
|
/// On testnet the account key *is* the device key (one device == one
|
|
/// account); resolution then reports the same id for both.
|
|
#[test]
|
|
fn single_key_account_resolves_to_itself() {
|
|
let key = Ed25519SigningKey::generate();
|
|
let pubkey = key.verifying_key();
|
|
|
|
let endorsement =
|
|
endorse_local_identity::<Infallible>(&pubkey, &pubkey, |m| Ok(key.sign(m))).unwrap();
|
|
let content = encode_credential(&pubkey, &endorsement);
|
|
|
|
let sender = resolve_sender(&content, &pubkey).unwrap();
|
|
assert_eq!(sender.account, sender.local_identity);
|
|
}
|
|
|
|
/// An endorsement for device A, presented with device B's key (as MLS would
|
|
/// hand it over if B forged a leaf), fails: the signature covers A's key.
|
|
#[test]
|
|
fn rejects_endorsement_for_a_different_device() {
|
|
let account_key = Ed25519SigningKey::generate();
|
|
let account_pub = account_key.verifying_key();
|
|
let device_a = Ed25519SigningKey::generate().verifying_key();
|
|
let device_b = Ed25519SigningKey::generate().verifying_key();
|
|
|
|
let endorsement = endorse_local_identity::<Infallible>(&account_pub, &device_a, |m| {
|
|
Ok(account_key.sign(m))
|
|
})
|
|
.unwrap();
|
|
let content = encode_credential(&account_pub, &endorsement);
|
|
|
|
assert!(matches!(
|
|
resolve_sender(&content, &device_b),
|
|
Err(CredentialError::SignatureInvalid)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_missing_domain_short_and_bad_version() {
|
|
let account_key = Ed25519SigningKey::generate();
|
|
let account_pub = account_key.verifying_key();
|
|
let device = Ed25519SigningKey::generate().verifying_key();
|
|
let endorsement =
|
|
endorse_local_identity::<Infallible>(
|
|
&account_pub,
|
|
&device,
|
|
|m| Ok(account_key.sign(m)),
|
|
)
|
|
.unwrap();
|
|
let content = encode_credential(&account_pub, &endorsement);
|
|
|
|
// Strip the domain prefix.
|
|
assert!(matches!(
|
|
resolve_sender(&content[CREDENTIAL_DOMAIN.len()..], &device),
|
|
Err(CredentialError::Domain)
|
|
));
|
|
// Domain present but body truncated.
|
|
let short = &content[..CREDENTIAL_DOMAIN.len() + 4];
|
|
assert!(matches!(
|
|
resolve_sender(short, &device),
|
|
Err(CredentialError::Short)
|
|
));
|
|
// Wrong version byte (first byte after the domain prefix).
|
|
let mut bad_version = content.clone();
|
|
bad_version[CREDENTIAL_DOMAIN.len()] = 99;
|
|
assert!(matches!(
|
|
resolve_sender(&bad_version, &device),
|
|
Err(CredentialError::Version(99))
|
|
));
|
|
}
|
|
}
|