Add publish to AccountDirectory

This commit is contained in:
Jazz Turner-Baggs 2026-08-05 17:53:12 -07:00
parent be8830c694
commit c7a5d68114
No known key found for this signature in database
10 changed files with 241 additions and 190 deletions

1
Cargo.lock generated
View File

@ -1317,6 +1317,7 @@ dependencies = [
"clap",
"crossbeam-channel",
"crossterm 0.29.0",
"hex",
"logos-account",
"logos-chat",
"ratatui",

View File

@ -10,8 +10,9 @@ use std::{
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
use crate::{
AccountAddr, AccountEntry, AccountError, AccountLog, AccountRegistry, EntryData,
SignedAccountLog, verify_extension, verify_log,
AccountAddr, AccountDirectory, AccountEntry, AccountError, AccountLog, AccountRegistry,
EntryData, Lamport, SignedAccountLog, SignedDeviceBundle, encode_bundle_payload,
verify_extension, verify_log,
};
/// Logs "published" by accounts, keyed by address.
@ -87,7 +88,7 @@ impl TestLogosAccount {
TestAccountService::new().account()
}
fn with_service(service: TestAccountService) -> Self {
pub fn with_service(service: TestAccountService) -> Self {
let signing_key = Ed25519SigningKey::generate();
let addr = AccountAddr::from(&signing_key.verifying_key());
Self {
@ -112,7 +113,50 @@ impl TestLogosAccount {
&self.addr
}
pub fn endorse_ed25519_signer(
/// 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<D: AccountDirectory>(
&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::<Result<Vec<_>, _>>()?;
// 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(
&mut self,
key: &Ed25519VerifyingKey,
) -> Result<(), AccountError> {
@ -148,7 +192,7 @@ mod tests {
let mut account = srv.account();
let dev = device();
account.endorse_ed25519_signer(&dev).unwrap();
account.append_ed25519_endorsement(&dev).unwrap();
assert!(srv.is_ed25519_endorsed(&dev, account.address()).unwrap());
assert!(
@ -176,8 +220,8 @@ mod tests {
let mut account = srv.account();
let (a, b) = (device(), device());
account.endorse_ed25519_signer(&a).unwrap();
account.endorse_ed25519_signer(&b).unwrap();
account.append_ed25519_endorsement(&a).unwrap();
account.append_ed25519_endorsement(&b).unwrap();
let keys = srv
.endorsed_ed25519_keys(account.address())

View File

@ -9,21 +9,21 @@ fn main() {
let bus = MessageBus::default();
let mut reg = EphemeralRegistry::new();
// Mint two accounts, each with a delegate signer, and publish their device
// bundles so a peer can resolve an account address to its device.
let saro_account = TestLogosAccount::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 saro_delegate = DelegateSigner::random();
saro_account
.add_delegate_signer(&mut reg, saro_delegate.public_key())
.endorse_ed25519_signer(&mut reg, saro_delegate.public_key())
.unwrap();
let raya_account = TestLogosAccount::new();
let mut raya_account = TestLogosAccount::new();
let raya_delegate = DelegateSigner::random();
raya_account
.add_delegate_signer(&mut reg, raya_delegate.public_key())
.endorse_ed25519_signer(&mut reg, raya_delegate.public_key())
.unwrap();
let (mut saro, saro_events) = ChatClientBuilder::new(saro_account.address())
let (mut saro, saro_events) = ChatClientBuilder::new(saro_account.address().to_bytes())
.auth(LogosAuthVerifier::new())
.ident(saro_delegate)
.transport(InProcessDelivery::new(bus.clone()))
@ -31,7 +31,7 @@ fn main() {
.build()
.unwrap();
let (mut raya, raya_events) = ChatClientBuilder::new(raya_account.address())
let (mut raya, raya_events) = ChatClientBuilder::new(raya_account.address().to_bytes())
.auth(LogosAuthVerifier::new())
.ident(raya_delegate)
.transport(InProcessDelivery::new(bus))

View File

@ -19,7 +19,7 @@ pub struct Unset;
pub struct ChatClientBuilder<I = Unset, AS = Unset, T = Unset, R = Unset, S = Unset> {
ident: I,
auth: AS,
account: String,
account: Vec<u8>,
transport: T,
registration: R,
storage: S,
@ -28,14 +28,14 @@ pub struct ChatClientBuilder<I = Unset, AS = Unset, T = Unset, R = Unset, S = Un
impl ChatClientBuilder {
/// Every client acts for an account, so the builder starts from its
/// address. It becomes the client's shareable address
/// address bytes. They become the client's shareable address
/// ([`ChatClient::addr`]) and the account claim in the wire credential;
/// the account must endorse the signer in the directory for peers to
/// verify that claim.
///
/// A credential verifier is required before [`build`](ChatClientBuilder::build);
/// set one with [`auth`](ChatClientBuilder::auth).
pub fn new(account: impl Into<String>) -> Self {
pub fn new(account: impl Into<Vec<u8>>) -> Self {
Self {
ident: Unset,
auth: Unset,

View File

@ -34,7 +34,9 @@ impl AuthVerifyService for LogosAuthVerifier {
}
type ClientCore<T, R, S> = Core<(DelegateIdentity, T, R, ThreadedWakeupService, S)>;
type AccountAddressRef<'a> = &'a str;
/// An account address as the client handles it: opaque bytes, interpreted only
/// where they meet the account layer.
type AccountAddressRef<'a> = &'a [u8];
type LocalSignerId = IdentId;
/// A member of a group conversation's roster.
@ -52,7 +54,7 @@ type LocalSignerId = IdentId;
/// never commits stays pending for the life of the conversation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupMember {
pub account: Option<IdentId>,
pub account: Option<Vec<u8>>,
pub local_identity: IdentId,
pub pending: bool,
}
@ -91,8 +93,8 @@ impl MemberWithAuthResult {
/// The account this member's credential claims, if any. Trustworthy only
/// when `auth_result` is `Valid`; the credential asserts it, unverified.
pub fn account_claim(&self) -> Option<String> {
self.credential()?.account_addr().map(str::to_owned)
pub fn account_claim(&self) -> Option<Vec<u8>> {
self.credential()?.account_addr().map(<[u8]>::to_vec)
}
}
@ -107,7 +109,7 @@ impl From<MemberWithAuthResult> for GroupMember {
.map(|c| IdentId::new(hex::encode(c.delegate_id().as_ref())))
.unwrap_or_else(|| IdentId::new(hex::encode(member.signer_id.as_bytes())));
let account = (member.auth_result == AuthResult::Valid)
.then(|| cred.and_then(|c| c.account_addr().map(|a| IdentId::new(a.to_string()))))
.then(|| cred.and_then(|c| c.account_addr().map(<[u8]>::to_vec)))
.flatten();
GroupMember {
account,
@ -175,7 +177,7 @@ where
/// Dropped on `Drop` to wake the worker's `select!` and shut it down.
shutdown: Option<Sender<()>>,
worker: Option<JoinHandle<()>>,
address: String,
address: Vec<u8>,
}
// -- GenericChatClient
@ -189,7 +191,7 @@ where
pub fn new(
ident: DelegateSigner,
auth: AS,
account: String,
account: Vec<u8>,
mut transport: T,
reg: R,
storage: S,
@ -201,8 +203,7 @@ where
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
let directory = reg.clone();
let ident = DelegateIdentity::new(ident, &account);
let mut core =
Core::new_with_name(ident, transport, reg, wakeup_service, storage)?;
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);
}
@ -215,7 +216,7 @@ where
core: ClientCore<T, R, S>,
auth: AS,
directory: R,
address: String,
address: Vec<u8>,
inbound: Receiver<Vec<u8>>,
wakeup_events: Receiver<WakeupEvent>,
) -> (Self, Receiver<Event>) {
@ -252,7 +253,7 @@ where
}
/// The account address peers use to reach this client.
pub fn addr(&self) -> &str {
pub fn addr(&self) -> &[u8] {
&self.address
}
@ -385,7 +386,9 @@ where
&self,
account: AccountAddressRef,
) -> Result<Vec<LocalSignerId>, ClientError> {
let account = IdentId::new(account.to_string());
// 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())
@ -500,9 +503,9 @@ fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory
}
}
/// Interpret a hex account address as an Ed25519 account verifying key.
fn account_key_from_hex(addr: &str) -> Option<Ed25519VerifyingKey> {
let bytes: [u8; 32] = hex::decode(addr).ok()?.try_into().ok()?;
/// Interpret account address bytes as an Ed25519 account verifying key.
fn account_key_from_bytes(addr: &[u8]) -> Option<Ed25519VerifyingKey> {
let bytes: [u8; 32] = addr.try_into().ok()?;
Ed25519VerifyingKey::from_bytes(&bytes).ok()
}
@ -516,7 +519,7 @@ enum SenderError {
NotHex,
/// Credential bytes did not decode to a delegate credential.
Malformed,
/// The claimed account address is not an Ed25519 verifying key.
/// 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,
@ -529,7 +532,7 @@ enum AccountClaim {
/// The credential claimed no account.
None,
/// Confirmed: the directory lists this device under the claimed account.
Verified(IdentId),
Verified(Vec<u8>),
/// An account was claimed but could not be confirmed (see [`SenderError`]).
Unverified(SenderError),
}
@ -561,8 +564,8 @@ fn parse_credential(
let Some(account_addr) = cred.account_addr() else {
return Ok((device, AccountClaim::None));
};
let Some(account_key) = account_key_from_hex(account_addr) else {
tracing::warn!(account_addr, "account address is not a verifying key");
let Some(account_key) = account_key_from_bytes(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),
@ -570,10 +573,10 @@ fn parse_credential(
};
let claim = match directory.fetch(&account_key) {
Ok(Some(set)) if set.devices.iter().any(|d| d.as_str() == device.as_str()) => {
AccountClaim::Verified(IdentId::new(account_addr.to_string()))
AccountClaim::Verified(account_addr.to_vec())
}
_ => {
tracing::warn!(account_addr, device = %device.as_str(), "account → device mapping is wrong or unconfirmable");
tracing::warn!(account_addr = %hex::encode(account_addr), device = %device.as_str(), "account → device mapping is wrong or unconfirmable");
AccountClaim::Unverified(SenderError::Unverified)
}
};
@ -739,6 +742,11 @@ mod sender_check_tests {
IdentId::new(hex::encode(k.as_ref()))
}
/// An account address as the client carries it: the raw key bytes.
fn account_addr(k: &Ed25519VerifyingKey) -> Vec<u8> {
k.as_ref().to_vec()
}
/// The account published a device set that includes the sending device — the
/// claim checks out, so the message is delivered with a verified account.
#[test]
@ -746,11 +754,11 @@ mod sender_check_tests {
let account = key();
let device = key();
let dir = FakeDir::with_devices(&account, &[&device]);
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
let cred = DelegateCredential::associated(&device, account.as_ref());
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Ok(MessageSender {
account: Some(local_id(&account)),
account: Some(account_addr(&account)),
local_identity: local_id(&device),
})
);
@ -764,7 +772,7 @@ mod sender_check_tests {
let endorsed = key();
let spoofer = key();
let dir = FakeDir::with_devices(&account, &[&endorsed]);
let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref()));
let cred = DelegateCredential::associated(&spoofer, account.as_ref());
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::Unverified)
@ -793,7 +801,7 @@ mod sender_check_tests {
let account = key();
let device = key();
let dir = FakeDir::default(); // nothing published
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
let cred = DelegateCredential::associated(&device, account.as_ref());
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::Unverified)
@ -810,7 +818,7 @@ mod sender_check_tests {
fail: true,
..Default::default()
};
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
let cred = DelegateCredential::associated(&device, account.as_ref());
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::Unverified)
@ -841,7 +849,7 @@ mod sender_check_tests {
#[test]
fn non_key_account_address_is_dropped() {
let dir = FakeDir::default();
let cred = DelegateCredential::associated(&key(), "user@example.com");
let cred = DelegateCredential::associated(&key(), b"user@example.com");
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::AccountNotAKey)
@ -870,11 +878,11 @@ mod sender_check_tests {
fn resolves_verified_member_to_account_and_device() {
let account = key();
let device = key();
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
let cred = DelegateCredential::associated(&device, account.as_ref());
assert_eq!(
GroupMember::from(member_entry(cred, AuthResult::Valid, false)),
GroupMember {
account: Some(local_id(&account)),
account: Some(account_addr(&account)),
local_identity: local_id(&device),
pending: false,
}
@ -887,7 +895,7 @@ mod sender_check_tests {
fn resolves_unverified_member_to_device_without_account() {
let account = key();
let device = key();
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
let cred = DelegateCredential::associated(&device, account.as_ref());
assert_eq!(
GroupMember::from(member_entry(cred, AuthResult::Mismatch, false)),
GroupMember {

View File

@ -3,8 +3,6 @@ use libchat::{IdentId, IdentityProvider, trunc};
use crate::ClientError;
type AccountAddr = String;
/// A local signing identity that holds an Ed25519 keypair — the per-device
/// (installation) signer. It knows nothing about accounts: the client composes
/// the account association into the wire credential ([`DelegateIdentity`]).
@ -42,7 +40,7 @@ pub(crate) struct DelegateIdentity {
}
impl DelegateIdentity {
pub(crate) fn new(signer: DelegateSigner, account: &str) -> Self {
pub(crate) fn new(signer: DelegateSigner, account: &[u8]) -> Self {
let credential = DelegateCredential::associated(signer.public_key(), account);
Self {
identifier: credential.into(),
@ -73,11 +71,12 @@ impl IdentityProvider for DelegateIdentity {
///
/// Serialized as a TLV byte sequence prefixed with magic bytes `0x23 0x23`.
/// A credential without an `account_addr` is *unassociated* — it identifies the
/// delegate key but has not yet been linked to an account.
/// delegate key but has not yet been linked to an account. The address is
/// carried as opaque bytes; only the account layer interprets them.
#[derive(Debug)]
pub struct DelegateCredential {
delegate_id: Ed25519VerifyingKey,
account_addr: Option<AccountAddr>,
account_addr: Option<Vec<u8>>,
}
impl DelegateCredential {
@ -91,10 +90,10 @@ impl DelegateCredential {
}
}
pub fn associated(delegate: &Ed25519VerifyingKey, account: &str) -> Self {
pub fn associated(delegate: &Ed25519VerifyingKey, account: &[u8]) -> Self {
Self {
delegate_id: delegate.clone(),
account_addr: Some(account.to_string()),
account_addr: Some(account.to_vec()),
}
}
@ -105,7 +104,7 @@ impl DelegateCredential {
/// The account this delegate claims to act for, if it is associated. The
/// claim is unverified — confirm it against the account directory.
pub fn account_addr(&self) -> Option<&str> {
pub fn account_addr(&self) -> Option<&[u8]> {
self.account_addr.as_deref()
}
@ -120,13 +119,12 @@ impl DelegateCredential {
data.extend_from_slice(&[Self::TAG_DELEGATE_ID, key_bytes.len() as u8]);
data.extend_from_slice(key_bytes);
if let Some(addr) = self.account_addr {
let addr_bytes = addr.as_bytes();
debug_assert!(
addr_bytes.len() <= 255,
addr.len() <= 255,
"account_addr too large for 1-byte TLV length"
);
data.extend_from_slice(&[Self::TAG_ACCOUNT_ADDR, addr_bytes.len() as u8]);
data.extend_from_slice(addr_bytes);
data.extend_from_slice(&[Self::TAG_ACCOUNT_ADDR, addr.len() as u8]);
data.extend_from_slice(&addr);
}
data
}
@ -167,10 +165,7 @@ impl TryFrom<Vec<u8>> for DelegateCredential {
);
}
DelegateCredential::TAG_ACCOUNT_ADDR => {
account_addr = Some(
String::from_utf8(v.to_vec())
.map_err(|_| ClientError::BadlyFormedCredential)?,
);
account_addr = Some(v.to_vec());
}
_ => {}
}
@ -218,7 +213,7 @@ mod tests {
#[test]
fn roundtrip_associated() {
let key = test_key();
let bytes = DelegateCredential::associated(&key, "user@example.com").serialize();
let bytes = DelegateCredential::associated(&key, b"user@example.com").serialize();
let recovered: DelegateCredential = bytes.clone().try_into().unwrap();
assert_eq!(recovered.serialize(), bytes);
}
@ -235,7 +230,7 @@ mod tests {
#[test]
fn ident_id_roundtrip_associated() {
let key = test_key();
let addr = "user@example.com";
let addr = b"user@example.com";
let original = DelegateCredential::associated(&key, addr).serialize();
let ident_id: IdentId = DelegateCredential::associated(&key, addr).into();
let recovered: DelegateCredential = ident_id.try_into().unwrap();
@ -245,12 +240,12 @@ mod tests {
#[test]
fn account_addr_preserved_across_roundtrip() {
let key = test_key();
let addr = "alice@libchat.example";
let addr = b"alice@libchat.example";
let recovered: DelegateCredential = DelegateCredential::associated(&key, addr)
.serialize()
.try_into()
.unwrap();
assert_eq!(recovered.account_addr.as_deref(), Some(addr));
assert_eq!(recovered.account_addr.as_deref(), Some(addr.as_slice()));
}
#[test]
@ -293,18 +288,16 @@ mod tests {
));
}
/// An account address is opaque bytes — a raw key, not text — so bytes that
/// are not valid UTF-8 survive the round trip verbatim.
#[test]
fn invalid_utf8_account_addr_rejected() {
fn non_utf8_account_addr_roundtrips() {
let key = test_key();
// Build a valid credential then corrupt the account_addr bytes
let mut bytes = DelegateCredential::unassociated(&key).serialize();
// Append a TAG_ACCOUNT_ADDR field with invalid UTF-8
bytes.push(DelegateCredential::TAG_ACCOUNT_ADDR);
bytes.push(3); // len
bytes.extend_from_slice(&[0xFF, 0xFE, 0xFD]); // invalid UTF-8
assert!(matches!(
DelegateCredential::try_from(bytes),
Err(ClientError::BadlyFormedCredential)
));
let addr = [0xFFu8, 0xFE, 0xFD];
let recovered: DelegateCredential = DelegateCredential::associated(&key, &addr)
.serialize()
.try_into()
.unwrap();
assert_eq!(recovered.account_addr.as_deref(), Some(addr.as_slice()));
}
}

View File

@ -15,11 +15,11 @@ use libchat::{ConversationClass, IdentId};
/// `account` is present only when the sender associated an account *and* the
/// account → device directory confirmed this device belongs to it — spoofed or
/// unconfirmable claims never reach the application, so a `Some` account is
/// always verified. `local_identity` is the sending device (delegate key),
/// hex-encoded.
/// always verified. It carries the account address bytes. `local_identity` is
/// the sending device (delegate key), hex-encoded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageSender {
pub account: Option<IdentId>,
pub account: Option<Vec<u8>>,
pub local_identity: IdentId,
}

View File

@ -36,13 +36,13 @@ fn fast_group_v2_config() -> GroupV2Config {
type TestClient = ChatClient<LogosAuthVerifier, InProcessDelivery, EphemeralRegistry, ChatStorage>;
/// A client for a fresh account: mints the account and a delegate, publishes
/// the endorsing bundle, and builds the client on the shared bus/registry with
/// the fast GroupV2 timers. Returns the account address peers invite by.
/// A client for a fresh account: mints the account and a delegate, endorses the
/// delegate on the account, and builds the client on the shared bus/registry
/// with the fast GroupV2 timers. Returns the account address peers invite by.
fn create_test_client(
message_bus: MessageBus,
reg: EphemeralRegistry,
) -> (TestClient, Receiver<Event>, String) {
) -> (TestClient, Receiver<Event>, Vec<u8>) {
create_test_client_with(message_bus, reg, fast_group_v2_config())
}
@ -52,13 +52,13 @@ fn create_test_client_with(
message_bus: MessageBus,
mut reg: EphemeralRegistry,
config: GroupV2Config,
) -> (TestClient, Receiver<Event>, String) {
let account = TestLogosAccount::new();
) -> (TestClient, Receiver<Event>, Vec<u8>) {
let mut account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
account
.add_delegate_signer(&mut reg, delegate.public_key())
.endorse_ed25519_signer(&mut reg, delegate.public_key())
.unwrap();
let (client, events) = ChatClientBuilder::new(account.address())
let (client, events) = ChatClientBuilder::new(account.address().to_bytes())
.auth(LogosAuthVerifier::new())
.ident(delegate)
.transport(InProcessDelivery::new(message_bus))
@ -66,7 +66,7 @@ fn create_test_client_with(
.group_v2_config(config)
.build()
.expect("client create");
let addr = client.addr().to_string();
let addr = client.addr().to_vec();
(client, events, addr)
}
@ -108,16 +108,13 @@ fn wait_for_group_started(events: &Receiver<Event>, label: &str) -> String {
/// roster settles asynchronously as each member applies the add commit, so it is
/// polled rather than snapshotted; members still awaiting that commit are
/// skipped so an invite alone never reads as convergence.
fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&str]) {
fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&[u8]]) {
use std::collections::BTreeSet;
let want: BTreeSet<String> = expected.iter().map(|s| s.to_string()).collect();
let want: BTreeSet<Vec<u8>> = expected.iter().map(|a| a.to_vec()).collect();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let roster = client.group_members(convo_id).expect("group_members");
let got: BTreeSet<String> = roster
.iter()
.filter_map(|m| m.account.as_ref().map(|a| a.as_str().to_string()))
.collect();
let got: BTreeSet<Vec<u8>> = roster.iter().filter_map(|m| m.account.clone()).collect();
if got == want {
return;
}
@ -129,14 +126,14 @@ fn wait_for_members(client: &mut TestClient, convo_id: &str, expected: &[&str])
}
/// Wait for `content` to arrive and return the sender's verified account.
fn wait_for_message(events: &Receiver<Event>, content: &[u8]) -> Option<String> {
fn wait_for_message(events: &Receiver<Event>, content: &[u8]) -> Option<Vec<u8>> {
let label = format!("MessageReceived({})", String::from_utf8_lossy(content));
wait_for_event(events, &label, Duration::from_secs(10), |e| match e {
Event::MessageReceived {
content: got,
sender,
..
} if got == content => Some(sender.account.as_ref().map(|a| a.as_str().to_string())),
} if got == content => Some(sender.account.clone()),
_ => None,
})
}
@ -154,7 +151,7 @@ fn group_v2_three_members() {
let (mut pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone());
let convo_id = saro
.create_group_conversation(&[&raya_addr], unnamed_group())
.create_group_conversation(&[raya_addr.as_slice()], unnamed_group())
.expect("saro create group");
// The invite lands once saro's steward commit finalizes (wakeup-driven);
@ -163,24 +160,32 @@ fn group_v2_three_members() {
assert_eq!(raya_convo_id, convo_id);
// Both sides see the two-account roster once the add commits.
wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr]);
wait_for_members(&mut raya, &raya_convo_id, &[&saro_addr, &raya_addr]);
wait_for_members(
&mut saro,
&convo_id,
&[saro_addr.as_slice(), raya_addr.as_slice()],
);
wait_for_members(
&mut raya,
&raya_convo_id,
&[saro_addr.as_slice(), raya_addr.as_slice()],
);
saro.send_message(&convo_id, b"hello raya").unwrap();
assert_eq!(
wait_for_message(&raya_events, b"hello raya").as_deref(),
Some(saro_addr.as_str())
Some(saro_addr.as_slice())
);
raya.send_message(&raya_convo_id, b"hi saro").unwrap();
assert_eq!(
wait_for_message(&saro_events, b"hi saro").as_deref(),
Some(raya_addr.as_str())
Some(raya_addr.as_slice())
);
// A non-creator grows the group: raya proposes pax, the steward commits,
// and raya (who holds the pending invite) routes the welcome to pax.
raya.add_group_members(&raya_convo_id, &[&pax_addr])
raya.add_group_members(&raya_convo_id, &[pax_addr.as_slice()])
.expect("raya add pax");
let pax_convo_id = wait_for_group_started(&pax_events, "pax ConversationStarted");
assert_eq!(pax_convo_id, convo_id);
@ -190,25 +195,29 @@ fn group_v2_three_members() {
saro.send_message(&convo_id, b"all three?").unwrap();
assert_eq!(
wait_for_message(&raya_events, b"all three?").as_deref(),
Some(saro_addr.as_str())
Some(saro_addr.as_slice())
);
assert_eq!(
wait_for_message(&pax_events, b"all three?").as_deref(),
Some(saro_addr.as_str())
Some(saro_addr.as_slice())
);
pax.send_message(&pax_convo_id, b"pax is in").unwrap();
assert_eq!(
wait_for_message(&saro_events, b"pax is in").as_deref(),
Some(pax_addr.as_str())
Some(pax_addr.as_slice())
);
assert_eq!(
wait_for_message(&raya_events, b"pax is in").as_deref(),
Some(pax_addr.as_str())
Some(pax_addr.as_slice())
);
// All three rosters converge on the same three accounts.
let all = [saro_addr.as_str(), raya_addr.as_str(), pax_addr.as_str()];
let all = [
saro_addr.as_slice(),
raya_addr.as_slice(),
pax_addr.as_slice(),
];
wait_for_members(&mut saro, &convo_id, &all);
wait_for_members(&mut raya, &raya_convo_id, &all);
wait_for_members(&mut pax, &pax_convo_id, &all);
@ -238,8 +247,11 @@ fn peers_invited_to_many_groups() {
let mut convo_ids = Vec::new();
for _ in 0..GROUPS {
convo_ids.push(
saro.create_group_conversation(&[&raya_addr, &pax_addr], unnamed_group())
.expect("saro create group"),
saro.create_group_conversation(
&[raya_addr.as_slice(), pax_addr.as_slice()],
unnamed_group(),
)
.expect("saro create group"),
);
}
@ -256,11 +268,11 @@ fn peers_invited_to_many_groups() {
saro.send_message(convo_id, &msg).unwrap();
assert_eq!(
wait_for_message(&raya_events, &msg).as_deref(),
Some(saro_addr.as_str())
Some(saro_addr.as_slice())
);
assert_eq!(
wait_for_message(&pax_events, &msg).as_deref(),
Some(saro_addr.as_str())
Some(saro_addr.as_slice())
);
}
@ -280,10 +292,7 @@ fn group_creator_is_in_own_roster() {
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
let roster = saro.group_members(&convo_id).expect("group_members");
let accounts: Vec<Option<String>> = roster
.iter()
.map(|m| m.account.as_ref().map(|a| a.as_str().to_string()))
.collect();
let accounts: Vec<Option<Vec<u8>>> = roster.iter().map(|m| m.account.clone()).collect();
assert_eq!(accounts, vec![Some(saro_addr.clone())]);
}
@ -307,19 +316,19 @@ fn invited_member_is_pending_until_the_group_commits() {
let convo_id = saro
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
saro.add_group_members(&convo_id, &[&raya_addr])
saro.add_group_members(&convo_id, &[raya_addr.as_slice()])
.expect("saro invites raya");
let roster = saro.group_members(&convo_id).expect("group_members");
let accounts = |pending: bool| -> Vec<&str> {
let accounts = |pending: bool| -> Vec<&[u8]> {
roster
.iter()
.filter(|m| m.pending == pending)
.filter_map(|m| m.account.as_ref().map(|a| a.as_str()))
.filter_map(|m| m.account.as_deref())
.collect()
};
assert_eq!(accounts(false), vec![saro_addr.as_str()]);
assert_eq!(accounts(true), vec![raya_addr.as_str()]);
assert_eq!(accounts(false), vec![saro_addr.as_slice()]);
assert_eq!(accounts(true), vec![raya_addr.as_slice()]);
}
/// The pending flag is transient: once the group commits the add, the invitee
@ -337,11 +346,15 @@ fn pending_clears_once_the_add_commits() {
let convo_id = saro
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
saro.add_group_members(&convo_id, &[&raya_addr])
saro.add_group_members(&convo_id, &[raya_addr.as_slice()])
.expect("saro invites raya");
let raya_convo_id = wait_for_group_started(&raya_events, "raya ConversationStarted");
wait_for_members(&mut saro, &convo_id, &[&saro_addr, &raya_addr]);
wait_for_members(
&mut saro,
&convo_id,
&[saro_addr.as_slice(), raya_addr.as_slice()],
);
let roster = saro.group_members(&convo_id).expect("group_members");
assert!(
@ -371,21 +384,24 @@ fn add_batch_with_missing_key_package_invites_no_one() {
let (_raya, raya_events, raya_addr) = create_test_client(bus.clone(), reg.clone());
let (_pax, pax_events, pax_addr) = create_test_client(bus.clone(), reg.clone());
// Ghost: its account endorses a device in the directory, but that device
// never registered a key package (no client was built for it).
let ghost_account = TestLogosAccount::new();
// 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 ghost_delegate = DelegateSigner::random();
ghost_account
.add_delegate_signer(&mut reg, ghost_delegate.public_key())
.endorse_ed25519_signer(&mut reg, ghost_delegate.public_key())
.unwrap();
let convo_id = saro
.create_group_conversation(&[&raya_addr], unnamed_group())
.create_group_conversation(&[raya_addr.as_slice()], unnamed_group())
.expect("saro create group");
wait_for_group_started(&raya_events, "raya ConversationStarted");
saro.add_group_members(&convo_id, &[&ghost_account.address(), &pax_addr])
.expect_err("ghost has no key package");
saro.add_group_members(
&convo_id,
&[ghost_account.address().to_bytes(), pax_addr.as_slice()],
)
.expect_err("ghost has no key package");
// Pax was in the failed batch and must not have been invited.
assert!(
@ -410,8 +426,8 @@ fn group_invite_of_unpublished_account_is_an_error() {
let unpublished = TestLogosAccount::new();
let err = saro
.create_group_conversation(&[&unpublished.address()], unnamed_group())
.expect_err("no bundle published for the account");
.create_group_conversation(&[unpublished.address().to_bytes()], unnamed_group())
.expect_err("nothing published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
@ -421,8 +437,8 @@ fn group_invite_of_unpublished_account_is_an_error() {
.create_group_conversation(&[], unnamed_group())
.expect("empty group");
let err = saro
.add_group_members(&convo_id, &[&unpublished.address()])
.expect_err("no bundle published for the account");
.add_group_members(&convo_id, &[unpublished.address().to_bytes()])
.expect_err("nothing published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
@ -442,7 +458,7 @@ fn group_metadata_reaches_joiners() {
let convo_id = saro
.create_group_conversation(
&[&raya_addr],
&[raya_addr.as_slice()],
GroupMetadata::new("Book Club", "Weekly reads"),
)
.expect("saro create group");

View File

@ -2,25 +2,14 @@ use std::time::Duration;
use components::EphemeralRegistry;
use crossbeam_channel::{Receiver, Sender};
use crypto::Ed25519VerifyingKey;
use logos_account::TestLogosAccount;
use logos_generic_chat::{
AddressedEnvelope, ChatClient, ChatClientBuilder, ConversationClass, DelegateSigner,
DeliveryService, Event, InProcessDelivery, LogosAuthVerifier, MessageBus, Transport,
};
/// Publish a signed device bundle endorsing `device` as a device of `account`,
/// so a receiver can verify the sender's account → device mapping.
fn publish_device_bundle(
reg: &mut EphemeralRegistry,
account: &TestLogosAccount,
device: &Ed25519VerifyingKey,
) {
account.add_delegate_signer(reg, device).unwrap();
}
/// A client for a fresh account: mints the account and a delegate, publishes
/// the endorsing bundle, and builds the client on the shared bus/registry.
/// A client for a fresh account: mints the account and a delegate, endorses the
/// delegate on the account, and builds the client on the shared bus/registry.
#[allow(clippy::type_complexity)]
fn create_test_client(
message_bus: MessageBus,
@ -32,11 +21,13 @@ fn create_test_client(
),
logos_generic_chat::ClientError,
> {
let account = TestLogosAccount::new();
let mut account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
publish_device_bundle(&mut reg, &account, delegate.public_key());
account
.endorse_ed25519_signer(&mut reg, delegate.public_key())
.unwrap();
let d = InProcessDelivery::new(message_bus);
ChatClientBuilder::new(account.address())
ChatClientBuilder::new(account.address().to_bytes())
.auth(LogosAuthVerifier::new())
.ident(delegate)
.transport(d)
@ -90,17 +81,19 @@ fn direct_v1_standalone_integration() {
let mut reg_service = EphemeralRegistry::new();
// Create accounts and delegates, and publish device bundles so the
// receiver can verify the account → device mapping carried in the
// 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 saro_account = TestLogosAccount::new();
let saro_account_id = saro_account.address();
let mut saro_account = TestLogosAccount::new();
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());
publish_device_bundle(&mut reg_service, &saro_account, saro_delegate.public_key());
saro_account
.endorse_ed25519_signer(&mut reg_service, saro_delegate.public_key())
.unwrap();
// Build saro's client with its account so its outbound messages carry a
// credential the receiver can verify against the published bundle.
// credential the receiver can verify against the endorsement.
let (mut saro, _saro_events) = ChatClientBuilder::new(saro_account_id.clone())
.auth(LogosAuthVerifier::new())
.ident(saro_delegate)
@ -127,12 +120,9 @@ fn direct_v1_standalone_integration() {
content, sender, ..
} => {
assert_eq!(content.as_slice(), b"Hey from saro");
// saro associated an account and published a matching bundle, so the
// saro associated an account that endorses its delegate, so the
// sender surfaces with a verified account and its device.
assert_eq!(
sender.account.as_ref().map(|a| a.as_str()),
Some(saro_account_id.as_str())
);
assert_eq!(sender.account.as_deref(), Some(saro_account_id.as_slice()));
assert_eq!(sender.local_identity.as_str(), saro_device_id.as_str());
Ok(())
}
@ -150,10 +140,12 @@ fn direct_v1_by_account_address() {
let bus = MessageBus::default();
let mut reg_service = EphemeralRegistry::new();
let raya_account = TestLogosAccount::new();
let raya_account_addr = raya_account.address();
let mut raya_account = TestLogosAccount::new();
let raya_account_addr = raya_account.address().to_bytes().to_vec();
let raya_delegate = DelegateSigner::random();
publish_device_bundle(&mut reg_service, &raya_account, raya_delegate.public_key());
raya_account
.endorse_ed25519_signer(&mut reg_service, raya_delegate.public_key())
.unwrap();
let (mut raya, raya_events) = ChatClientBuilder::new(raya_account_addr.clone())
.auth(LogosAuthVerifier::new())
@ -166,7 +158,7 @@ fn direct_v1_by_account_address() {
create_test_client(bus.clone(), reg_service.clone()).expect("client create");
// Raya's shared address is her account address, not her signer id.
assert_eq!(raya.addr(), raya_account_addr.as_str());
assert_eq!(raya.addr(), raya_account_addr);
let convo_id = saro.create_direct_conversation(&raya_account_addr).unwrap();
// DirectV1 is the pairwise shape, so the joiner sees it classed Private even
@ -194,11 +186,11 @@ fn direct_v1_by_account_address() {
content, sender, ..
} => {
assert_eq!(content.as_slice(), b"hi saro");
// raya's bundle endorses her delegate, so her sender surfaces with
// raya's account endorses her delegate, so her sender surfaces with
// the verified account.
assert_eq!(
sender.account.as_ref().map(|a| a.as_str()),
Some(raya_account_addr.as_str())
sender.account.as_deref(),
Some(raya_account_addr.as_slice())
);
Ok(())
}
@ -237,8 +229,8 @@ fn saro_raya_message_exchange() {
} => {
assert_eq!(convo_id, raya_convo_id);
assert_eq!(content.as_slice(), b"hello raya");
// saro's account published a bundle endorsing its delegate, so the
// sender surfaces a verified account.
// saro's account endorses its delegate, so the sender surfaces a
// verified account.
assert!(sender.account.is_some());
assert!(!sender.local_identity.as_str().is_empty());
Ok(())
@ -319,19 +311,16 @@ fn direct_conversation_lists_its_participants() {
create_test_client(bus.clone(), reg.clone()).expect("client create");
let (raya, _raya_events) = create_test_client(bus.clone(), reg.clone()).expect("client create");
let saro_addr = saro.addr().to_string();
let raya_addr = raya.addr().to_string();
let saro_addr = saro.addr().to_vec();
let raya_addr = raya.addr().to_vec();
let convo_id = saro
.create_direct_conversation(&raya_addr)
.expect("convo create");
let roster = saro.group_members(&convo_id).expect("group_members");
let mut accounts: Vec<Option<&str>> = roster
.iter()
.map(|m| m.account.as_ref().map(|a| a.as_str()))
.collect();
let mut accounts: Vec<Option<&[u8]>> = roster.iter().map(|m| m.account.as_deref()).collect();
accounts.sort();
let mut expected = vec![Some(saro_addr.as_str()), Some(raya_addr.as_str())];
let mut expected = vec![Some(saro_addr.as_slice()), Some(raya_addr.as_slice())];
expected.sort();
assert_eq!(accounts, expected);
@ -408,7 +397,7 @@ 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())
let (_client, events) = ChatClientBuilder::new(TestLogosAccount::new().address().to_bytes())
.auth(LogosAuthVerifier::new())
.transport(delivery)
.build()
@ -426,7 +415,7 @@ fn malformed_inbound_surfaces_as_error_event() {
}
/// Opening a conversation by an address whose account never published a
/// device bundle fails at resolution, not with a late key-package miss.
/// device list fails at resolution, not with a late key-package miss.
#[test]
fn unpublished_account_address_is_an_error() {
let bus = MessageBus::default();
@ -437,15 +426,15 @@ fn unpublished_account_address_is_an_error() {
let unpublished = TestLogosAccount::new();
let err = saro
.create_direct_conversation(&unpublished.address())
.expect_err("no bundle published for the account");
.create_direct_conversation(unpublished.address().to_bytes())
.expect_err("nothing published for the account");
assert!(matches!(
err,
logos_generic_chat::ClientError::AccountResolution(_)
));
let err = saro
.create_direct_conversation("not-an-account-address")
.create_direct_conversation(b"not-an-account-address")
.expect_err("not an account key");
assert!(matches!(
err,

View File

@ -134,10 +134,10 @@ pub fn open_with_transport<T: Transport + Clone>(
ClientError,
> {
// A fresh account endorsing a fresh delegate each open: the account
// key is dropped after publishing the bundle, so devices cannot be
// added later. A caller-supplied, custody-holding account replaces
// 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 account = TestLogosAccount::new();
let mut account = TestLogosAccount::new();
let delegate = DelegateSigner::random();
let mut registry = ContactRegistry::new(
transport.clone(),
@ -145,9 +145,9 @@ pub fn open_with_transport<T: Transport + Clone>(
config.registry_publish_mode,
);
account
.add_delegate_signer(&mut registry, delegate.public_key())
.endorse_ed25519_signer(&mut registry, delegate.public_key())
.map_err(|e| ClientError::BundlePublish(e.to_string()))?;
let mut builder = ChatClientBuilder::new(account.address())
let mut builder = ChatClientBuilder::new(account.address().to_bytes())
.auth(LogosAuthVerifier::new())
.ident(delegate)
.transport(transport)