mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-08-25 07:01:08 +00:00
Replace AccountDirectory with AccountRegistry
This commit is contained in:
+42
-98
@@ -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::<Result<Vec<_>, _>>()
|
||||
.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<S> {
|
||||
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<S: AccountLogStore> TestLogosAccount<S> {
|
||||
/// 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<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(
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<Ed25519VerifyingKey>, 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<EntryData> {
|
||||
|
||||
@@ -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<u8>,
|
||||
/// Account signature over `payload`.
|
||||
pub signature: Ed25519Signature,
|
||||
}
|
||||
|
||||
/// The verified result of a directory fetch: an account's device set at a given
|
||||
/// version. Produced only after the account signature has been checked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DeviceSet {
|
||||
pub lamport: Lamport,
|
||||
/// Device verifying keys, hex-encoded, ready for keypackage retrieval.
|
||||
pub devices: Vec<DeviceId>,
|
||||
}
|
||||
|
||||
/// 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<Option<DeviceSet>, Self::Error>;
|
||||
}
|
||||
|
||||
/// Failures decoding or verifying a [`SignedDeviceBundle`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BundleError {
|
||||
#[error("payload shorter than its declared layout")]
|
||||
Short,
|
||||
#[error("payload is missing the account-device-bundle domain prefix")]
|
||||
Domain,
|
||||
#[error("unsupported bundle version {0}")]
|
||||
Version(u8),
|
||||
#[error("account signature verification failed")]
|
||||
SignatureInvalid,
|
||||
}
|
||||
|
||||
/// The decoded (but not yet signature-verified) contents of a bundle payload.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedBundle {
|
||||
pub lamport: Lamport,
|
||||
pub devices: Vec<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Canonical binary payload — the bytes that are both signed and transmitted.
|
||||
/// Opaque to the server; decoded only by consumers:
|
||||
///
|
||||
/// ```text
|
||||
/// domain : BUNDLE_DOMAIN (constant prefix, NUL-terminated)
|
||||
/// version : u8 (1 byte)
|
||||
/// lamport : u64 LE (8 bytes)
|
||||
/// count : u16 LE (2 bytes) — number of device keys that follow
|
||||
/// devices : [u8; 32] * count (32 * count bytes)
|
||||
/// ```
|
||||
///
|
||||
/// Fixed-width fields with an explicit `count` make every byte string parse
|
||||
/// exactly one way. The [`BUNDLE_DOMAIN`] prefix binds the signature to this
|
||||
/// purpose (see its docs). The account key is *not* embedded: the account is
|
||||
/// identified out-of-band by the account verifying key the caller requests, and
|
||||
/// [`verify_bundle`] checks the signature under that key — so a bundle for one
|
||||
/// account cannot be passed off as another's.
|
||||
pub fn encode_bundle_payload(lamport: Lamport, devices: &[Ed25519VerifyingKey]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(BUNDLE_DOMAIN.len() + 1 + 8 + 2 + devices.len() * 32);
|
||||
out.extend_from_slice(BUNDLE_DOMAIN);
|
||||
out.push(BUNDLE_VERSION);
|
||||
out.extend_from_slice(&lamport.to_le_bytes());
|
||||
out.extend_from_slice(&(devices.len() as u16).to_le_bytes());
|
||||
for device in devices {
|
||||
out.extend_from_slice(device.as_ref());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_bundle_payload`]. Strips the domain prefix, then validates
|
||||
/// the version and that the declared device count matches the remaining bytes
|
||||
/// exactly.
|
||||
pub fn decode_bundle_payload(payload: &[u8]) -> Result<DecodedBundle, BundleError> {
|
||||
const HEADER: usize = 1 + 8 + 2;
|
||||
let payload = payload
|
||||
.strip_prefix(BUNDLE_DOMAIN)
|
||||
.ok_or(BundleError::Domain)?;
|
||||
if payload.len() < HEADER {
|
||||
return Err(BundleError::Short);
|
||||
}
|
||||
let version = payload[0];
|
||||
if version != BUNDLE_VERSION {
|
||||
return Err(BundleError::Version(version));
|
||||
}
|
||||
let lamport = u64::from_le_bytes(payload[1..9].try_into().expect("9 - 1 == 8"));
|
||||
let count = u16::from_le_bytes(payload[9..11].try_into().expect("11 - 9 == 2")) as usize;
|
||||
|
||||
let body = &payload[HEADER..];
|
||||
if body.len() != count * 32 {
|
||||
return Err(BundleError::Short);
|
||||
}
|
||||
let devices = body
|
||||
.chunks_exact(32)
|
||||
.map(|c| c.try_into().expect("chunks_exact(32) yields 32 bytes"))
|
||||
.collect();
|
||||
|
||||
Ok(DecodedBundle { lamport, devices })
|
||||
}
|
||||
|
||||
/// Decode `bundle`, confirm it belongs to `expected_account`, and verify the
|
||||
/// account signature over the exact payload bytes. Returns the verified
|
||||
/// [`DeviceSet`] (device keys hex-encoded for keypackage retrieval).
|
||||
pub fn verify_bundle(
|
||||
expected_account: &Ed25519VerifyingKey,
|
||||
bundle: &SignedDeviceBundle,
|
||||
) -> Result<DeviceSet, BundleError> {
|
||||
let decoded = decode_bundle_payload(&bundle.payload)?;
|
||||
|
||||
// Verifying the signature under the *requested* account key is what binds the
|
||||
// bundle to that account: another account's validly-signed bundle won't verify
|
||||
// under this key, so an untrusted server cannot substitute one.
|
||||
expected_account
|
||||
.verify(&bundle.payload, &bundle.signature)
|
||||
.map_err(|_| BundleError::SignatureInvalid)?;
|
||||
|
||||
Ok(DeviceSet {
|
||||
lamport: decoded.lamport,
|
||||
devices: decoded.devices.iter().map(hex::encode).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<D: AccountDirectory + ?Sized>(
|
||||
directory: &D,
|
||||
account: IdentIdRef,
|
||||
) -> Result<Vec<DeviceId>, 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<Ed25519VerifyingKey> {
|
||||
let bytes: [u8; 32] = hex::decode(id.as_str()).ok()?.try_into().ok()?;
|
||||
Ed25519VerifyingKey::from_bytes(&bytes).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crypto::Ed25519SigningKey;
|
||||
use shared_traits::IdentId;
|
||||
|
||||
/// encode → decode round-trips, including zero and many devices.
|
||||
#[test]
|
||||
fn payload_roundtrips() {
|
||||
let devices: Vec<_> = (0..3)
|
||||
.map(|_| Ed25519SigningKey::generate().verifying_key())
|
||||
.collect();
|
||||
|
||||
let payload = encode_bundle_payload(7, &devices);
|
||||
let decoded = decode_bundle_payload(&payload).unwrap();
|
||||
|
||||
assert_eq!(decoded.lamport, 7);
|
||||
let want: Vec<[u8; 32]> = devices
|
||||
.iter()
|
||||
.map(|d| d.as_ref().try_into().unwrap())
|
||||
.collect();
|
||||
assert_eq!(decoded.devices, want);
|
||||
|
||||
// Empty device set is valid (an account with no devices).
|
||||
let empty = encode_bundle_payload(0, &[]);
|
||||
assert!(decode_bundle_payload(&empty).unwrap().devices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_short_and_truncated() {
|
||||
// A domain-prefixed payload too short to hold the header.
|
||||
let mut short = BUNDLE_DOMAIN.to_vec();
|
||||
short.extend_from_slice(&[0u8; 5]);
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(&short),
|
||||
Err(BundleError::Short)
|
||||
));
|
||||
|
||||
let device = Ed25519SigningKey::generate().verifying_key();
|
||||
let mut payload = encode_bundle_payload(1, &[device]);
|
||||
payload.pop(); // drop a device byte: count no longer matches the body
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(&payload),
|
||||
Err(BundleError::Short)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_missing_domain() {
|
||||
// Bytes that would be a valid body but lack the domain prefix.
|
||||
let payload = encode_bundle_payload(1, &[]);
|
||||
let without_domain = &payload[BUNDLE_DOMAIN.len()..];
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(without_domain),
|
||||
Err(BundleError::Domain)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_bad_version() {
|
||||
let mut payload = encode_bundle_payload(1, &[]);
|
||||
payload[BUNDLE_DOMAIN.len()] = 99; // first byte after the domain prefix
|
||||
assert!(matches!(
|
||||
decode_bundle_payload(&payload),
|
||||
Err(BundleError::Version(99))
|
||||
));
|
||||
}
|
||||
|
||||
/// Full happy path: sign with the account key, verify under the account key.
|
||||
#[test]
|
||||
fn verify_accepts_well_formed_bundle() {
|
||||
let account_key = Ed25519SigningKey::generate();
|
||||
let account_pub = account_key.verifying_key();
|
||||
let devices: Vec<_> = (0..2)
|
||||
.map(|_| Ed25519SigningKey::generate().verifying_key())
|
||||
.collect();
|
||||
|
||||
let payload = encode_bundle_payload(42, &devices);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub: account_pub.clone(),
|
||||
signature: account_key.sign(&payload),
|
||||
payload,
|
||||
};
|
||||
|
||||
let set = verify_bundle(&account_pub, &bundle).unwrap();
|
||||
assert_eq!(set.lamport, 42);
|
||||
assert_eq!(set.devices.len(), 2);
|
||||
assert_eq!(set.devices[0], hex::encode(devices[0].as_ref()));
|
||||
}
|
||||
|
||||
/// A bundle validly signed by account A, served as the answer to a query for
|
||||
/// account B, fails: B's key does not verify A's signature. This is the
|
||||
/// anti-substitution guarantee, now resting entirely on the signature check.
|
||||
#[test]
|
||||
fn verify_rejects_wrong_account() {
|
||||
let account_key = Ed25519SigningKey::generate();
|
||||
let account_pub = account_key.verifying_key();
|
||||
let payload = encode_bundle_payload(1, &[]);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub,
|
||||
signature: account_key.sign(&payload),
|
||||
payload,
|
||||
};
|
||||
|
||||
let other = Ed25519SigningKey::generate().verifying_key();
|
||||
assert!(matches!(
|
||||
verify_bundle(&other, &bundle),
|
||||
Err(BundleError::SignatureInvalid)
|
||||
));
|
||||
}
|
||||
|
||||
/// Minimal in-test directory so `resolve_device_ids` can be exercised
|
||||
/// without pulling in the `components` crate.
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeDir(Option<SignedDeviceBundle>);
|
||||
|
||||
impl AccountDirectory for FakeDir {
|
||||
type Error = BundleError;
|
||||
fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error> {
|
||||
self.0 = Some(bundle.clone());
|
||||
Ok(())
|
||||
}
|
||||
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, Self::Error> {
|
||||
self.0
|
||||
.as_ref()
|
||||
.map(|b| verify_bundle(account, b))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> = devices.iter().map(|d| hex::encode(d.as_ref())).collect();
|
||||
assert_eq!(resolved, want);
|
||||
}
|
||||
|
||||
/// Tampering with any payload byte breaks verification.
|
||||
#[test]
|
||||
fn verify_rejects_tampered_payload() {
|
||||
let account_key = Ed25519SigningKey::generate();
|
||||
let account_pub = account_key.verifying_key();
|
||||
let device = Ed25519SigningKey::generate().verifying_key();
|
||||
|
||||
let payload = encode_bundle_payload(1, std::slice::from_ref(&device));
|
||||
let signature = account_key.sign(&payload);
|
||||
|
||||
// Re-encode with a different lamport, keep the old signature.
|
||||
let tampered = encode_bundle_payload(2, &[device]);
|
||||
let bundle = SignedDeviceBundle {
|
||||
account_pub: account_pub.clone(),
|
||||
payload: tampered,
|
||||
signature,
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_bundle(&account_pub, &bundle),
|
||||
Err(BundleError::SignatureInvalid)
|
||||
));
|
||||
}
|
||||
}
|
||||
+20
-7
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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<AS, T, R, S> ChatClientBuilder<DelegateSigner, AS, T, R, S>
|
||||
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<AS, T, R, S> {
|
||||
@@ -206,7 +206,7 @@ impl<AS, T, R> ChatClientBuilder<Unset, AS, T, R, Unset>
|
||||
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<AS, T, R, ChatStorage> {
|
||||
ChatClient::new(
|
||||
@@ -246,7 +246,7 @@ impl<AS, T, R> ChatClientBuilder<DelegateSigner, AS, T, R, Unset>
|
||||
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<AS, T, R, ChatStorage> {
|
||||
ChatClient::new(
|
||||
@@ -266,7 +266,7 @@ impl<AS, T, R, S> ChatClientBuilder<Unset, AS, T, R, S>
|
||||
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<AS, T, R, S> {
|
||||
|
||||
@@ -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<AS, T, R, S>
|
||||
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<Mutex<ClientCore<T, R, S>>>,
|
||||
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<Sender<()>>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
@@ -185,7 +184,7 @@ impl<AS, T, R, S> ChatClient<AS, T, R, S>
|
||||
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<T, R, S>,
|
||||
auth: AS,
|
||||
directory: R,
|
||||
accounts: R,
|
||||
address: Vec<u8>,
|
||||
inbound: Receiver<Vec<u8>>,
|
||||
wakeup_events: Receiver<WakeupEvent>,
|
||||
@@ -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<Vec<LocalSignerId>, 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<AS, T, R, S> Drop for ChatClient<AS, T, R, S>
|
||||
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<T, R, S: ChatStore + 'static>(
|
||||
core: Arc<Mutex<ClientCore<T, R, S>>>,
|
||||
directory: R,
|
||||
accounts: R,
|
||||
inbound: Receiver<Vec<u8>>,
|
||||
wakeup_events: Receiver<WakeupEvent>,
|
||||
shutdown: Receiver<()>,
|
||||
event_tx: Sender<Event>,
|
||||
) where
|
||||
T: DeliveryService + Send + 'static,
|
||||
R: RegistrationService + AccountDirectory + Send + 'static,
|
||||
R: RegistrationService + AccountRegistry + Send + 'static,
|
||||
{
|
||||
loop {
|
||||
select! {
|
||||
@@ -453,7 +462,7 @@ fn worker_loop<T, R, S: ChatStore + 'static>(
|
||||
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<T, R, S: ChatStore + 'static>(
|
||||
};
|
||||
// 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<T, R, S: ChatStore + 'static>(
|
||||
/// observation. For an `Inbox` outcome, [`Event::ConversationStarted`]
|
||||
/// precedes the message event. The convo id is wrapped into `Arc<str>` once
|
||||
/// per outcome and shared across the events it produces.
|
||||
fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
||||
fn events_from_inbound(result: PayloadOutcome, accounts: &impl AccountRegistry) -> Vec<Event> {
|
||||
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<Ed25519VerifyingKey> {
|
||||
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<u8>),
|
||||
/// 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<MessageSender, SenderError> {
|
||||
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<Event> {
|
||||
fn convo_events(outcome: ConvoOutcome, accounts: &impl AccountRegistry) -> Vec<Event> {
|
||||
let ConvoOutcome {
|
||||
convo_id,
|
||||
content,
|
||||
@@ -632,7 +632,7 @@ fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec
|
||||
let convo_id: Arc<str> = 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<Event> {
|
||||
fn inbox_events(outcome: InboxOutcome, accounts: &impl AccountRegistry) -> Vec<Event> {
|
||||
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<String, Vec<String>>,
|
||||
endorsements: HashMap<AccountAddr, Vec<Ed25519VerifyingKey>>,
|
||||
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<Option<DeviceSet>, Self::Error> {
|
||||
fn endorsed_ed25519_keys(
|
||||
&self,
|
||||
addr: &AccountAddr,
|
||||
) -> Result<Option<Vec<Ed25519VerifyingKey>>, 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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Event>, Vec<u8>) {
|
||||
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())
|
||||
|
||||
@@ -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<LogosAuthVerifier, InProcessDelivery, EphemeralRegistry, libchat::ChatStorage>,
|
||||
@@ -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");
|
||||
|
||||
@@ -137,15 +137,15 @@ pub fn open_with_transport<T: Transport + Clone>(
|
||||
// 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())
|
||||
|
||||
@@ -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<Mutex<HashMap<String, Vec<u8>>>>,
|
||||
installations: Arc<Mutex<HashMap<String, SignedDeviceBundle>>>,
|
||||
accounts: Arc<Mutex<HashMap<AccountAddr, SignedAccountLog>>>,
|
||||
}
|
||||
|
||||
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<(), <Self as AccountDirectory>::Error> {
|
||||
self.installations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(hex::encode(bundle.account_pub.as_ref()), bundle.clone());
|
||||
addr: &AccountAddr,
|
||||
log: SignedAccountLog,
|
||||
) -> Result<(), <Self as AccountLogStore>::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<Option<DeviceSet>, <Self as AccountDirectory>::Error> {
|
||||
let Some(bundle) = self
|
||||
.installations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&hex::encode(account.as_ref()))
|
||||
.cloned()
|
||||
else {
|
||||
addr: &AccountAddr,
|
||||
) -> Result<Option<Vec<Ed25519VerifyingKey>>, <Self as AccountRegistry>::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())
|
||||
}
|
||||
|
||||
@@ -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<D: DeliveryService> RegistrationService for ContactRegistry<D> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: DeliveryService> AccountDirectory for ContactRegistry<D> {
|
||||
impl<D: DeliveryService> AccountLogStore for ContactRegistry<D> {
|
||||
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<Option<DeviceSet>, Self::Error> {
|
||||
let url = format!(
|
||||
"{}/v0/account/{}",
|
||||
self.base_url,
|
||||
hex::encode(account.as_ref())
|
||||
);
|
||||
impl<D: DeliveryService> AccountRegistry for ContactRegistry<D> {
|
||||
type Error = ContactRegistryError;
|
||||
|
||||
fn endorsed_ed25519_keys(
|
||||
&self,
|
||||
addr: &AccountAddr,
|
||||
) -> Result<Option<Vec<Ed25519VerifyingKey>>, 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]
|
||||
|
||||
Reference in New Issue
Block a user