mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-26 23:51:14 +00:00
fix: signer-scoped DirectV1 routing (#162)
Core is no longer account-aware: the client resolves an account address to signer ids via the account directory, and the signer's verifying-key hex serves as registry key, inbox subscription, and Welcome routing target end to end. The MLS credential stays the full id(). - GroupV2 reads the de-mls member id from the fetched key package and maps it to the signer id the welcome is delivered to. - All account machinery (directory trait, bundle codec, resolution) moves out of core into logos-account; the RegistrationService supertrait and Core::account_directory() are gone, and the client holds its own directory handle. - The account exposes functionality, never a signer: add_delegate_signer does the lamport upsert and signs internally. - Every client acts for an account (ChatClientBuilder::new(account)). DelegateSigner is a pure keypair; the client composes the wire credential from the signer and the account, so the association is client state. addr() is the account address. - resolve_device_ids fails fast (NotAnAccountKey / NoDeviceBundle / Directory) instead of falling back to treating an unresolved address as a signer id. LogosChatClient::open and chat-cli mint and publish a dev account each launch. - EphemeralRegistry keys key packages by hex pubkey like HttpRegistry. Supersedes #155 (routing_id).
This commit is contained in:
@@ -1,420 +0,0 @@
|
||||
//! Account → device directory: traits and the signed device-list bundle codec.
|
||||
//!
|
||||
//! An Account (AccountAddress, an Ed25519 key) endorses a set of device
|
||||
//! (LocalIdentity) public keys by signing a bundle. The directory service stores
|
||||
//! one such bundle per account so that an inviter can resolve an account public
|
||||
//! key to every device it must invite.
|
||||
//!
|
||||
//! Two roles are kept distinct from the per-device [`IdentityProvider`]:
|
||||
//!
|
||||
//! - [`AccountAuthority`] — the injected account key. Custody (wallet, enclave,
|
||||
//! another device) stays outside libchat; we only ever ask it to sign. Present
|
||||
//! only where the user authorizes a device change.
|
||||
//! - [`AccountDirectory`] — the client that publishes and fetches+verifies the
|
||||
//! bundle against the directory service.
|
||||
//!
|
||||
//! The bundle `payload` is opaque to the server. Both the signing side
|
||||
//! ([`encode_bundle_payload`]) and the verifying side ([`verify_bundle`]) live
|
||||
//! here so they cannot drift apart.
|
||||
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use crypto::{Ed25519Signature, Ed25519VerifyingKey};
|
||||
use shared_traits::IdentIdRef;
|
||||
use thiserror::Error;
|
||||
|
||||
/// A device (LocalIdentity) verifying key, hex-encoded — the same shape as the
|
||||
/// keypackage registry's `device_id`, so values flow straight into
|
||||
/// [`KeyPackageProvider::retrieve`](crate::service_traits::KeyPackageProvider).
|
||||
pub type DeviceId = String;
|
||||
|
||||
/// The account's monotonic version counter, bumped on every membership change.
|
||||
/// The directory server reads it from the signed payload and rejects a publish
|
||||
/// whose lamport is not strictly higher than the stored one, so an older bundle
|
||||
/// can't be replayed to downgrade the device list. Consumers also keep the
|
||||
/// highest value seen per account and reject anything lower as defence in depth.
|
||||
pub type Lamport = u64;
|
||||
|
||||
/// Current bundle payload version. Bump when the layout in
|
||||
/// [`encode_bundle_payload`] changes.
|
||||
pub const BUNDLE_VERSION: u8 = 1;
|
||||
|
||||
/// Domain-separation tag prepended to every signed payload. The account key may
|
||||
/// live in an external signer (wallet/enclave) that signs other things too, so
|
||||
/// binding the signature to this exact purpose stops a signature obtained
|
||||
/// elsewhere from being replayed as a device-bundle signature (and vice-versa).
|
||||
/// It is a fixed constant prefix — not a field separator — so it adds no parsing
|
||||
/// ambiguity. The trailing NUL keeps it from being a prefix of any other domain.
|
||||
pub const BUNDLE_DOMAIN: &[u8] = b"libchat:account-device-bundle\0";
|
||||
|
||||
/// The signed device-list bundle. The `payload` bytes are exactly
|
||||
/// what [`AccountAuthority::sign`] signed, so verifiers check the
|
||||
/// signature over the same bytes they received.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignedDeviceBundle {
|
||||
/// The account verifying key this bundle belongs to. Used for addressing on
|
||||
/// publish; on verify the caller supplies the expected account key separately
|
||||
/// and the signature is checked under it.
|
||||
pub account_pub: Ed25519VerifyingKey,
|
||||
/// Canonical signed bytes — see [`encode_bundle_payload`].
|
||||
pub payload: Vec<u8>,
|
||||
/// Account signature over `payload`.
|
||||
pub signature: Ed25519Signature,
|
||||
}
|
||||
|
||||
/// The verified result of a directory fetch: an account's device set at a given
|
||||
/// version. Produced only after the account signature has been checked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DeviceSet {
|
||||
pub lamport: Lamport,
|
||||
/// Device verifying keys, hex-encoded, ready for keypackage retrieval.
|
||||
pub devices: Vec<DeviceId>,
|
||||
}
|
||||
|
||||
/// The account capability, injected by the platform.
|
||||
///
|
||||
/// Custody of the account key stays outside libchat — the library only ever asks
|
||||
/// it to sign a device-list bundle. The same trait covers a local on-device key
|
||||
/// (testnet) and an external signer (wallet/enclave), which is why [`sign`] is
|
||||
/// fallible: an external signer can be offline or decline the prompt.
|
||||
///
|
||||
/// Verification needs no authority — anyone holding the account verifying key
|
||||
/// verifies with [`verify_bundle`].
|
||||
///
|
||||
/// [`sign`]: AccountAuthority::sign
|
||||
pub trait AccountAuthority {
|
||||
type Error: Display + Debug;
|
||||
|
||||
/// The account verifying key identifying this participant.
|
||||
fn account_pub(&self) -> &Ed25519VerifyingKey;
|
||||
/// Sign the canonical bundle bytes with the account key.
|
||||
fn sign(&self, payload: &[u8]) -> Result<Ed25519Signature, Self::Error>;
|
||||
}
|
||||
|
||||
/// Client for the account → device directory service.
|
||||
///
|
||||
/// Mirrors [`RegistrationService`](crate::service_traits::RegistrationService):
|
||||
/// an injected trait in core with an HTTP implementation in the extension layer.
|
||||
/// The service is untrusted, so [`fetch`](AccountDirectory::fetch) verifies the
|
||||
/// account signature before returning a [`DeviceSet`].
|
||||
pub trait AccountDirectory: Debug {
|
||||
type Error: Display + Debug;
|
||||
|
||||
/// Upsert the signed device list for an account, replacing any previous one.
|
||||
fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error>;
|
||||
|
||||
/// Fetch and verify the device set for `account`. `Ok(None)` means the
|
||||
/// account has never published — callers fall back to legacy 1:1 resolution.
|
||||
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, Self::Error>;
|
||||
}
|
||||
|
||||
/// Failures decoding or verifying a [`SignedDeviceBundle`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BundleError {
|
||||
#[error("payload shorter than its declared layout")]
|
||||
Short,
|
||||
#[error("payload is missing the account-device-bundle domain prefix")]
|
||||
Domain,
|
||||
#[error("unsupported bundle version {0}")]
|
||||
Version(u8),
|
||||
#[error("account signature verification failed")]
|
||||
SignatureInvalid,
|
||||
}
|
||||
|
||||
/// The decoded (but not yet signature-verified) contents of a bundle payload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedBundle {
|
||||
pub lamport: Lamport,
|
||||
pub devices: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Canonical binary payload — the bytes that are both signed and transmitted.
|
||||
/// Opaque to the server; decoded only by consumers:
|
||||
///
|
||||
/// ```text
|
||||
/// domain : BUNDLE_DOMAIN (constant prefix, NUL-terminated)
|
||||
/// version : u8 (1 byte)
|
||||
/// lamport : u64 LE (8 bytes)
|
||||
/// count : u16 LE (2 bytes) — number of device keys that follow
|
||||
/// devices : [u8; 32] * count (32 * count bytes)
|
||||
/// ```
|
||||
///
|
||||
/// Fixed-width fields with an explicit `count` make every byte string parse
|
||||
/// exactly one way. The [`BUNDLE_DOMAIN`] prefix binds the signature to this
|
||||
/// purpose (see its docs). The account key is *not* embedded: the account is
|
||||
/// identified out-of-band by the account verifying key the caller requests, and
|
||||
/// [`verify_bundle`] checks the signature under that key — so a bundle for one
|
||||
/// account cannot be passed off as another's.
|
||||
pub fn encode_bundle_payload(lamport: Lamport, devices: &[Ed25519VerifyingKey]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(BUNDLE_DOMAIN.len() + 1 + 8 + 2 + devices.len() * 32);
|
||||
out.extend_from_slice(BUNDLE_DOMAIN);
|
||||
out.push(BUNDLE_VERSION);
|
||||
out.extend_from_slice(&lamport.to_le_bytes());
|
||||
out.extend_from_slice(&(devices.len() as u16).to_le_bytes());
|
||||
for device in devices {
|
||||
out.extend_from_slice(device.as_ref());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_bundle_payload`]. Strips the domain prefix, then validates
|
||||
/// the version and that the declared device count matches the remaining bytes
|
||||
/// exactly.
|
||||
pub fn decode_bundle_payload(payload: &[u8]) -> Result<DecodedBundle, BundleError> {
|
||||
const HEADER: usize = 1 + 8 + 2;
|
||||
let payload = payload
|
||||
.strip_prefix(BUNDLE_DOMAIN)
|
||||
.ok_or(BundleError::Domain)?;
|
||||
if payload.len() < HEADER {
|
||||
return Err(BundleError::Short);
|
||||
}
|
||||
let version = payload[0];
|
||||
if version != BUNDLE_VERSION {
|
||||
return Err(BundleError::Version(version));
|
||||
}
|
||||
let lamport = u64::from_le_bytes(payload[1..9].try_into().expect("9 - 1 == 8"));
|
||||
let count = u16::from_le_bytes(payload[9..11].try_into().expect("11 - 9 == 2")) as usize;
|
||||
|
||||
let body = &payload[HEADER..];
|
||||
if body.len() != count * 32 {
|
||||
return Err(BundleError::Short);
|
||||
}
|
||||
let devices = body
|
||||
.chunks_exact(32)
|
||||
.map(|c| c.try_into().expect("chunks_exact(32) yields 32 bytes"))
|
||||
.collect();
|
||||
|
||||
Ok(DecodedBundle { lamport, devices })
|
||||
}
|
||||
|
||||
/// Decode `bundle`, confirm it belongs to `expected_account`, and verify the
|
||||
/// account signature over the exact payload bytes. Returns the verified
|
||||
/// [`DeviceSet`] (device keys hex-encoded for keypackage retrieval).
|
||||
pub fn verify_bundle(
|
||||
expected_account: &Ed25519VerifyingKey,
|
||||
bundle: &SignedDeviceBundle,
|
||||
) -> Result<DeviceSet, BundleError> {
|
||||
let decoded = decode_bundle_payload(&bundle.payload)?;
|
||||
|
||||
// Verifying the signature under the *requested* account key is what binds the
|
||||
// bundle to that account: another account's validly-signed bundle won't verify
|
||||
// under this key, so an untrusted server cannot substitute one.
|
||||
expected_account
|
||||
.verify(&bundle.payload, &bundle.signature)
|
||||
.map_err(|_| BundleError::SignatureInvalid)?;
|
||||
|
||||
Ok(DeviceSet {
|
||||
lamport: decoded.lamport,
|
||||
devices: decoded.devices.iter().map(hex::encode).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve an account to the device ids whose KeyPackages must be fetched.
|
||||
///
|
||||
/// The directory is keyed by the account verifying key. When `account` is the hex
|
||||
/// of such a key and a bundle exists, returns its verified device set. Otherwise
|
||||
/// falls back to treating the identifier itself as a single device id — the
|
||||
/// pre-directory behaviour — so opaque or never-published ids keep working.
|
||||
pub fn resolve_device_ids<D: AccountDirectory + ?Sized>(
|
||||
directory: &D,
|
||||
account: IdentIdRef,
|
||||
) -> Result<Vec<DeviceId>, D::Error> {
|
||||
if let Some(account_key) = account_key_from_id(account)
|
||||
&& let Some(set) = directory.fetch(&account_key)?
|
||||
{
|
||||
return Ok(set.devices);
|
||||
}
|
||||
Ok(vec![account.to_string()])
|
||||
}
|
||||
|
||||
/// Interpret an identity id as the hex of an account verifying key, if it is one.
|
||||
fn account_key_from_id(id: IdentIdRef) -> Option<Ed25519VerifyingKey> {
|
||||
let bytes: [u8; 32] = hex::decode(id.as_str()).ok()?.try_into().ok()?;
|
||||
Ed25519VerifyingKey::from_bytes(&bytes).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crypto::Ed25519SigningKey;
|
||||
use shared_traits::IdentId;
|
||||
|
||||
/// encode → decode round-trips, including zero and many devices.
|
||||
#[test]
|
||||
fn payload_roundtrips() {
|
||||
let devices: Vec<_> = (0..3)
|
||||
.map(|_| Ed25519SigningKey::generate().verifying_key())
|
||||
.collect();
|
||||
|
||||
let payload = encode_bundle_payload(7, &devices);
|
||||
let decoded = decode_bundle_payload(&payload).unwrap();
|
||||
|
||||
assert_eq!(decoded.lamport, 7);
|
||||
let want: Vec<[u8; 32]> = devices
|
||||
.iter()
|
||||
.map(|d| d.as_ref().try_into().unwrap())
|
||||
.collect();
|
||||
assert_eq!(decoded.devices, want);
|
||||
|
||||
// Empty device set is valid (an account with no devices).
|
||||
let empty = encode_bundle_payload(0, &[]);
|
||||
assert!(decode_bundle_payload(&empty).unwrap().devices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_short_and_truncated() {
|
||||
// A domain-prefixed payload too short to hold the header.
|
||||
let mut short = BUNDLE_DOMAIN.to_vec();
|
||||
short.extend_from_slice(&[0u8; 5]);
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(&short),
|
||||
Err(BundleError::Short)
|
||||
));
|
||||
|
||||
let device = Ed25519SigningKey::generate().verifying_key();
|
||||
let mut payload = encode_bundle_payload(1, &[device]);
|
||||
payload.pop(); // drop a device byte: count no longer matches the body
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(&payload),
|
||||
Err(BundleError::Short)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_missing_domain() {
|
||||
// Bytes that would be a valid body but lack the domain prefix.
|
||||
let payload = encode_bundle_payload(1, &[]);
|
||||
let without_domain = &payload[BUNDLE_DOMAIN.len()..];
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(without_domain),
|
||||
Err(BundleError::Domain)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_bad_version() {
|
||||
let mut payload = encode_bundle_payload(1, &[]);
|
||||
payload[BUNDLE_DOMAIN.len()] = 99; // first byte after the domain prefix
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(&payload),
|
||||
Err(BundleError::Version(99))
|
||||
));
|
||||
}
|
||||
|
||||
/// Full happy path: sign with the account key, verify under the account key.
|
||||
#[test]
|
||||
fn verify_accepts_well_formed_bundle() {
|
||||
let account_key = Ed25519SigningKey::generate();
|
||||
let account_pub = account_key.verifying_key();
|
||||
let devices: Vec<_> = (0..2)
|
||||
.map(|_| Ed25519SigningKey::generate().verifying_key())
|
||||
.collect();
|
||||
|
||||
let payload = encode_bundle_payload(42, &devices);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub: account_pub.clone(),
|
||||
signature: account_key.sign(&payload),
|
||||
payload,
|
||||
};
|
||||
|
||||
let set = verify_bundle(&account_pub, &bundle).unwrap();
|
||||
assert_eq!(set.lamport, 42);
|
||||
assert_eq!(set.devices.len(), 2);
|
||||
assert_eq!(set.devices[0], hex::encode(devices[0].as_ref()));
|
||||
}
|
||||
|
||||
/// A bundle validly signed by account A, served as the answer to a query for
|
||||
/// account B, fails: B's key does not verify A's signature. This is the
|
||||
/// anti-substitution guarantee, now resting entirely on the signature check.
|
||||
#[test]
|
||||
fn verify_rejects_wrong_account() {
|
||||
let account_key = Ed25519SigningKey::generate();
|
||||
let account_pub = account_key.verifying_key();
|
||||
let payload = encode_bundle_payload(1, &[]);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub,
|
||||
signature: account_key.sign(&payload),
|
||||
payload,
|
||||
};
|
||||
|
||||
let other = Ed25519SigningKey::generate().verifying_key();
|
||||
assert!(matches!(
|
||||
verify_bundle(&other, &bundle),
|
||||
Err(BundleError::SignatureInvalid)
|
||||
));
|
||||
}
|
||||
|
||||
/// Minimal in-test directory so `resolve_device_ids` can be exercised
|
||||
/// without pulling in the `components` crate.
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeDir(Option<SignedDeviceBundle>);
|
||||
|
||||
impl AccountDirectory for FakeDir {
|
||||
type Error = BundleError;
|
||||
fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error> {
|
||||
self.0 = Some(bundle.clone());
|
||||
Ok(())
|
||||
}
|
||||
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, Self::Error> {
|
||||
self.0
|
||||
.as_ref()
|
||||
.map(|b| verify_bundle(account, b))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
/// No published bundle → fall back to the identifier as a single device id.
|
||||
#[test]
|
||||
fn resolve_falls_back_to_account_id() {
|
||||
let account = IdentId::new("pax");
|
||||
let resolved = resolve_device_ids(&FakeDir(None), &account).unwrap();
|
||||
assert_eq!(resolved, vec![account.to_string()]);
|
||||
}
|
||||
|
||||
/// A published bundle → resolve to its verified device ids (hex pubkeys).
|
||||
#[test]
|
||||
fn resolve_returns_published_devices() {
|
||||
let account_key = Ed25519SigningKey::generate();
|
||||
let account_pub = account_key.verifying_key();
|
||||
let devices: Vec<_> = (0..2)
|
||||
.map(|_| Ed25519SigningKey::generate().verifying_key())
|
||||
.collect();
|
||||
|
||||
let payload = encode_bundle_payload(1, &devices);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub: account_pub.clone(),
|
||||
signature: account_key.sign(&payload),
|
||||
payload,
|
||||
};
|
||||
|
||||
// The identifier is the hex of the account key, so resolution consults the
|
||||
// directory rather than falling back.
|
||||
let account_id = IdentId::new(hex::encode(account_pub.as_ref()));
|
||||
let resolved = resolve_device_ids(&FakeDir(Some(bundle)), &account_id).unwrap();
|
||||
let want: Vec<String> = devices.iter().map(|d| hex::encode(d.as_ref())).collect();
|
||||
assert_eq!(resolved, want);
|
||||
}
|
||||
|
||||
/// Tampering with any payload byte breaks verification.
|
||||
#[test]
|
||||
fn verify_rejects_tampered_payload() {
|
||||
let account_key = Ed25519SigningKey::generate();
|
||||
let account_pub = account_key.verifying_key();
|
||||
let device = Ed25519SigningKey::generate().verifying_key();
|
||||
|
||||
let payload = encode_bundle_payload(1, std::slice::from_ref(&device));
|
||||
let signature = account_key.sign(&payload);
|
||||
|
||||
// Re-encode with a different lamport, keep the old signature.
|
||||
let tampered = encode_bundle_payload(2, &[device]);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub: account_pub.clone(),
|
||||
payload: tampered,
|
||||
signature,
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_bundle(&account_pub, &bundle),
|
||||
Err(BundleError::SignatureInvalid)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ use shared_traits::IdentIdRef;
|
||||
use std::collections::VecDeque;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::account_directory::{AccountDirectory, resolve_device_ids};
|
||||
use crate::conversation::ConversationIdRef;
|
||||
use crate::inbox_v2::MlsProvider;
|
||||
use crate::service_context::{ExternalServices, ServiceContext};
|
||||
@@ -137,38 +136,27 @@ impl GroupV1Convo {
|
||||
Self::delivery_address_from_id(&self.convo_id)
|
||||
}
|
||||
|
||||
/// Resolve an account to a KeyPackage for *every* device it authorizes.
|
||||
///
|
||||
/// First resolves the account to its device ids through the account
|
||||
/// directory ([`resolve_device_ids`]), then fetches each device's
|
||||
/// KeyPackage. When the account never published a bundle, resolution falls
|
||||
/// back to a single device id equal to the account id — the pre-directory
|
||||
/// behaviour — so single-device accounts are unaffected.
|
||||
fn key_packages_for_account(
|
||||
/// Fetch a signer's KeyPackage from the registry. Members are signer
|
||||
/// (installation) ids; resolving an account to its signers is the caller's
|
||||
/// concern, above the core.
|
||||
fn key_package_for_signer(
|
||||
&self,
|
||||
ident: IdentIdRef,
|
||||
signer: IdentIdRef,
|
||||
provider: &impl MlsProvider,
|
||||
registry: &(impl KeyPackageProvider + AccountDirectory),
|
||||
) -> Result<Vec<KeyPackage>, ChatError> {
|
||||
let device_ids =
|
||||
resolve_device_ids(registry, ident).map_err(|e| ChatError::Generic(e.to_string()))?;
|
||||
registry: &impl KeyPackageProvider,
|
||||
) -> Result<KeyPackage, ChatError> {
|
||||
let retrieved = registry
|
||||
.retrieve(signer.as_str())
|
||||
.map_err(|e| ChatError::Generic(e.to_string()))?;
|
||||
let Some(keypkg_bytes) = retrieved else {
|
||||
return Err(ChatError::Protocol(format!(
|
||||
"no keypackage for signer {signer}"
|
||||
)));
|
||||
};
|
||||
|
||||
let mut keypackages = Vec::with_capacity(device_ids.len());
|
||||
for device_id in &device_ids {
|
||||
let retrieved = registry
|
||||
.retrieve(device_id)
|
||||
.map_err(|e| ChatError::Generic(e.to_string()))?;
|
||||
let Some(keypkg_bytes) = retrieved else {
|
||||
return Err(ChatError::Protocol(format!(
|
||||
"no keypackage for device {device_id} of account {ident}"
|
||||
)));
|
||||
};
|
||||
|
||||
let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?;
|
||||
let keypkg = key_package_in.validate(provider.crypto(), ProtocolVersion::Mls10)?; //TODO: P3 - Hardcoded Protocol Version
|
||||
keypackages.push(keypkg);
|
||||
}
|
||||
Ok(keypackages)
|
||||
let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?;
|
||||
let keypkg = key_package_in.validate(provider.crypto(), ProtocolVersion::Mls10)?; //TODO: P3 - Hardcoded Protocol Version
|
||||
Ok(keypkg)
|
||||
}
|
||||
|
||||
fn send_message<S: ExternalServices>(
|
||||
@@ -324,12 +312,12 @@ impl<S: ExternalServices> GroupConvo<S> for GroupV1Convo {
|
||||
));
|
||||
}
|
||||
|
||||
// Resolve each account to a KeyPackage per authorized device and flatten
|
||||
// them into one list — every device of every invitee becomes an MLS
|
||||
// leaf, so all of a user's installations join the group.
|
||||
// Members are signer (installation) ids: one KeyPackage each, one MLS
|
||||
// leaf each. A caller inviting an account passes every signer id the
|
||||
// account's directory bundle lists.
|
||||
let mut keypkgs = Vec::with_capacity(members.len());
|
||||
for ident in members {
|
||||
keypkgs.extend(self.key_packages_for_account(ident, &cx.mls_provider, &cx.registry)?);
|
||||
keypkgs.push(self.key_package_for_signer(ident, &cx.mls_provider, &cx.registry)?);
|
||||
}
|
||||
|
||||
let (commit, welcome, _group_info) = self
|
||||
@@ -346,9 +334,9 @@ impl<S: ExternalServices> GroupConvo<S> for GroupV1Convo {
|
||||
.unwrap();
|
||||
|
||||
// TODO: (P3) Evaluate privacy/performance implications of an aggregated Welcome for multiple users
|
||||
for account_id in members {
|
||||
for signer_id in members {
|
||||
cx.mls_provider
|
||||
.invite_user(&mut cx.ds, account_id, &welcome)?;
|
||||
.invite_user(&mut cx.ds, signer_id, &welcome)?;
|
||||
}
|
||||
|
||||
self.send_payload(cx, commit.to_bytes()?)
|
||||
|
||||
@@ -17,6 +17,8 @@ use de_mls::{
|
||||
};
|
||||
use hashgraph_like_consensus::signing::EthereumConsensusSigner;
|
||||
use openmls::group::MlsGroupCreateConfig;
|
||||
use openmls::prelude::tls_codec::Deserialize as _;
|
||||
use openmls::prelude::{KeyPackageIn, OpenMlsProvider as _, ProtocolVersion};
|
||||
use prost::Message;
|
||||
use shared_traits::{IdentId, IdentIdRef};
|
||||
use std::sync::Arc;
|
||||
@@ -74,8 +76,10 @@ fn demls_config() -> ConversationConfig {
|
||||
pub struct GroupV2Convo {
|
||||
convo_id: String,
|
||||
conversation: Conversation<DefaultConsensusPlugin, InMemoryPeerScoreStorage>,
|
||||
/// Member-ids we proposed via add_member. We forward a welcome only to joiners WE invited.
|
||||
pending_invites: Vec<Vec<u8>>,
|
||||
/// Joiners WE invited, as `(member_id, signer_id)`: the de-mls member id
|
||||
/// (the joiner's leaf credential content, read from its key package) paired
|
||||
/// with the signer id its welcome is delivered to.
|
||||
pending_invites: Vec<(Vec<u8>, String)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GroupV2Convo {
|
||||
@@ -274,20 +278,32 @@ where
|
||||
members: &[IdentIdRef],
|
||||
) -> Result<(), ChatError> {
|
||||
// Record who WE invited before touching the conversation: after_op
|
||||
// forwards a welcome only to joiners in pending_invites (the de-mls
|
||||
// member-id is the invitee's id bytes).
|
||||
// forwards a welcome only to joiners in pending_invites. Members are
|
||||
// signer ids; the de-mls member id must match the id of the
|
||||
// IdentityProvider that generated the key package (its MLS leaf
|
||||
// credential content — de-mls matches members by credential), so it is
|
||||
// read from the fetched key package rather than assumed equal to the
|
||||
// signer id.
|
||||
for member in members {
|
||||
let kp_bytes = service_ctx
|
||||
.registry
|
||||
.retrieve(member.as_str())
|
||||
.map_err(ChatError::generic)?
|
||||
.ok_or_else(|| ChatError::generic("No key package"))?;
|
||||
let key_package_in = KeyPackageIn::tls_deserialize(&mut kp_bytes.as_slice())?;
|
||||
let keypkg = key_package_in
|
||||
.validate(service_ctx.mls_provider.crypto(), ProtocolVersion::Mls10)?;
|
||||
let member_id = keypkg
|
||||
.leaf_node()
|
||||
.credential()
|
||||
.serialized_content()
|
||||
.to_vec();
|
||||
self.pending_invites
|
||||
.push(member.as_str().as_bytes().to_vec());
|
||||
.push((member_id.clone(), member.to_string()));
|
||||
self.conversation.add_member(
|
||||
&service_ctx.mls_provider,
|
||||
&service_ctx.mls_identity,
|
||||
member.as_str().as_bytes(),
|
||||
&member_id,
|
||||
&kp_bytes,
|
||||
)?;
|
||||
}
|
||||
@@ -314,16 +330,17 @@ impl GroupV2Convo {
|
||||
let outbound = self.conversation.drain_outbound(); // Vec<de_mls::session::Outbound>
|
||||
let wakeup = self.conversation.next_wakeup_in();
|
||||
|
||||
// 1. Route welcomes for joiners WE invited (event fires on every member now).
|
||||
// 1. Route welcomes for joiners WE invited (event fires on every member
|
||||
// now). The welcome travels to the joiner's signer id (where its
|
||||
// InboxV2 listens), not its de-mls member id.
|
||||
for evt in &events {
|
||||
if let ConversationEvent::WelcomeReady { welcome, .. } = evt {
|
||||
for joiner in &welcome.joiner_identities {
|
||||
if let Some(i) = self.pending_invites.iter().position(|p| p == joiner) {
|
||||
self.pending_invites.remove(i);
|
||||
let name = String::from_utf8(joiner.clone()).map_err(ChatError::generic)?;
|
||||
if let Some(i) = self.pending_invites.iter().position(|(p, _)| p == joiner) {
|
||||
let (_, signer_id) = self.pending_invites.remove(i);
|
||||
crate::inbox_v2::invite_user_v2(
|
||||
&mut service_ctx.ds,
|
||||
&IdentId::new(name),
|
||||
&IdentId::new(signer_id),
|
||||
welcome,
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::{
|
||||
};
|
||||
use crypto::{Identity, PublicKey};
|
||||
use openmls::group::GroupId;
|
||||
use shared_traits::IdentIdRef;
|
||||
use shared_traits::{IdentId, IdentIdRef};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use storage::{ChatStore, ConversationKind, ConversationStore};
|
||||
@@ -97,7 +97,6 @@ where
|
||||
)?;
|
||||
|
||||
core.register_keypackage()?;
|
||||
core.register_account_bundle()?;
|
||||
Ok(core)
|
||||
}
|
||||
|
||||
@@ -112,7 +111,12 @@ where
|
||||
store: CS,
|
||||
) -> Result<Self, ChatError> {
|
||||
let inbox = Inbox::new(&identity);
|
||||
let ident_id = ident.id().clone();
|
||||
// InboxV2 rendezvous is signer-scoped: it subscribes under the hex of
|
||||
// the signer's verifying key — the same string the account → device
|
||||
// directory lists and the registries key key-packages under, so it is
|
||||
// exactly what an inviter can derive for this installation. The MLS
|
||||
// credential below still carries the full `id()`.
|
||||
let ident_id = IdentId::new(hex::encode(ident.public_key().as_ref()));
|
||||
let mls_identity = MlsIdentityProvider::new(ident);
|
||||
let mls_provider = MlsEphemeralPqProvider::new().map_err(ChatError::generic)?;
|
||||
let causal = CausalHistoryStore::new();
|
||||
@@ -153,19 +157,12 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
|
||||
&self.services.store
|
||||
}
|
||||
|
||||
/// The account → device directory (our account store). Used to verify that a
|
||||
/// received message's claimed account actually endorses the sending device
|
||||
/// before the message is surfaced. Exposed as `RegistrationService`, whose
|
||||
/// `AccountDirectory` supertrait provides `fetch`.
|
||||
pub fn account_directory(&self) -> &S::RS {
|
||||
&self.services.registry
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> &Identity {
|
||||
&self.services.identity
|
||||
}
|
||||
|
||||
/// Returns the unique identifier associated with the account
|
||||
/// The signer id this core receives InboxV2 invites under — the hex of the
|
||||
/// signer's verifying key.
|
||||
pub fn ident_id(&'a self) -> IdentIdRef<'a> {
|
||||
self.pq_inbox.ident_id()
|
||||
}
|
||||
@@ -177,14 +174,6 @@ impl<'a, S: ExternalServices + 'static> Core<S> {
|
||||
self.pq_inbox.register(&mut self.services)
|
||||
}
|
||||
|
||||
/// Publish this installation's device key into the account → device
|
||||
/// directory, so inviters can resolve this account to its device(s). Pairs
|
||||
/// with [`register_keypackage`](Self::register_keypackage); call both after
|
||||
/// provisioning so the account is fully discoverable.
|
||||
pub fn register_account_bundle(&mut self) -> Result<(), ChatError> {
|
||||
self.pq_inbox.publish_device_bundle(&mut self.services)
|
||||
}
|
||||
|
||||
pub fn installation_name(&self) -> &str {
|
||||
self.services.identity.get_name()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ mod identity;
|
||||
mod mls_provider;
|
||||
|
||||
use chat_proto::logoschat::envelope::EnvelopeV1;
|
||||
use crypto::Ed25519VerifyingKey;
|
||||
use de_mls::protos::de_mls::messages::v1::MemberWelcome;
|
||||
use openmls::prelude::tls_codec::Serialize;
|
||||
use openmls::prelude::*;
|
||||
@@ -23,11 +22,7 @@ use crate::conversation::GroupV2Convo;
|
||||
use crate::conversation::Identified as _;
|
||||
use crate::service_context::{ExternalServices, ServiceContext};
|
||||
use crate::utils::{blake2b_hex, hash_size};
|
||||
use crate::{
|
||||
AccountAuthority, AccountDirectory, AddressedEnvelope, SignedDeviceBundle,
|
||||
encode_bundle_payload,
|
||||
};
|
||||
use crate::{IdentId, IdentIdRef, IdentityProvider};
|
||||
use crate::{AddressedEnvelope, IdentId, IdentIdRef, IdentityProvider};
|
||||
|
||||
// Downgraded from MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519 until demls accepts an external provider
|
||||
pub(crate) const CIPHER_SUITE: Ciphersuite =
|
||||
@@ -53,33 +48,34 @@ pub trait MlsProvider: OpenMlsProvider {
|
||||
) -> Result<(), ChatError>;
|
||||
}
|
||||
|
||||
/// Deliver a de-mls welcome to `account_id` over its InboxV2 1-1 channel.
|
||||
/// Deliver a de-mls welcome to `signer_id` over its InboxV2 1-1 channel.
|
||||
/// Function mirroring the GroupV1 `invite_user` path, but carrying a de-mls `MemberWelcome`.
|
||||
pub fn invite_user_v2<DS: DeliveryService>(
|
||||
ds: &mut DS,
|
||||
account_id: IdentIdRef,
|
||||
signer_id: IdentIdRef,
|
||||
welcome: &MemberWelcome,
|
||||
) -> Result<(), ChatError> {
|
||||
let frame = InboxV2Frame {
|
||||
payload: Some(InviteType::GroupV2(welcome.encode_to_vec())),
|
||||
};
|
||||
let envelope = EnvelopeV1 {
|
||||
conversation_hint: conversation_id_for(account_id),
|
||||
conversation_hint: conversation_id_for(signer_id),
|
||||
salt: 0,
|
||||
payload: frame.encode_to_vec().into(),
|
||||
};
|
||||
ds.publish(AddressedEnvelope {
|
||||
delivery_address: delivery_address_for(account_id),
|
||||
delivery_address: delivery_address_for(signer_id),
|
||||
data: envelope.encode_to_vec(),
|
||||
})
|
||||
.map_err(ChatError::generic)
|
||||
}
|
||||
|
||||
/// An PQ focused Conversation initializer.
|
||||
/// InboxV2 Incorporates an Account based identity system to support PQ based conversation protocols
|
||||
/// such as MLS.
|
||||
/// A PQ focused Conversation initializer.
|
||||
/// InboxV2 is signer-scoped: it receives invites under this installation's
|
||||
/// signer id (the hex of the signer's verifying key), supporting PQ based
|
||||
/// conversation protocols such as MLS.
|
||||
pub struct InboxV2 {
|
||||
// Account_id field is an owned value, so it can be returned via reference.
|
||||
// Owned so it can be returned via reference.
|
||||
ident_id: IdentId,
|
||||
}
|
||||
|
||||
@@ -205,78 +201,6 @@ impl InboxV2 {
|
||||
}
|
||||
}
|
||||
|
||||
// Publishing the account → device bundle needs the account key, so this method
|
||||
// is available only when the registry also implements `AccountDirectory`. The
|
||||
// signing authority is the `LogosAccount` wrapped by `mls_identity`; on testnet
|
||||
// that is a local key (account key == device key), while an external signer
|
||||
// would supply its own authority.
|
||||
impl InboxV2 {
|
||||
/// Add this installation's device key to the account's directory bundle.
|
||||
///
|
||||
/// Fetches the current (verified) device set, adds this device if absent,
|
||||
/// bumps the lamport, re-signs with the account key, and publishes. Safe to
|
||||
/// call repeatedly — an unchanged set is simply re-published, which also
|
||||
/// refreshes the server's retention clock.
|
||||
pub fn publish_device_bundle<S: ExternalServices>(
|
||||
&self,
|
||||
cx: &mut ServiceContext<S>,
|
||||
) -> Result<(), ChatError> {
|
||||
// On testnet `mls_identity` doubles as the `AccountAuthority` — the
|
||||
// account key is the installation's own key.
|
||||
let authority = &cx.mls_identity;
|
||||
|
||||
let account_pub = AccountAuthority::account_pub(authority).clone();
|
||||
let device_key = cx.mls_identity.public_key().clone();
|
||||
let device_hex = hex::encode(device_key.as_ref());
|
||||
|
||||
// Start from the devices already registered so other installations of
|
||||
// this account are preserved across the upsert.
|
||||
let existing = cx
|
||||
.registry
|
||||
.fetch(&account_pub)
|
||||
.map_err(|e| ChatError::Generic(e.to_string()))?;
|
||||
let (mut devices, next_lamport) = match existing {
|
||||
Some(set) => {
|
||||
let mut keys = Vec::with_capacity(set.devices.len() + 1);
|
||||
for hex_id in &set.devices {
|
||||
let bytes: [u8; 32] = hex::decode(hex_id)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| {
|
||||
ChatError::Generic("directory returned a malformed device id".into())
|
||||
})?;
|
||||
let key = Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| {
|
||||
ChatError::Generic("directory returned a malformed device key".into())
|
||||
})?;
|
||||
keys.push(key);
|
||||
}
|
||||
(keys, set.lamport + 1)
|
||||
}
|
||||
None => (Vec::new(), 0),
|
||||
};
|
||||
|
||||
if !devices
|
||||
.iter()
|
||||
.any(|d| hex::encode(d.as_ref()) == device_hex)
|
||||
{
|
||||
devices.push(device_key);
|
||||
}
|
||||
|
||||
let payload = encode_bundle_payload(next_lamport, &devices);
|
||||
let signature = AccountAuthority::sign(authority, &payload)
|
||||
.map_err(|e| ChatError::Generic(e.to_string()))?;
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub,
|
||||
payload,
|
||||
signature,
|
||||
};
|
||||
|
||||
cx.registry
|
||||
.publish(&bundle)
|
||||
.map_err(|e| ChatError::Generic(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
pub struct InboxV2Frame {
|
||||
#[prost(oneof = "InviteType", tags = "1, 2")]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::ops::Deref;
|
||||
|
||||
use crypto::{Ed25519Signature, Ed25519VerifyingKey};
|
||||
use openmls::credentials::{BasicCredential, CredentialWithKey};
|
||||
use openmls_traits::{
|
||||
signatures::{Signer, SignerError},
|
||||
@@ -8,7 +7,6 @@ use openmls_traits::{
|
||||
};
|
||||
use shared_traits::IdentIdRef;
|
||||
|
||||
use crate::AccountAuthority;
|
||||
use crate::IdentityProvider;
|
||||
|
||||
/// A Wrapper for an IdentityProvider which provides MLS specific functionality
|
||||
@@ -57,22 +55,6 @@ impl<T: IdentityProvider> IdentityProvider for MlsIdentityProvider<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// On testnet the installation identity is also the account authority: the
|
||||
// account key is the installation's own key, so the device bundle is signed and
|
||||
// addressed under `public_key()`. A real deployment injects a separate
|
||||
// `AccountAuthority` (wallet/enclave) whose key custody lives outside libchat.
|
||||
impl<T: IdentityProvider> AccountAuthority for MlsIdentityProvider<T> {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn account_pub(&self) -> &Ed25519VerifyingKey {
|
||||
self.public_key()
|
||||
}
|
||||
|
||||
fn sign(&self, payload: &[u8]) -> Result<Ed25519Signature, Self::Error> {
|
||||
Ok(IdentityProvider::sign(self, payload))
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Signer directly for MlsIdentityProvider, so that openmls Signer contstraint
|
||||
// does not leave the module.
|
||||
impl<T: IdentityProvider> Signer for MlsIdentityProvider<T> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
mod account_directory;
|
||||
mod causal_history;
|
||||
mod conversation;
|
||||
mod core;
|
||||
@@ -13,11 +12,6 @@ mod service_traits;
|
||||
mod types;
|
||||
mod utils;
|
||||
|
||||
pub use account_directory::{
|
||||
AccountAuthority, AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId,
|
||||
DeviceSet, Lamport, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload,
|
||||
resolve_device_ids, verify_bundle,
|
||||
};
|
||||
pub use causal_history::{Frontier, MissingMessage};
|
||||
pub use chat_sqlite::ChatStorage;
|
||||
pub use chat_sqlite::StorageConfig;
|
||||
|
||||
@@ -49,10 +49,8 @@ pub(crate) struct ServiceContext<S: ExternalServices> {
|
||||
#[cfg(test)]
|
||||
mod test_support {
|
||||
use super::*;
|
||||
use crate::account_directory::{AccountDirectory, DeviceSet, SignedDeviceBundle};
|
||||
use crate::types::AddressedEnvelope;
|
||||
use crate::{ChatError, IdentityProvider};
|
||||
use crypto::Ed25519VerifyingKey;
|
||||
|
||||
/// Delivery double that drops every payload.
|
||||
#[derive(Debug)]
|
||||
@@ -81,32 +79,11 @@ mod test_support {
|
||||
&mut self,
|
||||
_identity: &dyn IdentityProvider,
|
||||
_key_bundle: Vec<u8>,
|
||||
) -> Result<(), <Self as RegistrationService>::Error> {
|
||||
) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve(
|
||||
&self,
|
||||
_device_id: &str,
|
||||
) -> Result<Option<Vec<u8>>, <Self as RegistrationService>::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountDirectory for NoopRegistration {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn publish(
|
||||
&mut self,
|
||||
_bundle: &SignedDeviceBundle,
|
||||
) -> Result<(), <Self as AccountDirectory>::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
&self,
|
||||
_account: &Ed25519VerifyingKey,
|
||||
) -> Result<Option<DeviceSet>, <Self as AccountDirectory>::Error> {
|
||||
fn retrieve(&self, _device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{AccountDirectory, ConversationId, types::AddressedEnvelope};
|
||||
use crate::{ConversationId, types::AddressedEnvelope};
|
||||
|
||||
/// A Delivery service is responsible for payload transport.
|
||||
/// This interface allows Conversations to send payloads on the wire as well as
|
||||
@@ -29,25 +29,14 @@ pub trait DeliveryService: Debug {
|
||||
/// implementations that need to authenticate the submission — e.g. a network
|
||||
/// service that verifies the bundle is signed by the correct account — can
|
||||
/// sign or attest with the caller's key material.
|
||||
///
|
||||
/// On testnet a single service (the keypackage-registry) provides both the
|
||||
/// keypackage store and the account → device directory, so [`AccountDirectory`]
|
||||
/// is a supertrait: any `RegistrationService` also resolves accounts to devices.
|
||||
/// This co-location is intentional and temporary; the two can be split into
|
||||
/// separate injected services once λLEZ lands.
|
||||
pub trait RegistrationService: Debug + AccountDirectory {
|
||||
// Disambiguated below: with `AccountDirectory` as a supertrait, a bare
|
||||
// `Self::Error` is ambiguous between the two traits' associated types.
|
||||
pub trait RegistrationService: Debug {
|
||||
type Error: Display + Debug;
|
||||
fn register(
|
||||
&mut self,
|
||||
identity: &dyn IdentityProvider,
|
||||
key_bundle: Vec<u8>,
|
||||
) -> Result<(), <Self as RegistrationService>::Error>;
|
||||
fn retrieve(
|
||||
&self,
|
||||
device_id: &str,
|
||||
) -> Result<Option<Vec<u8>>, <Self as RegistrationService>::Error>;
|
||||
) -> Result<(), Self::Error>;
|
||||
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error>;
|
||||
}
|
||||
|
||||
/// Read-only view of a contact registry. Not part of the public API.
|
||||
@@ -58,9 +47,7 @@ pub trait KeyPackageProvider: Debug {
|
||||
}
|
||||
|
||||
impl<T: RegistrationService> KeyPackageProvider for T {
|
||||
// Disambiguate: `RegistrationService` now has `AccountDirectory` as a
|
||||
// supertrait, so both expose an associated `Error`.
|
||||
type Error = <T as RegistrationService>::Error;
|
||||
type Error = T::Error;
|
||||
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
RegistrationService::retrieve(self, device_id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user