From 6f7a0989bd8ec9e08aa3ce0e3bdf5066e363ab20 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:04:22 -0700 Subject: [PATCH] Replace AccountDirectory with AccountRegistry --- core/account/src/account.rs | 140 ++---- core/account/src/account_log.rs | 18 +- core/account/src/directory.rs | 421 ------------------ core/account/src/lib.rs | 27 +- .../examples/message-exchange/main.rs | 10 +- crates/generic-chat/src/builder.rs | 10 +- crates/generic-chat/src/client.rs | 175 ++++---- crates/generic-chat/src/lib.rs | 2 +- crates/generic-chat/tests/group_v2.rs | 14 +- crates/generic-chat/tests/saro_and_raya.rs | 24 +- crates/logos-chat/src/logos.rs | 8 +- .../src/contact_registry/ephemeral.rs | 52 ++- .../components/src/contact_registry/store.rs | 89 ++-- 13 files changed, 272 insertions(+), 718 deletions(-) delete mode 100644 core/account/src/directory.rs diff --git a/core/account/src/account.rs b/core/account/src/account.rs index c01408e..160171c 100644 --- a/core/account/src/account.rs +++ b/core/account/src/account.rs @@ -10,9 +10,8 @@ use std::{ use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; use crate::{ - AccountAddr, AccountDirectory, AccountEntry, AccountError, AccountLog, AccountRegistry, - EntryData, Lamport, SignedAccountLog, SignedDeviceBundle, encode_bundle_payload, - verify_extension, verify_log, + AccountAddr, AccountEntry, AccountError, AccountLog, AccountLogStore, AccountRegistry, + EntryData, SignedAccountLog, verify_extension, verify_log, }; /// Logs "published" by accounts, keyed by address. @@ -29,15 +28,18 @@ impl TestAccountService { pub fn new() -> Self { Self::default() } +} - /// A new account publishing to this service's shared backend. - pub fn account(&self) -> TestLogosAccount { - TestLogosAccount::with_service(self.clone()) - } +/// The publish gate a real service runs: signature under the claimed address, +/// strict extension of whatever is already stored. +impl AccountLogStore for TestAccountService { + type Error = AccountError; - /// The publish gate a real service runs: signature under the claimed - /// address, strict extension of whatever is already stored. - fn publish(&self, addr: &AccountAddr, log: SignedAccountLog) -> Result<(), AccountError> { + fn publish_log( + &mut self, + addr: &AccountAddr, + log: SignedAccountLog, + ) -> Result<(), Self::Error> { verify_log(addr, &log)?; let mut logs = self.logs.lock().expect("poisoned"); if let Some(previous) = logs.get(addr) { @@ -59,104 +61,44 @@ impl AccountRegistry for TestAccountService { let Some(signed) = logs.get(addr) else { return Ok(None); }; - let log = verify_log(addr, signed)?; - log.live_entries() - .iter() - .map(|data| match data { - // A signed log endorsing a non-key is the account's error, - // not a lookup miss — surface it rather than skip it. - EntryData::Ed25519Key(bytes) => Ed25519VerifyingKey::from_bytes(bytes) - .map_err(|_| AccountError::Generic("endorsed key is invalid".into())), - }) - .collect::, _>>() - .map(Some) + Ok(Some(verify_log(addr, signed)?.endorsed_ed25519_keys()?)) } } -/// A test-focused account: holds its signing key and working log, publishes -/// through a [`TestAccountService`]. Not persisted; not for production. -pub struct TestLogosAccount { +/// A test-focused account: its signing key, its working log, and the +/// [`AccountLogStore`] it publishes to. Every change goes to that store, so the +/// log this account extends and the log readers resolve are the same log. +/// +/// Nothing is persisted: an account lives and dies with the process, so its +/// endorsements cannot be extended by a later run. Not for production. +pub struct TestLogosAccount { signing_key: Ed25519SigningKey, addr: AccountAddr, log: AccountLog, - service: TestAccountService, + store: S, } -impl TestLogosAccount { - /// An account with its own private backend. - pub fn new() -> Self { - TestAccountService::new().account() - } - - pub fn with_service(service: TestAccountService) -> Self { +impl TestLogosAccount { + /// A brand-new account publishing to `store`. Its key is freshly minted, so + /// there is nothing published to read back. + pub fn new(store: S) -> Self { let signing_key = Ed25519SigningKey::generate(); let addr = AccountAddr::from(&signing_key.verifying_key()); Self { signing_key, addr, log: AccountLog::new(vec![]).expect("empty log is valid"), - service, + store, } } -} -impl Default for TestLogosAccount { - fn default() -> Self { - Self::new() - } -} - -// Inherent for now — the write-side trait (AccountProvider) is parked in -// lib.rs; these become its impl when it lands. -impl TestLogosAccount { pub fn address(&self) -> &AccountAddr { &self.addr } - /// Endorse `key` on this account: append it to the log, sign the log whole, - /// and publish it. - /// - /// `directory` is transitional — clients still resolve an account through - /// [`AccountDirectory`] rather than reading endorsements from - /// [`AccountRegistry`], so every endorsement also mirrors the account's live - /// key set there as a signed device bundle. The parameter goes away once - /// they read the registry. - pub fn endorse_ed25519_signer( - &mut self, - directory: &mut D, - key: &Ed25519VerifyingKey, - ) -> Result<(), AccountError> { - self.append_ed25519_endorsement(key)?; - - // TODO: delete with the directory — the `directory` parameter, everything - // below, and the bundle imports; `append_ed25519_endorsement` is what's left. - let devices = self - .log - .live_entries() - .iter() - .map(|data| match data { - EntryData::Ed25519Key(bytes) => Ed25519VerifyingKey::from_bytes(bytes) - .map_err(|_| AccountError::Generic("endorsed key is invalid".into())), - }) - .collect::, _>>()?; - - // Every endorsement appends an entry, so the log's length is a version - // that only ever climbs — what the directory demands of a republish. - let payload = encode_bundle_payload(self.log.entries().len() as Lamport, &devices); - let bundle = SignedDeviceBundle { - account_pub: self.signing_key.verifying_key(), - signature: self.signing_key.sign(&payload), - payload, - }; - directory - .publish(&bundle) - .map_err(|e| AccountError::Generic(e.to_string())) - } - - /// The endorsement itself: extend the log, sign it, publish it to the - /// account service. What [`Self::endorse_ed25519_signer`] reduces to once - /// the directory mirror is gone. - fn append_ed25519_endorsement( + /// Endorse `key` on this account: append it to the log, sign the log + /// whole, and publish it — where readers resolve this account. + pub fn endorse_ed25519_signer( &mut self, key: &Ed25519VerifyingKey, ) -> Result<(), AccountError> { @@ -171,7 +113,9 @@ impl TestLogosAccount { signature: self.signing_key.sign(payload.as_bytes()), payload, }; - self.service.publish(&self.addr, signed)?; + self.store + .publish_log(&self.addr, signed) + .map_err(|e| AccountError::Generic(e.to_string()))?; self.log = log; Ok(()) } @@ -185,14 +129,14 @@ mod tests { Ed25519SigningKey::generate().verifying_key() } - /// endorse → the shared service resolves the key for that account. + /// endorse → the service it published to resolves the key for that account. #[test] fn endorsed_signer_is_resolvable() { let srv = TestAccountService::new(); - let mut account = srv.account(); + let mut account = TestLogosAccount::new(srv.clone()); let dev = device(); - account.append_ed25519_endorsement(&dev).unwrap(); + account.endorse_ed25519_signer(&dev).unwrap(); assert!(srv.is_ed25519_endorsed(&dev, account.address()).unwrap()); assert!( @@ -205,7 +149,7 @@ mod tests { #[test] fn unpublished_account_is_unknown() { let srv = TestAccountService::new(); - let account = srv.account(); + let account = TestLogosAccount::new(srv.clone()); assert!( srv.endorsed_ed25519_keys(account.address()) .unwrap() @@ -217,11 +161,11 @@ mod tests { #[test] fn endorsements_accumulate() { let srv = TestAccountService::new(); - let mut account = srv.account(); + let mut account = TestLogosAccount::new(srv.clone()); let (a, b) = (device(), device()); - account.append_ed25519_endorsement(&a).unwrap(); - account.append_ed25519_endorsement(&b).unwrap(); + account.endorse_ed25519_signer(&a).unwrap(); + account.endorse_ed25519_signer(&b).unwrap(); let keys = srv .endorsed_ed25519_keys(account.address()) @@ -233,8 +177,8 @@ mod tests { /// The publish gate refuses a log signed by anyone but the account. #[test] fn publish_rejects_wrong_signer() { - let srv = TestAccountService::new(); - let account = srv.account(); + let mut srv = TestAccountService::new(); + let account = TestLogosAccount::new(srv.clone()); let imposter = Ed25519SigningKey::generate(); let payload = AccountLog::new(vec![]).unwrap().encode(); @@ -242,6 +186,6 @@ mod tests { signature: imposter.sign(payload.as_bytes()), payload, }; - assert!(srv.publish(account.address(), forged).is_err()); + assert!(srv.publish_log(account.address(), forged).is_err()); } } diff --git a/core/account/src/account_log.rs b/core/account/src/account_log.rs index 99a6d58..822ce21 100644 --- a/core/account/src/account_log.rs +++ b/core/account/src/account_log.rs @@ -21,7 +21,7 @@ //! //! Replaying ([`AccountLog::live_entries`]) yields the account's current state. -use crypto::Ed25519Signature; +use crypto::{Ed25519Signature, Ed25519VerifyingKey}; use crate::error::AccountLogError; @@ -86,6 +86,22 @@ impl AccountLog { &self.entries } + /// The Ed25519 keys this log currently endorses, in add order. + /// + /// The one place [`EntryData`] is turned into keys, so every registry + /// derives the endorsed set identically — and none has to match on a + /// non_exhaustive enum. A log endorsing a non-key is the account's error, + /// not a lookup miss: it is surfaced, not skipped. + pub fn endorsed_ed25519_keys(&self) -> Result, AccountLogError> { + self.live_entries() + .iter() + .map(|data| match data { + EntryData::Ed25519Key(bytes) => Ed25519VerifyingKey::from_bytes(bytes) + .map_err(|_| AccountLogError::Malformed("endorsed key is invalid".into())), + }) + .collect() + } + /// Replay the log into its live entry set — the account's current state, /// in add order. pub fn live_entries(&self) -> Vec { diff --git a/core/account/src/directory.rs b/core/account/src/directory.rs deleted file mode 100644 index 0814889..0000000 --- a/core/account/src/directory.rs +++ /dev/null @@ -1,421 +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. -//! -//! [`AccountDirectory`] is the client that publishes and fetches+verifies the -//! bundle against the directory service. Signing a bundle is account -//! functionality (e.g. [`TestLogosAccount::add_delegate_signer`](crate::TestLogosAccount)); -//! the account key never leaves the account type. -//! -//! 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 a keypackage -/// retrieval. -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 the account 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, - /// 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, -} - -/// Client for the account → device directory service. -/// -/// Mirrors the core's `RegistrationService`: an injected trait 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, 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 { - 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 { - 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 { - 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(), - }) -} - -/// Failures resolving an account address to its device ids. -#[derive(Debug, Error)] -pub enum ResolveError { - #[error("address is not an account key")] - NotAnAccountKey, - #[error("account has published no device bundle")] - NoDeviceBundle, - #[error("directory: {0}")] - Directory(String), -} - -/// Resolve an account to the device ids whose KeyPackages must be fetched. -/// -/// The directory is keyed by the account verifying key: `account` must be the -/// hex of such a key, and a reachable account has published a bundle endorsing -/// at least one device. Anything else is an error — the distinct variants tell -/// a malformed address, an unpublished account, and a directory outage apart. -pub fn resolve_device_ids( - directory: &D, - account: IdentIdRef, -) -> Result, ResolveError> { - let account_key = account_key_from_id(account).ok_or(ResolveError::NotAnAccountKey)?; - let set = directory - .fetch(&account_key) - .map_err(|e| ResolveError::Directory(e.to_string()))? - .ok_or(ResolveError::NoDeviceBundle)?; - Ok(set.devices) -} - -/// Interpret an identity id as the hex of an account verifying key, if it is one. -fn account_key_from_id(id: IdentIdRef) -> Option { - 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); - - 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, Self::Error> { - self.0 - .as_ref() - .map(|b| verify_bundle(account, b)) - .transpose() - } - } - - /// An address that is not the hex of an account key cannot be resolved. - #[test] - fn resolve_rejects_non_key_address() { - let account = IdentId::new("pax"); - assert!(matches!( - resolve_device_ids(&FakeDir(None), &account), - Err(ResolveError::NotAnAccountKey) - )); - } - - /// An account that never published a bundle is unreachable. - #[test] - fn resolve_rejects_unpublished_account() { - let account_pub = Ed25519SigningKey::generate().verifying_key(); - let account_id = IdentId::new(hex::encode(account_pub.as_ref())); - assert!(matches!( - resolve_device_ids(&FakeDir(None), &account_id), - Err(ResolveError::NoDeviceBundle) - )); - } - - /// 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 = 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) - )); - } -} diff --git a/core/account/src/lib.rs b/core/account/src/lib.rs index e3d8f70..2afdd8d 100644 --- a/core/account/src/lib.rs +++ b/core/account/src/lib.rs @@ -12,7 +12,6 @@ mod account; mod account_log; mod addr; mod codec; -mod directory; mod error; use crypto::Ed25519VerifyingKey; @@ -22,12 +21,6 @@ pub use addr::AccountAddr; pub use codec::{ACCOUNT_LOG_DOMAIN, verify_extension, verify_log}; pub use error::{AccountError, AccountLogError}; -pub use directory::{ - AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, DeviceSet, Lamport, - ResolveError, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload, - resolve_device_ids, verify_bundle, -}; - #[cfg(feature = "dev")] pub use account::{TestAccountService, TestLogosAccount}; @@ -53,3 +46,23 @@ pub trait AccountRegistry { .is_some_and(|keys| keys.contains(signer))) } } + +/// Where an account publishes its signed log — the write side of what +/// [`AccountRegistry`] serves to readers. +/// +/// An account holds one of these and publishes through it, so the log it +/// extends and the log others read are the same log. +pub trait AccountLogStore { + type Error: std::fmt::Display + std::fmt::Debug; + + /// Store `log` as `addr`'s log, replacing any earlier one. The store is + /// untrusted, so it proves nothing: readers verify what they fetch. + /// + /// Taken by value: an account has no use for the log once published, and a + /// store that keeps it would otherwise have to copy it. + fn publish_log( + &mut self, + addr: &AccountAddr, + log: SignedAccountLog, + ) -> Result<(), Self::Error>; +} diff --git a/crates/generic-chat/examples/message-exchange/main.rs b/crates/generic-chat/examples/message-exchange/main.rs index 2b4d7e3..c1794d9 100644 --- a/crates/generic-chat/examples/message-exchange/main.rs +++ b/crates/generic-chat/examples/message-exchange/main.rs @@ -7,20 +7,20 @@ use std::time::Duration; fn main() { let bus = MessageBus::default(); - let mut reg = EphemeralRegistry::new(); + let reg = EphemeralRegistry::new(); // Mint two accounts, each endorsing a delegate signer, so a peer can resolve // an account address to its device. - let mut saro_account = TestLogosAccount::new(); + let mut saro_account = TestLogosAccount::new(reg.clone()); let saro_delegate = DelegateSigner::random(); saro_account - .endorse_ed25519_signer(&mut reg, saro_delegate.public_key()) + .endorse_ed25519_signer(saro_delegate.public_key()) .unwrap(); - let mut raya_account = TestLogosAccount::new(); + let mut raya_account = TestLogosAccount::new(reg.clone()); let raya_delegate = DelegateSigner::random(); raya_account - .endorse_ed25519_signer(&mut reg, raya_delegate.public_key()) + .endorse_ed25519_signer(raya_delegate.public_key()) .unwrap(); let (mut saro, saro_events) = ChatClientBuilder::new(saro_account.address().to_bytes()) diff --git a/crates/generic-chat/src/builder.rs b/crates/generic-chat/src/builder.rs index bc5db77..7771d7c 100644 --- a/crates/generic-chat/src/builder.rs +++ b/crates/generic-chat/src/builder.rs @@ -3,7 +3,7 @@ use crossbeam_channel::Receiver; use libchat::{ AuthVerifyService, ChatError, ChatStorage, GroupV2Config, RegistrationService, StorageConfig, }; -use logos_account::AccountDirectory; +use logos_account::AccountRegistry; use storage::ChatStore; use crate::Transport; @@ -147,7 +147,7 @@ impl ChatClientBuilder where AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + AccountDirectory + Clone + Send + 'static, + R: RegistrationService + AccountRegistry + Clone + Send + 'static, S: ChatStore + Send + 'static, { pub fn build(self) -> Built { @@ -206,7 +206,7 @@ impl ChatClientBuilder where AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + AccountDirectory + Clone + Send + 'static, + R: RegistrationService + AccountRegistry + Clone + Send + 'static, { pub fn build(self) -> Built { ChatClient::new( @@ -246,7 +246,7 @@ impl ChatClientBuilder where AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + AccountDirectory + Clone + Send + 'static, + R: RegistrationService + AccountRegistry + Clone + Send + 'static, { pub fn build(self) -> Built { ChatClient::new( @@ -266,7 +266,7 @@ impl ChatClientBuilder where AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + AccountDirectory + Clone + Send + 'static, + R: RegistrationService + AccountRegistry + Clone + Send + 'static, S: ChatStore + Send + 'static, { pub fn build(self) -> Built { diff --git a/crates/generic-chat/src/client.rs b/crates/generic-chat/src/client.rs index 6dcc7b5..1541668 100644 --- a/crates/generic-chat/src/client.rs +++ b/crates/generic-chat/src/client.rs @@ -4,13 +4,12 @@ use std::thread::{self, JoinHandle}; use components::{ThreadedWakeupService, WakeupEvent}; use crossbeam_channel::{Receiver, Sender, select}; -use crypto::Ed25519VerifyingKey; use libchat::{ AuthResult, AuthVerifyService, ConversationId, ConvoMetadata, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, IdentIdRef, InboxOutcome, PayloadOutcome, RegistrationService, SignerId, UnverifiedSender, }; -use logos_account::{AccountDirectory, resolve_device_ids}; +use logos_account::{AccountAddr, AccountRegistry}; use parking_lot::Mutex; use storage::ChatStore; @@ -42,8 +41,8 @@ type LocalSignerId = IdentId; /// A member of a group conversation's roster. /// /// Shares [`MessageSender`]'s field semantics: `account` is set only when the -/// member's credential claimed an account *and* the directory confirmed this -/// device belongs to it. Unlike a message sender, an unconfirmable claim does +/// member's credential claimed an account *and* the registry confirmed the +/// account endorses this device. Unlike a message sender, an unconfirmable claim does /// not hide the member: a committed member is cryptographically in the group, /// so it is listed by `local_identity` (its device) with `account: None`. /// @@ -163,17 +162,17 @@ pub struct ChatClient where AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + AccountDirectory + Clone + Send + 'static, + R: RegistrationService + AccountRegistry + Clone + Send + 'static, S: ChatStore + Send + 'static, { /// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't /// starve caller operations of the lock. core: Arc>>, account_verify_service: AS, - /// The account → device directory. On testnet the registration service - /// doubles as the directory (one deployed registry serves both roles), so - /// the client keeps its own clone of `R`; the core sees key packages only. - directory: R, + /// The account registry. On testnet the registration service doubles as the + /// account store (one deployed registry serves both roles), so the client + /// keeps its own clone of `R`; the core sees key packages only. + accounts: R, /// Dropped on `Drop` to wake the worker's `select!` and shut it down. shutdown: Option>, worker: Option>, @@ -185,7 +184,7 @@ impl ChatClient where AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + AccountDirectory + Clone + Send + 'static, + R: RegistrationService + AccountRegistry + Clone + Send + 'static, S: ChatStore + Send + 'static, { pub fn new( @@ -201,21 +200,21 @@ where let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded(); let wakeup_service = ThreadedWakeupService::new(wakeup_tx); - let directory = reg.clone(); + let accounts = reg.clone(); let ident = DelegateIdentity::new(ident, &account); let mut core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?; if let Some(config) = group_v2 { core.set_group_v2_config(config); } Ok(Self::spawn( - core, auth, directory, account, inbound, wakeup_rx, + core, auth, accounts, account, inbound, wakeup_rx, )) } fn spawn( core: ClientCore, auth: AS, - directory: R, + accounts: R, address: Vec, inbound: Receiver>, wakeup_events: Receiver, @@ -226,11 +225,11 @@ where let worker = thread::spawn({ let core = Arc::clone(&core); - let directory = directory.clone(); + let accounts = accounts.clone(); move || { worker_loop( core, - directory, + accounts, inbound, wakeup_events, shutdown_rx, @@ -243,7 +242,7 @@ where Self { core, account_verify_service: auth, - directory, + accounts, shutdown: Some(shutdown_tx), worker: Some(worker), address, @@ -277,7 +276,7 @@ where } /// Create a GroupV2 conversation with the given accounts' devices. Each - /// account resolves to the signer ids its directory bundle endorses; the + /// account resolves to the signer ids its account log endorses; the /// group invite goes to every one of them. An empty slice creates a group /// with only this client, to grow via [`Self::add_group_members`]. /// `metadata` becomes the group's shared name and description, carried to @@ -379,19 +378,29 @@ where .map_err(Into::into) } - /// Resolve an account address to the signer (device) ids its published - /// directory bundle endorses. A reachable account has published at least - /// one signer; anything else is an error. + /// Resolve an account address to the signer (device) ids its published log + /// endorses. A reachable account has published at least one signer; + /// anything else is an error. fn signers_from_account( &self, account: AccountAddressRef, ) -> Result, ClientError> { - // The directory keys accounts by the hex of the account key, so the - // address bytes are encoded at that boundary. - let account = IdentId::new(hex::encode(account)); - let device_ids = resolve_device_ids(&self.directory, &account) - .map_err(|e| ClientError::AccountResolution(e.to_string()))?; - Ok(device_ids.into_iter().map(IdentId::new).collect()) + let addr = AccountAddr::try_from(account) + .map_err(|_| ClientError::AccountResolution("not an account address".into()))?; + let keys = self + .accounts + .endorsed_ed25519_keys(&addr) + .map_err(|e| ClientError::AccountResolution(e.to_string()))? + .filter(|keys| !keys.is_empty()) + .ok_or_else(|| { + ClientError::AccountResolution("account endorses no signer".to_string()) + })?; + // A signer id is the hex of its verifying key — what the keypackage + // registry is keyed by. + Ok(keys + .iter() + .map(|key| IdentId::new(hex::encode(key.as_ref()))) + .collect()) } /// Resolve each account to its signer ids and flatten them, failing on the @@ -417,7 +426,7 @@ impl Drop for ChatClient where AS: AuthVerifyService + Send + 'static, T: Transport + Send + 'static, - R: RegistrationService + AccountDirectory + Clone + Send + 'static, + R: RegistrationService + AccountRegistry + Clone + Send + 'static, S: ChatStore + Send + 'static, { fn drop(&mut self) { @@ -435,14 +444,14 @@ where /// the thread until one of the channels is ready. fn worker_loop( core: Arc>>, - directory: R, + accounts: R, inbound: Receiver>, wakeup_events: Receiver, shutdown: Receiver<()>, event_tx: Sender, ) where T: DeliveryService + Send + 'static, - R: RegistrationService + AccountDirectory + Send + 'static, + R: RegistrationService + AccountRegistry + Send + 'static, { loop { select! { @@ -453,7 +462,7 @@ fn worker_loop( let events = { let mut core = core.lock(); match core.handle_payload(&bytes) { - Ok(outcome) => events_from_inbound(outcome, &directory), + Ok(outcome) => events_from_inbound(outcome, &accounts), Err(e) => { tracing::warn!("inbound handle_payload failed: {e:?}"); vec![Event::InboundError { @@ -474,7 +483,7 @@ fn worker_loop( }; // A wakeup can drive the steward's own commit, so it yields events too. let events = match core.lock().wakeup(&convo_id) { - Ok(outcome) => events_from_inbound(outcome, &directory), + Ok(outcome) => events_from_inbound(outcome, &accounts), Err(e) => { tracing::warn!("wakeup failed: {e:?}"); Vec::new() @@ -495,20 +504,14 @@ fn worker_loop( /// observation. For an `Inbox` outcome, [`Event::ConversationStarted`] /// precedes the message event. The convo id is wrapped into `Arc` once /// per outcome and shared across the events it produces. -fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec { +fn events_from_inbound(result: PayloadOutcome, accounts: &impl AccountRegistry) -> Vec { match result { PayloadOutcome::Empty => Vec::new(), - PayloadOutcome::Convo(co) => convo_events(co, directory), - PayloadOutcome::Inbox(io) => inbox_events(io, directory), + PayloadOutcome::Convo(co) => convo_events(co, accounts), + PayloadOutcome::Inbox(io) => inbox_events(io, accounts), } } -/// Interpret account address bytes as an Ed25519 account verifying key. -fn account_key_from_bytes(addr: &[u8]) -> Option { - let bytes: [u8; 32] = addr.try_into().ok()?; - Ed25519VerifyingKey::from_bytes(&bytes).ok() -} - /// Why a message's sender could not be accepted, so the message is dropped. #[derive(Debug, PartialEq, Eq)] enum SenderError { @@ -521,30 +524,29 @@ enum SenderError { Malformed, /// The claimed account address is not the bytes of an Ed25519 verifying key. AccountNotAKey, - /// The account → device mapping is wrong or could not be confirmed: the - /// device is not in the account's published set, the account published none, - /// or the directory lookup failed. + /// The endorsement is missing or could not be confirmed: the account does + /// not endorse this device, it published nothing, or the lookup failed. Unverified, } -/// The resolution of a credential's account claim against the directory. +/// The resolution of a credential's account claim against the registry. enum AccountClaim { /// The credential claimed no account. None, - /// Confirmed: the directory lists this device under the claimed account. + /// Confirmed: the account endorses this device. Verified(Vec), /// An account was claimed but could not be confirmed (see [`SenderError`]). Unverified(SenderError), } /// Parse a wire credential into the device it names and the resolution of any -/// account claim, checked against the account → device directory. `Err` only -/// when no device can be attributed at all (missing or unparseable credential). +/// account claim, checked against the account registry. `Err` only when no +/// device can be attributed at all (missing or unparseable credential). /// /// The account-claim policy is left to the caller: a message drops on an /// unconfirmable claim, a roster entry keeps the device and forgoes the account. fn parse_credential( - directory: &impl AccountDirectory, + accounts: &impl AccountRegistry, encoded: &[u8], ) -> Result<(IdentId, AccountClaim), SenderError> { // No credential at all: there is no device to attribute. @@ -560,41 +562,39 @@ fn parse_credential( return Err(SenderError::Malformed); }; let device = IdentId::new(hex::encode(cred.delegate_id().as_ref())); - // An unassociated delegate asserts no account → device mapping. + // An unassociated delegate claims no account. let Some(account_addr) = cred.account_addr() else { return Ok((device, AccountClaim::None)); }; - let Some(account_key) = account_key_from_bytes(account_addr) else { + let Ok(addr) = AccountAddr::try_from(account_addr) else { tracing::warn!(account_addr = %hex::encode(account_addr), "account address is not a verifying key"); return Ok(( device, AccountClaim::Unverified(SenderError::AccountNotAKey), )); }; - let claim = match directory.fetch(&account_key) { - Ok(Some(set)) if set.devices.iter().any(|d| d.as_str() == device.as_str()) => { - AccountClaim::Verified(account_addr.to_vec()) - } + let claim = match accounts.is_ed25519_endorsed(cred.delegate_id(), &addr) { + Ok(true) => AccountClaim::Verified(account_addr.to_vec()), _ => { - tracing::warn!(account_addr = %hex::encode(account_addr), device = %device.as_str(), "account → device mapping is wrong or unconfirmable"); + tracing::warn!(account_addr = %addr, device = %device.as_str(), "account does not endorse this device, or the endorsement is unconfirmable"); AccountClaim::Unverified(SenderError::Unverified) } }; Ok((device, claim)) } -/// Decode and verify a message's sender from its credential, checked against the -/// account → device directory (our account store). +/// Decode and verify a message's sender from its credential, checked against +/// the account registry. /// /// `Ok(sender)` — deliver with the sender; its `account` is set only when the -/// directory confirmed the device, so it is always verified. `Err` — drop the -/// message (including when no credential is present, since every delivered +/// registry confirmed the endorsement, so it is always verified. `Err` — drop +/// the message (including when no credential is present, since every delivered /// message must carry an explicit sender). fn decode_sender( - directory: &impl AccountDirectory, + accounts: &impl AccountRegistry, encoded: &[u8], ) -> Result { - let (device, claim) = parse_credential(directory, encoded)?; + let (device, claim) = parse_credential(accounts, encoded)?; match claim { AccountClaim::None => Ok(MessageSender { account: None, @@ -623,7 +623,7 @@ fn dedup_members( .collect() } -fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec { +fn convo_events(outcome: ConvoOutcome, accounts: &impl AccountRegistry) -> Vec { let ConvoOutcome { convo_id, content, @@ -632,7 +632,7 @@ fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec let convo_id: Arc = Arc::from(convo_id); let mut events = Vec::new(); if let Some(c) = content - && let Ok(sender) = decode_sender(directory, &c.encoded_credential) + && let Ok(sender) = decode_sender(accounts, &c.encoded_credential) { events.push(Event::MessageReceived { convo_id: Arc::clone(&convo_id), @@ -646,7 +646,7 @@ fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec events } -fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec { +fn inbox_events(outcome: InboxOutcome, accounts: &impl AccountRegistry) -> Vec { let InboxOutcome { new_conversation, initial, @@ -658,7 +658,7 @@ fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec class: new_conversation.class, }); if let Some(c) = initial.and_then(|co| co.content) - && let Ok(sender) = decode_sender(directory, &c.encoded_credential) + && let Ok(sender) = decode_sender(accounts, &c.encoded_credential) { events.push(Event::MessageReceived { convo_id: Arc::clone(&id), @@ -675,7 +675,7 @@ mod sender_check_tests { use crypto::{Ed25519SigningKey, Ed25519VerifyingKey}; use libchat::IdentId; - use logos_account::{DeviceSet, SignedDeviceBundle}; + use logos_account::{AccountAddr, AccountRegistry}; use libchat::{AuthResult, SignerId}; @@ -684,47 +684,40 @@ mod sender_check_tests { }; use crate::delegate::DelegateCredential; - /// In-test account → device directory. Holds device id sets keyed by the hex - /// account key, and can be made to fail to simulate a directory outage. + /// In-test account registry. Holds the endorsed key set per account, and + /// can be made to fail to simulate a registry outage. #[derive(Debug, Default)] struct FakeDir { - bundles: HashMap>, + endorsements: HashMap>, fail: bool, } impl FakeDir { - /// Publish `devices` (verifying keys) as `account`'s device set. + /// Endorse `devices` (verifying keys) under `account`. fn with_devices(account: &Ed25519VerifyingKey, devices: &[&Ed25519VerifyingKey]) -> Self { - let mut bundles = HashMap::new(); - bundles.insert( - hex::encode(account.as_ref()), - devices.iter().map(|d| hex::encode(d.as_ref())).collect(), + let mut endorsements = HashMap::new(); + endorsements.insert( + AccountAddr::from(account), + devices.iter().map(|d| (*d).clone()).collect(), ); Self { - bundles, + endorsements, fail: false, } } } - impl logos_account::AccountDirectory for FakeDir { + impl AccountRegistry for FakeDir { type Error = &'static str; - fn publish(&mut self, _: &SignedDeviceBundle) -> Result<(), Self::Error> { - Ok(()) - } - - fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error> { + fn endorsed_ed25519_keys( + &self, + addr: &AccountAddr, + ) -> Result>, Self::Error> { if self.fail { - return Err("directory unavailable"); + return Err("registry unavailable"); } - Ok(self - .bundles - .get(&hex::encode(account.as_ref())) - .map(|devices| DeviceSet { - lamport: 1, - devices: devices.clone(), - })) + Ok(self.endorsements.get(addr).cloned()) } } @@ -808,10 +801,10 @@ mod sender_check_tests { ); } - /// A directory outage leaves the mapping unconfirmed, so the message is + /// A registry outage leaves the endorsement unconfirmed, so the message is /// dropped rather than delivered on an unverified claim. #[test] - fn directory_error_is_dropped() { + fn registry_error_is_dropped() { let account = key(); let device = key(); let dir = FakeDir { diff --git a/crates/generic-chat/src/lib.rs b/crates/generic-chat/src/lib.rs index cfde171..4a1295a 100644 --- a/crates/generic-chat/src/lib.rs +++ b/crates/generic-chat/src/lib.rs @@ -21,7 +21,7 @@ pub use libchat::{ }; // The directory trait bounds ChatClient's registry parameter, so callers // writing code generic over ChatClient need it too. -pub use logos_account::AccountDirectory; +pub use logos_account::AccountRegistry; // Re-export bundled registry implementations so callers can pick one without // pulling in `components` directly. diff --git a/crates/generic-chat/tests/group_v2.rs b/crates/generic-chat/tests/group_v2.rs index 9b5847d..4315224 100644 --- a/crates/generic-chat/tests/group_v2.rs +++ b/crates/generic-chat/tests/group_v2.rs @@ -50,13 +50,13 @@ fn create_test_client( /// to observe the group between two protocol steps. fn create_test_client_with( message_bus: MessageBus, - mut reg: EphemeralRegistry, + reg: EphemeralRegistry, config: GroupV2Config, ) -> (TestClient, Receiver, Vec) { - let mut account = TestLogosAccount::new(); + let mut account = TestLogosAccount::new(reg.clone()); let delegate = DelegateSigner::random(); account - .endorse_ed25519_signer(&mut reg, delegate.public_key()) + .endorse_ed25519_signer(delegate.public_key()) .unwrap(); let (client, events) = ChatClientBuilder::new(account.address().to_bytes()) .auth(LogosAuthVerifier::new()) @@ -378,7 +378,7 @@ fn pending_clears_once_the_add_commits() { #[test] fn add_batch_with_missing_key_package_invites_no_one() { let bus = MessageBus::default(); - let mut reg = EphemeralRegistry::new(); + let reg = EphemeralRegistry::new(); let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone()); let (_raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone()); @@ -386,10 +386,10 @@ fn add_batch_with_missing_key_package_invites_no_one() { // Ghost: its account endorses a device, but that device never registered a // key package (no client was built for it). - let mut ghost_account = TestLogosAccount::new(); + let mut ghost_account = TestLogosAccount::new(reg.clone()); let ghost_delegate = DelegateSigner::random(); ghost_account - .endorse_ed25519_signer(&mut reg, ghost_delegate.public_key()) + .endorse_ed25519_signer(ghost_delegate.public_key()) .unwrap(); let convo_id = saro @@ -423,7 +423,7 @@ fn group_invite_of_unpublished_account_is_an_error() { let reg = EphemeralRegistry::new(); let (mut saro, _saro_events, _saro_addr) = create_test_client(bus.clone(), reg.clone()); - let unpublished = TestLogosAccount::new(); + let unpublished = TestLogosAccount::new(reg.clone()); let err = saro .create_group_conversation(&[unpublished.address().to_bytes()], unnamed_group()) diff --git a/crates/generic-chat/tests/saro_and_raya.rs b/crates/generic-chat/tests/saro_and_raya.rs index 7e89482..c445df3 100644 --- a/crates/generic-chat/tests/saro_and_raya.rs +++ b/crates/generic-chat/tests/saro_and_raya.rs @@ -13,7 +13,7 @@ use logos_generic_chat::{ #[allow(clippy::type_complexity)] fn create_test_client( message_bus: MessageBus, - mut reg: EphemeralRegistry, + reg: EphemeralRegistry, ) -> Result< ( ChatClient, @@ -21,10 +21,10 @@ fn create_test_client( ), logos_generic_chat::ClientError, > { - let mut account = TestLogosAccount::new(); + let mut account = TestLogosAccount::new(reg.clone()); let delegate = DelegateSigner::random(); account - .endorse_ed25519_signer(&mut reg, delegate.public_key()) + .endorse_ed25519_signer(delegate.public_key()) .unwrap(); let d = InProcessDelivery::new(message_bus); ChatClientBuilder::new(account.address().to_bytes()) @@ -79,17 +79,17 @@ fn direct_v1_integration() { fn direct_v1_standalone_integration() { let bus = MessageBus::default(); - let mut reg_service = EphemeralRegistry::new(); + let reg_service = EphemeralRegistry::new(); // Create accounts and delegates, and endorse each delegate on its account so // the receiver can verify the account → device mapping carried in the // sender's credential. - let mut saro_account = TestLogosAccount::new(); + let mut saro_account = TestLogosAccount::new(reg_service.clone()); let saro_account_id = saro_account.address().to_bytes().to_vec(); let saro_delegate = DelegateSigner::random(); let saro_device_id = hex::encode(saro_delegate.public_key().as_ref()); saro_account - .endorse_ed25519_signer(&mut reg_service, saro_delegate.public_key()) + .endorse_ed25519_signer(saro_delegate.public_key()) .unwrap(); // Build saro's client with its account so its outbound messages carry a @@ -138,13 +138,13 @@ fn direct_v1_standalone_integration() { #[test] fn direct_v1_by_account_address() { let bus = MessageBus::default(); - let mut reg_service = EphemeralRegistry::new(); + let reg_service = EphemeralRegistry::new(); - let mut raya_account = TestLogosAccount::new(); + let mut raya_account = TestLogosAccount::new(reg_service.clone()); let raya_account_addr = raya_account.address().to_bytes().to_vec(); let raya_delegate = DelegateSigner::random(); raya_account - .endorse_ed25519_signer(&mut reg_service, raya_delegate.public_key()) + .endorse_ed25519_signer(raya_delegate.public_key()) .unwrap(); let (mut raya, raya_events) = ChatClientBuilder::new(raya_account_addr.clone()) @@ -397,7 +397,9 @@ fn malformed_inbound_surfaces_as_error_event() { let delivery = FailingDelivery::new(); let inbound_tx = delivery.inbound_sender(); - let (_client, events) = ChatClientBuilder::new(TestLogosAccount::new().address().to_bytes()) + // This client never resolves an account, so its registry only has to exist. + let account = TestLogosAccount::new(EphemeralRegistry::new()); + let (_client, events) = ChatClientBuilder::new(account.address().to_bytes()) .auth(LogosAuthVerifier::new()) .transport(delivery) .build() @@ -424,7 +426,7 @@ fn unpublished_account_address_is_an_error() { let (mut saro, _saro_events) = create_test_client(bus.clone(), reg_service.clone()).expect("client create"); - let unpublished = TestLogosAccount::new(); + let unpublished = TestLogosAccount::new(reg_service.clone()); let err = saro .create_direct_conversation(unpublished.address().to_bytes()) .expect_err("nothing published for the account"); diff --git a/crates/logos-chat/src/logos.rs b/crates/logos-chat/src/logos.rs index e2e7ca1..740c5c6 100644 --- a/crates/logos-chat/src/logos.rs +++ b/crates/logos-chat/src/logos.rs @@ -137,15 +137,15 @@ pub fn open_with_transport( // key is dropped after the endorsement, so devices cannot be added // later. A caller-supplied, custody-holding account replaces // this once the platform provides one. - let mut account = TestLogosAccount::new(); - let delegate = DelegateSigner::random(); - let mut registry = ContactRegistry::new( + let registry = ContactRegistry::new( transport.clone(), config.registry_url, config.registry_publish_mode, ); + let delegate = DelegateSigner::random(); + let mut account = TestLogosAccount::new(registry.clone()); account - .endorse_ed25519_signer(&mut registry, delegate.public_key()) + .endorse_ed25519_signer(delegate.public_key()) .map_err(|e| ClientError::BundlePublish(e.to_string()))?; let mut builder = ChatClientBuilder::new(account.address().to_bytes()) .auth(LogosAuthVerifier::new()) diff --git a/extensions/components/src/contact_registry/ephemeral.rs b/extensions/components/src/contact_registry/ephemeral.rs index f240ae2..e6c1f69 100644 --- a/extensions/components/src/contact_registry/ephemeral.rs +++ b/extensions/components/src/contact_registry/ephemeral.rs @@ -6,19 +6,19 @@ use std::{ use crypto::Ed25519VerifyingKey; use libchat::{IdentityProvider, RegistrationService}; -use logos_account::{AccountDirectory, DeviceSet, SignedDeviceBundle, verify_bundle}; +use logos_account::{AccountAddr, AccountLogStore, AccountRegistry, SignedAccountLog, verify_log}; /// A Contact Registry used for Tests. -/// This implementation stores bundle bytes and then returns them when -/// retrieved. +/// This implementation stores what it is given and returns it when retrieved. /// /// Like the real `keypackage-registry`, one object serves both roles: a -/// keypackage store ([`RegistrationService`]) keyed by `device_id`, and an -/// account → device directory ([`AccountDirectory`]) keyed by the hex account key. +/// keypackage store ([`RegistrationService`]) keyed by `device_id`, and the +/// account log store ([`AccountLogStore`] / [`AccountRegistry`]) keyed by +/// account address. #[derive(Clone, Default)] pub struct EphemeralRegistry { key_packages: Arc>>>, - installations: Arc>>, + accounts: Arc>>, } impl EphemeralRegistry { @@ -76,36 +76,34 @@ impl RegistrationService for EphemeralRegistry { } } -/// Account → device directory, verifying each bundle on `fetch` exactly as the -/// HTTP client does so callers exercise the same trust path without a server. -impl AccountDirectory for EphemeralRegistry { +/// Stores whatever it is handed, like the untrusted service it stands in for. +impl AccountLogStore for EphemeralRegistry { type Error = String; - fn publish( + fn publish_log( &mut self, - bundle: &SignedDeviceBundle, - ) -> Result<(), ::Error> { - self.installations - .lock() - .unwrap() - .insert(hex::encode(bundle.account_pub.as_ref()), bundle.clone()); + addr: &AccountAddr, + log: SignedAccountLog, + ) -> Result<(), ::Error> { + self.accounts.lock().unwrap().insert(addr.clone(), log); Ok(()) } +} - fn fetch( +/// Verifies each log on read exactly as the HTTP client does, so callers +/// exercise the same trust path without a server. +impl AccountRegistry for EphemeralRegistry { + type Error = String; + + fn endorsed_ed25519_keys( &self, - account: &Ed25519VerifyingKey, - ) -> Result, ::Error> { - let Some(bundle) = self - .installations - .lock() - .unwrap() - .get(&hex::encode(account.as_ref())) - .cloned() - else { + addr: &AccountAddr, + ) -> Result>, ::Error> { + let Some(signed) = self.accounts.lock().unwrap().get(addr).cloned() else { return Ok(None); }; - verify_bundle(account, &bundle) + let log = verify_log(addr, &signed).map_err(|e| e.to_string())?; + log.endorsed_ed25519_keys() .map(Some) .map_err(|e| e.to_string()) } diff --git a/extensions/components/src/contact_registry/store.rs b/extensions/components/src/contact_registry/store.rs index 251cf39..1383e08 100644 --- a/extensions/components/src/contact_registry/store.rs +++ b/extensions/components/src/contact_registry/store.rs @@ -6,7 +6,10 @@ use base64::engine::general_purpose::STANDARD as BASE64; use chat_proto::logoschat::store::{AccountSubmissionV1, KeyPackageSubmissionV1}; use crypto::{Ed25519Signature, Ed25519VerifyingKey}; use libchat::{AddressedEnvelope, DeliveryService, IdentityProvider, RegistrationService}; -use logos_account::{AccountDirectory, BundleError, DeviceSet, SignedDeviceBundle, verify_bundle}; +use logos_account::{ + AccountAddr, AccountLogError, AccountLogStore, AccountRegistry, EncodedAccountLog, + SignedAccountLog, verify_log, +}; use prost::Message; use prost::bytes::Bytes; use serde::{Deserialize, Serialize}; @@ -17,14 +20,14 @@ use serde::{Deserialize, Serialize}; /// subscribes to the same topic. pub const KEYPACKAGE_SUBMIT_ADDRESS: &str = "store-keypackage-v0"; -/// Delivery address the store listens on for account device-list bundles. +/// Delivery address the store listens on for signed account logs. pub const ACCOUNT_SUBMIT_ADDRESS: &str = "store-account-v0"; /// Request timeout for the store's HTTP API (queries, and submissions in /// [`RegistryPublishMode::Http`]). const HTTP_TIMEOUT: Duration = Duration::from_secs(10); -/// How a [`ContactRegistry`] submits bundles to the store. Reads always use +/// How a [`ContactRegistry`] submits to the store. Reads always use /// the store's HTTP query API; only the write half switches. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum RegistryPublishMode { @@ -34,11 +37,11 @@ pub enum RegistryPublishMode { /// Publish over the delivery network on the well-known store addresses; /// the store subscribes and persists what verifies. Fire-and-forget — /// there is no per-submission acknowledgement, which the registry can - /// afford because consumers verify every bundle on retrieval anyway. + /// afford because consumers verify everything they retrieve anyway. Delivery, } -/// The keypackage store and account → device directory. +/// The keypackage store and account log store. /// /// Reads (keypackage retrieve, account fetch) always go over the store's HTTP /// query API. Writes (register, publish) go over whichever wire @@ -81,8 +84,8 @@ pub enum ContactRegistryError { Clock, #[error("signature verification failed")] SignatureInvalid, - #[error("bundle: {0}")] - Bundle(#[from] BundleError), + #[error("account log: {0}")] + Log(#[from] AccountLogError), #[error("publish over delivery: {0}")] Publish(String), } @@ -234,51 +237,56 @@ impl RegistrationService for ContactRegistry { } } -impl AccountDirectory for ContactRegistry { +impl AccountLogStore for ContactRegistry { type Error = ContactRegistryError; - fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error> { - // The bundle is already signed; both wires carry its exact bytes. + fn publish_log( + &mut self, + addr: &AccountAddr, + log: SignedAccountLog, + ) -> Result<(), Self::Error> { + // The log is already signed; both wires carry its exact bytes. match self.publish_mode { RegistryPublishMode::Http => self.http_post( "/v0/account", &SubmitAccountRequest { - account_pub: hex::encode(bundle.account_pub.as_ref()), - payload: BASE64.encode(&bundle.payload), - signature: BASE64.encode(bundle.signature.as_ref()), + account_pub: hex::encode(addr.to_bytes()), + payload: BASE64.encode(log.payload.as_bytes()), + signature: BASE64.encode(log.signature.as_ref()), }, ), RegistryPublishMode::Delivery => { let req = AccountSubmissionV1 { - account_pub: Bytes::copy_from_slice(bundle.account_pub.as_ref()), - payload: Bytes::copy_from_slice(&bundle.payload), - signature: Bytes::copy_from_slice(bundle.signature.as_ref()), + account_pub: Bytes::copy_from_slice(addr.to_bytes()), + payload: Bytes::copy_from_slice(log.payload.as_bytes()), + signature: Bytes::copy_from_slice(log.signature.as_ref()), }; self.publish_submission(ACCOUNT_SUBMIT_ADDRESS, &req) } } } +} - fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error> { - let url = format!( - "{}/v0/account/{}", - self.base_url, - hex::encode(account.as_ref()) - ); +impl AccountRegistry for ContactRegistry { + type Error = ContactRegistryError; + + fn endorsed_ed25519_keys( + &self, + addr: &AccountAddr, + ) -> Result>, Self::Error> { + let url = format!("{}/v0/account/{}", self.base_url, addr); let Some(FetchedBundle { payload, signature }) = self.http_fetch(&url)? else { return Ok(None); }; - // The directory service is untrusted: verify the account signature over - // the exact received bytes, and that the bundle is bound to the account - // we asked for, before handing back any device keys. - let bundle = SignedDeviceBundle { - account_pub: account.clone(), - payload, + // The store is untrusted: parse the bytes it returned as a log and + // verify the account signature over exactly those bytes, under the + // address we asked for, before handing back any keys. + let signed = SignedAccountLog { + payload: EncodedAccountLog::parse(payload)?, signature: Ed25519Signature::from(signature), }; - let device_set = verify_bundle(account, &bundle)?; - Ok(Some(device_set)) + Ok(Some(verify_log(addr, &signed)?.endorsed_ed25519_keys()?)) } } @@ -299,7 +307,7 @@ struct SubmitRequest { struct SubmitAccountRequest { /// hex of the 32-byte account verifying key — verification + storage key. account_pub: String, - /// base64 of the canonical signed device-list payload. + /// base64 of the canonical signed account-log payload. payload: String, /// base64 of the 64-byte account signature over `payload`. signature: String, @@ -417,6 +425,7 @@ mod tests { use super::*; use crypto::Ed25519SigningKey; use libchat::{IdentId, IdentIdRef}; + use logos_account::AccountLog; #[derive(Debug, Default)] struct CapturingDelivery { @@ -505,13 +514,13 @@ mod tests { RegistryPublishMode::Delivery, ); let account = Ed25519SigningKey::generate(); - let payload = b"signed-device-list".to_vec(); - let bundle = SignedDeviceBundle { - account_pub: account.verifying_key(), - signature: account.sign(&payload), - payload: payload.clone(), + let addr = AccountAddr::from(&account.verifying_key()); + let payload = AccountLog::new(vec![]).unwrap().encode(); + let log = SignedAccountLog { + signature: account.sign(payload.as_bytes()), + payload, }; - registry.publish(&bundle).unwrap(); + registry.publish_log(&addr, log.clone()).unwrap(); let [envelope] = ®istry.delivery.published[..] else { panic!("expected exactly one published envelope"); @@ -519,11 +528,11 @@ mod tests { assert_eq!(envelope.delivery_address, ACCOUNT_SUBMIT_ADDRESS); let wire = AccountSubmissionV1::decode(&envelope.data[..]).unwrap(); - assert_eq!(wire.account_pub.as_ref(), bundle.account_pub.as_ref()); + assert_eq!(wire.account_pub.as_ref(), addr.to_bytes()); // Payload travels verbatim so the store and consumers verify the exact // signed bytes. - assert_eq!(wire.payload.as_ref(), payload.as_slice()); - assert_eq!(wire.signature.as_ref(), bundle.signature.as_ref()); + assert_eq!(wire.payload.as_ref(), log.payload.as_bytes()); + assert_eq!(wire.signature.as_ref(), log.signature.as_ref()); } #[test]