Add AccountLog

This commit is contained in:
Jazz Turner-Baggs
2026-07-04 00:40:10 -07:00
parent c09459c0a0
commit 1fb02ef41c
5 changed files with 702 additions and 3 deletions
+177
View File
@@ -0,0 +1,177 @@
//! Signed account operation log: the append-only record of an account's
//! associated key and data.
//!
//! ```text
//! SignedAccountLog payload + account signature over its exact bytes
//! └── EncodedAccountLog the log as canonical bytes (wire form — see codec)
//! └── AccountLog the log as validated entries (working form)
//! └── AccountEntry Add(EntryData) | Remove { index }
//! └── EntryData Ed25519Key | Text
//! ```
//!
//! Invariants:
//! - Append-only: a newer log strictly extends the older one
//! ([`verify_extension`](crate::verify_extension)). There is no version
//! counter — a longer log is a newer log. A log that is longer but does not
//! extend the old one has rewritten history: either the signer is showing
//! different histories to different readers, or the account key is
//! compromised.
//! - A `Remove` tombstones a strictly earlier, still-live `Add`; anything else
//! rejects the whole log — fail closed ([`AccountLog::new`]).
//!
//! Replaying ([`AccountLog::live_entries`]) yields the account's current state.
use crypto::Ed25519Signature;
use crate::error::AccountLogError;
/// An [`AccountLog`] in its canonical byte encoding, plus the account's
/// signature over exactly those bytes.
///
/// The account key is not carried: the account address *is* the verifying key,
/// supplied by the caller on verify.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedAccountLog {
pub payload: EncodedAccountLog,
pub signature: Ed25519Signature,
}
/// An [`AccountLog`] as canonical bytes — exactly what is signed and
/// transmitted. Holding one proves the bytes decode to a valid log: construct
/// via [`AccountLog::encode`] or [`parse`](Self::parse). Byte layout: see
/// [`AccountLog::encode`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedAccountLog(pub(crate) Vec<u8>);
/// The log as a validated entry list. Construction checks every `Remove`, so
/// a held log always replays ([`live_entries`](Self::live_entries) cannot
/// fail).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountLog {
entries: Vec<AccountEntry>,
}
/// One operation in the log. An entry's index is its position — derived, not
/// stored, so an entry cannot lie about where it sits. One enum rather than
/// separate op/data fields: illegal combinations are unrepresentable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountEntry {
/// Endorse new data under this account.
Add(EntryData),
/// Tombstone the `Add` at position `index`. Must point at a strictly
/// earlier, still-live `Add`; anything else rejects the whole log —
/// fail closed, so verifiers can never skip their way to different sets.
Remove { index: u32 },
}
/// Data an account can endorse.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryData {
/// A device (LocalIdentity) verifying key.
Ed25519Key([u8; 32]),
/// An arbitrary UTF-8 record.
Text(String),
}
impl AccountLog {
/// Validate `entries` as a log (see [`AccountEntry::Remove`]).
/// [`EncodedAccountLog::parse`] applies the same gate to received bytes,
/// so a broken log — un-rewritable once published — never gets published.
pub fn new(entries: Vec<AccountEntry>) -> Result<Self, AccountLogError> {
removed_flags(&entries)?;
Ok(Self { entries })
}
pub fn entries(&self) -> &[AccountEntry] {
&self.entries
}
/// Replay the log into its live entry set — the account's current state,
/// in add order.
pub fn live_entries(&self) -> Vec<EntryData> {
let removed = removed_flags(&self.entries).expect("validated at construction");
self.entries
.iter()
.enumerate()
.filter_map(|(i, entry)| match entry {
AccountEntry::Add(data) if !removed[i] => Some(data.clone()),
_ => None,
})
.collect()
}
}
/// Which entries have been tombstoned, or the `Remove` that broke the log.
/// `index < position` means every target was already seen: single-pass.
fn removed_flags(entries: &[AccountEntry]) -> Result<Vec<bool>, AccountLogError> {
let mut removed = vec![false; entries.len()];
for (position, entry) in entries.iter().enumerate() {
let AccountEntry::Remove { index } = entry else {
continue;
};
let target = *index as usize;
let targets_live_add = target < position
&& !removed[target]
&& matches!(entries[target], AccountEntry::Add(_));
if !targets_live_add {
return Err(AccountLogError::InvalidRemove {
position,
index: *index,
});
}
removed[target] = true;
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(byte: u8) -> AccountEntry {
AccountEntry::Add(EntryData::Ed25519Key([byte; 32]))
}
/// Replay applies tombstones and preserves add order.
#[test]
fn live_entries_applies_removes() {
let log = AccountLog::new(vec![
key(1),
AccountEntry::Add(EntryData::Text("name".into())),
AccountEntry::Remove { index: 0 },
key(2),
])
.unwrap();
assert_eq!(
log.live_entries(),
vec![
EntryData::Text("name".into()),
EntryData::Ed25519Key([2; 32]),
]
);
}
/// Every malformed remove rejects the whole log: forward and self
/// references, removing a remove, and removing twice.
#[test]
fn new_rejects_invalid_removes() {
let dangling = vec![key(1), AccountEntry::Remove { index: 7 }];
let self_ref = vec![AccountEntry::Remove { index: 0 }];
let of_remove = vec![
key(1),
AccountEntry::Remove { index: 0 },
AccountEntry::Remove { index: 1 },
];
let twice = vec![
key(1),
AccountEntry::Remove { index: 0 },
AccountEntry::Remove { index: 0 },
];
for entries in [dangling, self_ref, of_remove, twice] {
assert!(matches!(
AccountLog::new(entries),
Err(AccountLogError::InvalidRemove { .. })
));
}
}
}
+68
View File
@@ -0,0 +1,68 @@
use std::fmt;
use crypto::Ed25519VerifyingKey;
use crate::error::AccountError;
/// A routable representation of an account
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountAddr {
pubkey: Ed25519VerifyingKey,
}
impl AccountAddr {
pub fn to_bytes(&self) -> &[u8] {
self.pubkey.as_ref()
}
}
/// Displays as the hex of the account verifying key — the same form the
/// directory and keypackage registry use for ids.
impl fmt::Display for AccountAddr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&hex::encode(self.pubkey.as_ref()))
}
}
impl From<&Ed25519VerifyingKey> for AccountAddr {
fn from(value: &Ed25519VerifyingKey) -> Self {
Self {
pubkey: value.clone(),
}
}
}
/// Not every byte string is an address: exactly 32 bytes forming a valid
/// Ed25519 key.
impl TryFrom<&[u8]> for AccountAddr {
type Error = AccountError;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let bytes: [u8; 32] = value
.try_into()
.map_err(|_| AccountError::InvalidAddress)?;
let pubkey =
Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| AccountError::InvalidAddress)?;
Ok(Self { pubkey })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crypto::Ed25519SigningKey;
/// Display is the hex of the key; TryFrom round-trips the bytes.
#[test]
fn display_and_try_from_roundtrip() {
let key = Ed25519SigningKey::generate().verifying_key();
let addr = AccountAddr::from(&key);
assert_eq!(addr.to_string(), hex::encode(key.as_ref()));
assert_eq!(AccountAddr::try_from(addr.to_bytes()).unwrap(), addr);
}
#[test]
fn try_from_rejects_wrong_length() {
assert!(AccountAddr::try_from(&[0u8; 31][..]).is_err());
}
}
+409
View File
@@ -0,0 +1,409 @@
//! Wire format for [`EncodedAccountLog`]: the canonical byte encoding of an
//! [`AccountLog`]. Encoding and decoding live together so they cannot drift
//! apart. The bytes are opaque to the server except for the fixed-offset
//! header the extension check reads.
use crypto::Ed25519VerifyingKey;
use crate::error::AccountLogError;
use crate::account_log::{AccountEntry, AccountLog, EncodedAccountLog, EntryData, SignedAccountLog};
/// Domain-separation tag, prepended to every signed payload:
///
/// ```text
/// logos:accounts:<version>\0
/// ```
///
/// Binds the signature to this exact purpose; bump `<version>` on layout change.
pub const ACCOUNT_LOG_DOMAIN: &[u8] = b"logos:accounts:1\0";
/// [`ACCOUNT_LOG_DOMAIN`] without the version segment.
const DOMAIN_STEM: &[u8] = b"logos:accounts:";
// Entry wire tags. One tag byte determines exactly how many bytes follow, so
// every byte string parses one way.
const TAG_ADD: u8 = 1;
const TAG_REMOVE: u8 = 2;
const DATA_ED25519: u8 = 1;
const DATA_TEXT: u8 = 2;
/// Header bytes after the domain prefix: the entry count (u32 LE). The count
/// doubles as the freshness marker; u32 so no plausible client bug can
/// exhaust it (a u16 could be burned by a publish loop, and the counter has
/// no reset mechanism).
const HEADER: usize = 4;
impl AccountLog {
/// Canonical binary encoding — the bytes that are both signed and
/// transmitted:
///
/// ```text
/// domain : ACCOUNT_LOG_DOMAIN (constant prefix incl. version, NUL-terminated)
/// count : u32 LE (4 bytes) — number of entries that follow
/// entries : count entries, each:
/// 0x01 0x01 <32 bytes> Add(Ed25519Key)
/// 0x01 0x02 <u16 LE len> <bytes> Add(Text), UTF-8
/// 0x02 <u32 LE index> Remove
/// ```
///
/// The account key is *not* embedded: the account is identified
/// out-of-band by the account verifying key the caller requests, and
/// [`verify_log`] checks the signature under that key — so a log for one
/// account cannot be passed off as another's.
pub fn encode(&self) -> EncodedAccountLog {
let entries = self.entries();
let mut out = Vec::with_capacity(ACCOUNT_LOG_DOMAIN.len() + HEADER + entries.len() * 34);
out.extend_from_slice(ACCOUNT_LOG_DOMAIN);
out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
for entry in entries {
match entry {
AccountEntry::Add(EntryData::Ed25519Key(key)) => {
out.push(TAG_ADD);
out.push(DATA_ED25519);
out.extend_from_slice(key);
}
AccountEntry::Add(EntryData::Text(text)) => {
out.push(TAG_ADD);
out.push(DATA_TEXT);
out.extend_from_slice(&(text.len() as u16).to_le_bytes());
out.extend_from_slice(text.as_bytes());
}
AccountEntry::Remove { index } => {
out.push(TAG_REMOVE);
out.extend_from_slice(&index.to_le_bytes());
}
}
}
EncodedAccountLog(out)
}
}
impl EncodedAccountLog {
/// Validate received bytes: checks the domain prefix and version, parses
/// exactly the declared number of entries with no bytes left over, and
/// validates the log itself via [`AccountLog::new`].
pub fn parse(bytes: Vec<u8>) -> Result<Self, AccountLogError> {
AccountLog::new(decode_entries(&bytes)?)?;
Ok(Self(bytes))
}
/// Decode. Cannot fail: construction validated the bytes.
pub fn decode(&self) -> AccountLog {
AccountLog::new(decode_entries(&self.0).expect("validated at construction"))
.expect("validated at construction")
}
/// The exact bytes that are signed and transmitted.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// The entry count from the header — the freshness marker.
fn count(&self) -> u32 {
let at = ACCOUNT_LOG_DOMAIN.len();
u32::from_le_bytes(self.0[at..at + 4].try_into().expect("4 bytes"))
}
/// The entry bytes after the header — the region the extension check
/// compares.
fn entry_bytes(&self) -> &[u8] {
&self.0[ACCOUNT_LOG_DOMAIN.len() + HEADER..]
}
}
/// Decoder behind [`EncodedAccountLog::parse`] and [`EncodedAccountLog::decode`].
fn decode_entries(payload: &[u8]) -> Result<Vec<AccountEntry>, AccountLogError> {
let payload = match payload.strip_prefix(ACCOUNT_LOG_DOMAIN) {
Some(rest) => rest,
None => return Err(domain_error(payload)),
};
if payload.len() < HEADER {
return Err(AccountLogError::Short);
}
let count = u32::from_le_bytes(payload[..4].try_into().expect("4 bytes")) as usize;
let mut body = &payload[HEADER..];
let mut entries = Vec::with_capacity(count.min(1024));
for _ in 0..count {
let (entry, rest) = decode_entry(body)?;
entries.push(entry);
body = rest;
}
if !body.is_empty() {
return Err(AccountLogError::Trailing);
}
Ok(entries)
}
/// Parse one entry off the front of `body`, returning it and the rest.
fn decode_entry(body: &[u8]) -> Result<(AccountEntry, &[u8]), AccountLogError> {
let (&tag, body) = body.split_first().ok_or(AccountLogError::Short)?;
match tag {
TAG_ADD => {
let (&data_tag, body) = body.split_first().ok_or(AccountLogError::Short)?;
match data_tag {
DATA_ED25519 => {
let (key, rest) = split_at_checked(body, 32)?;
let key = key.try_into().expect("split yields 32 bytes");
Ok((AccountEntry::Add(EntryData::Ed25519Key(key)), rest))
}
DATA_TEXT => {
let (len, body) = split_at_checked(body, 2)?;
let len = u16::from_le_bytes(len.try_into().expect("2 bytes")) as usize;
let (text, rest) = split_at_checked(body, len)?;
let text = String::from_utf8(text.to_vec()).map_err(|_| AccountLogError::Utf8)?;
Ok((AccountEntry::Add(EntryData::Text(text)), rest))
}
other => Err(AccountLogError::Tag(other)),
}
}
TAG_REMOVE => {
let (index, rest) = split_at_checked(body, 4)?;
let index = u32::from_le_bytes(index.try_into().expect("4 bytes"));
Ok((AccountEntry::Remove { index }, rest))
}
other => Err(AccountLogError::Tag(other)),
}
}
/// Classify a payload that failed the domain check: our stem with a different
/// version segment, or a foreign domain altogether.
fn domain_error(payload: &[u8]) -> AccountLogError {
let Some(rest) = payload.strip_prefix(DOMAIN_STEM) else {
return AccountLogError::Domain;
};
match rest.iter().take(16).position(|&b| b == 0) {
Some(end) => AccountLogError::Version(String::from_utf8_lossy(&rest[..end]).into_owned()),
None => AccountLogError::Domain,
}
}
/// `split_at` that reports a truncated payload instead of panicking.
fn split_at_checked(body: &[u8], mid: usize) -> Result<(&[u8], &[u8]), AccountLogError> {
if body.len() < mid {
return Err(AccountLogError::Short);
}
Ok(body.split_at(mid))
}
/// Verify the account signature over the exact payload bytes, returning the
/// decoded log.
///
/// Verifying under the *requested* account key is what binds the log to that
/// account: another account's validly-signed log won't verify under this key,
/// so an untrusted server cannot substitute one.
pub fn verify_log(
expected_account: &Ed25519VerifyingKey,
log: &SignedAccountLog,
) -> Result<AccountLog, AccountLogError> {
expected_account
.verify(log.payload.as_bytes(), &log.signature)
.map_err(|_| AccountLogError::SignatureInvalid)?;
Ok(log.payload.decode())
}
/// Check that `new` strictly extends `old`: strictly more entries, and the old
/// entry bytes are a prefix of the new ones. (The count field itself changes
/// between versions, so the check is over the entry region, not the whole
/// payload.)
///
/// The server runs this on publish to refuse stale or rewritten logs, and
/// consumers run it against the last log they saw as defence in depth. It
/// compares bytes, so the server needs no knowledge of entry semantics.
pub fn verify_extension(old: &EncodedAccountLog, new: &EncodedAccountLog) -> Result<(), AccountLogError> {
if new.count() <= old.count() {
return Err(AccountLogError::NotLonger {
old: old.count(),
new: new.count(),
});
}
if !new.entry_bytes().starts_with(old.entry_bytes()) {
return Err(AccountLogError::Forked);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crypto::Ed25519SigningKey;
fn key(byte: u8) -> AccountEntry {
AccountEntry::Add(EntryData::Ed25519Key([byte; 32]))
}
fn make_log(entries: Vec<AccountEntry>) -> AccountLog {
AccountLog::new(entries).unwrap()
}
/// encode → decode round-trips, and parse accepts encode's bytes,
/// including the empty log and every variant.
#[test]
fn payload_roundtrips() {
let log = make_log(vec![
key(1),
AccountEntry::Add(EntryData::Text("display name".into())),
AccountEntry::Remove { index: 0 },
key(2),
]);
let payload = log.encode();
assert_eq!(payload.decode(), log);
assert_eq!(
EncodedAccountLog::parse(payload.as_bytes().to_vec()).unwrap(),
payload
);
// Empty log is valid (an account with no entries yet).
assert!(make_log(vec![]).encode().decode().entries().is_empty());
}
#[test]
fn parse_rejects_short_and_truncated() {
// A domain-prefixed payload too short to hold the header.
let mut short = ACCOUNT_LOG_DOMAIN.to_vec();
short.extend_from_slice(&[0u8; 3]);
assert!(matches!(
EncodedAccountLog::parse(short),
Err(AccountLogError::Short)
));
// Drop a key byte: the last entry no longer fits.
let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec();
bytes.pop();
assert!(matches!(
EncodedAccountLog::parse(bytes),
Err(AccountLogError::Short)
));
}
#[test]
fn parse_rejects_trailing_bytes() {
let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec();
bytes.push(0);
assert!(matches!(
EncodedAccountLog::parse(bytes),
Err(AccountLogError::Trailing)
));
}
#[test]
fn parse_rejects_missing_domain() {
let payload = make_log(vec![]).encode();
let without_domain = payload.as_bytes()[ACCOUNT_LOG_DOMAIN.len()..].to_vec();
assert!(matches!(
EncodedAccountLog::parse(without_domain),
Err(AccountLogError::Domain)
));
}
#[test]
fn parse_rejects_bad_version_and_tag() {
let mut bytes = make_log(vec![]).encode().as_bytes().to_vec();
bytes[ACCOUNT_LOG_DOMAIN.len() - 2] = b'9'; // the version character
assert!(matches!(
EncodedAccountLog::parse(bytes),
Err(AccountLogError::Version(v)) if v == "9"
));
let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec();
bytes[ACCOUNT_LOG_DOMAIN.len() + HEADER] = 77; // first entry's tag byte
assert!(matches!(
EncodedAccountLog::parse(bytes),
Err(AccountLogError::Tag(77))
));
}
/// Well-formed bytes carrying an invalid log (a self-referencing remove)
/// are rejected at parse: broken logs never get past the boundary. Such
/// bytes cannot be produced through the API, so they are handcrafted.
#[test]
fn parse_rejects_invalid_log() {
let mut bytes = ACCOUNT_LOG_DOMAIN.to_vec();
bytes.extend_from_slice(&1u32.to_le_bytes());
bytes.push(TAG_REMOVE);
bytes.extend_from_slice(&0u32.to_le_bytes()); // Remove{0} at position 0
assert!(matches!(
EncodedAccountLog::parse(bytes),
Err(AccountLogError::InvalidRemove { .. })
));
}
/// Full happy path: sign with the account key, verify under the account key.
#[test]
fn verify_accepts_well_formed_log() {
let account_key = Ed25519SigningKey::generate();
let account_pub = account_key.verifying_key();
let log = make_log(vec![key(1), key(2)]);
let payload = log.encode();
let signed = SignedAccountLog {
signature: account_key.sign(payload.as_bytes()),
payload,
};
assert_eq!(verify_log(&account_pub, &signed).unwrap(), log);
}
/// A log 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.
#[test]
fn verify_rejects_wrong_account() {
let account_key = Ed25519SigningKey::generate();
let payload = make_log(vec![]).encode();
let signed = SignedAccountLog {
signature: account_key.sign(payload.as_bytes()),
payload,
};
let other = Ed25519SigningKey::generate().verifying_key();
assert!(matches!(
verify_log(&other, &signed),
Err(AccountLogError::SignatureInvalid)
));
}
/// A signature over one entry list does not verify another.
#[test]
fn verify_rejects_swapped_payload() {
let account_key = Ed25519SigningKey::generate();
let account_pub = account_key.verifying_key();
let signature = account_key.sign(make_log(vec![key(1)]).encode().as_bytes());
let signed = SignedAccountLog {
payload: make_log(vec![key(2)]).encode(),
signature,
};
assert!(matches!(
verify_log(&account_pub, &signed),
Err(AccountLogError::SignatureInvalid)
));
}
/// Appending entries is an extension; anything else is stale or a fork.
#[test]
fn extension_accepts_appends_only() {
let old = make_log(vec![key(1)]).encode();
let new = make_log(vec![key(1), key(2)]).encode();
verify_extension(&old, &new).unwrap();
// Same length: stale, even with identical contents.
assert!(matches!(
verify_extension(&old, &old),
Err(AccountLogError::NotLonger { old: 1, new: 1 })
));
// Shrinking: stale.
assert!(matches!(
verify_extension(&new, &old),
Err(AccountLogError::NotLonger { old: 2, new: 1 })
));
// Longer but rewrites entry 0: fork.
let fork = make_log(vec![key(3), key(2)]).encode();
assert!(matches!(
verify_extension(&old, &fork),
Err(AccountLogError::Forked)
));
}
}
+36
View File
@@ -0,0 +1,36 @@
pub use thiserror::Error;
#[derive(Error, Debug)]
pub enum AccountError {
#[error("Generic: {0}")]
Generic(String),
#[error("No account entry for id: {0} ")]
MissingEntry(String),
#[error("invalid account address")]
InvalidAddress,
}
/// Failures decoding, verifying, or replaying an account log.
#[derive(Debug, Error)]
pub enum AccountLogError {
#[error("payload shorter than its declared layout")]
Short,
#[error("payload is missing the account-log domain prefix")]
Domain,
#[error("unsupported log version {0}")]
Version(String),
#[error("unknown entry tag {0}")]
Tag(u8),
#[error("payload has bytes past its declared entries")]
Trailing,
#[error("text entry is not valid UTF-8")]
Utf8,
#[error("account signature verification failed")]
SignatureInvalid,
#[error("remove at position {position} does not point at an earlier live add ({index})")]
InvalidRemove { position: usize, index: u32 },
#[error("new log does not have more entries than the old ({new} <= {old})")]
NotLonger { old: u32, new: u32 },
#[error("new log rewrites history instead of extending it (fork)")]
Forked,
}
+12 -3
View File
@@ -1,13 +1,22 @@
#[cfg(feature = "dev")]
mod account;
mod addr;
mod codec;
mod directory;
mod error;
mod account_log;
use crypto::Ed25519VerifyingKey;
pub use directory::{
AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, DeviceSet, Lamport,
ResolveError, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload,
resolve_device_ids, verify_bundle,
};
pub use codec::{ACCOUNT_LOG_DOMAIN, verify_extension, verify_log};
pub use error::{AccountError, AccountLogError};
pub use account_log::{AccountEntry, AccountLog, EncodedAccountLog, EntryData, SignedAccountLog};
pub use addr::AccountAddr;
#[cfg(feature = "dev")]
mod account;
pub use account::TestLogosAccount;
#[cfg(feature = "dev")]
pub use account::{AddDelegateSignerError, TestLogosAccount};