AccountLog

FieldValue
NameAccountLog
Slug249
Statusraw
TypeRFC
CategoryStandards Track
Tagslogos-chat, identity
Editorjazzz [email protected]

Timeline

  • 2026-09-15c9c0cb3 — docs(identity): add AccountLog specification (#404)

Abstract

An account is used from more than one place, and each place needs its own key. Keys are added as installations come and go, and revoked when one is lost. Anyone reaching the account needs to know which keys are currently good.

This document specifies the AccountLog: an append-only list of entries that an account signs and publishes. Anyone can verify the log independently, to prove it is valid. An account is an Ed25519 keypair and its public key is the account address.

A consumer verifies the log against the address it already holds and reads off what is still live. Each entry is endorsed under a context saying what it is for, so an application takes what is meant for it and ignores the rest. Revoking one key leaves the others alone.

The log only grows — revoking appends a tombstone. An update therefore begins with a byte-for-byte copy of what came before, which is how a consumer checks that the history has not been rewritten.

Terminology

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.

  • Account — an Ed25519 keypair.
  • Account Address — an account's Ed25519 public key; see Account Address.
  • Account Log — the append-only list of entries an account has signed.
  • Consumer — any party that reads an account's log.
  • Domain — the constant byte prefix every payload begins with (logos:accounts:1\0); carries the encoding version and separates account-log signatures from any other use of the account key; see Log Encoding.
  • Owner — the party holding the account signing key, and so the only party that can extend the log.
  • Live Set — the entries in a log that no Remove targets; the account's current state.

Motivation

An account represents a person to the applications and the people they interact with. To its owner it holds what they would recognise as theirs — a name, a profile picture — and presents one identity that others can find and interact with. Behind that, it holds the keys applications need to communicate on their behalf, or to store data for them. One account, used consistently across every device and service the person uses.

An account is long-lived, and everything about it moves. People update their name and bio, install new applications, replace a phone, and lose a laptop. An account must therefore be mutable, and must outlast any one service, application or device.

Being long-lived also makes it worth attacking: whoever compromises an account can masquerade as its owner. So handing every application a copy of the account key is not a workable design. Every application holding a copy can act as the user everywhere, and nothing tells one application's actions from another's, or from the user's own. A copy cannot be recalled once given. The only way to lock out a faulty or malicious application is to change the account key itself — which locks out every other application too, and leaves everyone who knew the account needing to learn it again.

What is wanted instead is for every installation to operate under its own key: unique to it, never shared, and limited to what that installation is for. The account then records the user's own data alongside the set of keys currently allowed to act for them. Any one of those keys can be withdrawn at any time, without affecting the others and without changing the account itself.

An account is therefore not a key. It is a set of keys and records that its owner revises over time, that anyone can verify, and that no single service has to hold.

Theory / Semantics

Overview

An AccountLog is a list of operations signed as a whole by the account key. Add endorses data under a context; Remove withdraws an earlier Add. Replaying the log in order — applying each Remove to its target — yields the live set: what the account currently stands behind.

To read an account, a consumer:

1. fetches the signed log stored under an address it already holds
2. verifies the signature under that address
3. decodes the payload and replays the entries
4. uses the data in the live set relevant to its own use case

A signed log nests:

signed log      64-byte signature, then the payload it signs
└── payload     the canonical wire encoding (see Wire Format)
    └── log     the decoded sequence of entries
        └── entry    opcode + length + body; body is
                     Add { context, entry_data } | Remove { index }
            └── entry_data     Ed25519Key | Text

Three things the log determines rather than stores:

  • No entry count. Entries are self-delimiting, so a consumer parses until the payload runs out.
  • No sequence number. The log only grows, so a longer log is a newer log.
  • No entry index. An entry's index is its position in the log.

The account key is absent as well, for a different reason: it is the address, which a consumer holds before it fetches anything — see Account Address.

Because the log only grows and is never rewritten, two versions of one account's log stand in a single correct relation: the older is a byte-prefix of the newer. Anything longer that is not an extension is a divergent branch, refused rather than adopted — see Log Updates.

Assumptions

Each of the following is relied on; a deployment that cannot meet one is out of scope.

The account key is trusted. It is under the sole control of its owner and remains available to that owner for the life of the account. Neither compromise nor loss is in scope.

Keys in the log are not trusted. A key endorsed in an entry may be compromised, and revocation is the response.

The owner keeps one history. An account extends a single log rather than maintaining several. Conflicting versions do occur — an old backup, a stale copy, two publishes without a re-fetch in between — but they are mistakes, not an account deliberately showing different histories to different readers.

Account logs reach consumers somehow. Some means exists to publish a signed log and fetch one by address at any time. This document neither specifies it nor depends on it: a signed log is self-contained, so how its bytes were obtained changes nothing.

Identity binding happens out of band. How a consumer comes to hold an address, and how it establishes whose account that address is, are both out of scope. This document begins once a consumer holds an address it trusts.

The log is public. Anyone holding an address can fetch the log at any time. No entry is confidential. See Privacy.

Account Address

Every account is backed by an Ed25519 signing key, and the address is its Ed25519 public key.

A consumer uses the address to verify the log's signature, and ensure it belongs to the account. How it came to hold that address is out of scope (see Assumptions).

Requirements:

  • A consumer MUST NOT use an address until it has established that the address belongs to the account it intends to reach. An address obtained from the same source that supplied the log has not been established.

Signing and Verification

signature := Ed25519_sign(account_signing_key, payload)

The signature and payload travel together as one artifact; see Signed Log for its layout.

Requirements:

  • An owner MUST sign the exact payload bytes it transmits, and a consumer MUST verify over the exact bytes it received. There is no re-serialization on either side.
  • A consumer MUST verify the signature under the address it holds for the account it intends to reach; see Account Address. A validly-signed log for account A cannot then be passed off as account B's.
  • A consumer MUST verify the signature before decoding the payload, so that decoding is never applied to unauthenticated bytes.

Ed25519 Verification Profile

RFC 8032 permits both cofactored and cofactorless verification and is silent on several encoding edge cases, so two conformant implementations can disagree on whether the same signature verifies. The profile is therefore pinned.

Requirements:

  • A consumer MUST use cofactorless verification.
  • A consumer MUST reject the whole log if the signature whose scalar component S is not canonically reduced, i.e. S MUST satisfy 0 <= S < L.
  • A consumer MUST reject the whole log contains any Ed25519 point that is not a canonical compressed Edwards point encoding, or that is small-order. This covers the account address, the commitment R, and every Ed25519Key in the log.

These rules correspond to ed25519-dalek's verify_strict and to libsodium's crypto_sign_verify_detached. Implementations MUST NOT use a permissive verify path.

Log Data Model

A log is an ordered sequence of entries, numbered from zero by position. An entry either endorses data under the account or withdraws an endorsement made earlier in the log, and does nothing else:

Add { context, entry_data }   endorse `entry_data` under `context`
Remove { index: u32 }         tombstone the entry at position `index`

entry_data is one of:

Ed25519Key([u8; 32])   an Ed25519 public key
Text(String)           a UTF-8 record

Requirements:

  • A consumer MUST derive an entry's index from its position in the log. An index is never stored or transmitted separately.
  • Entries MUST NOT be deleted, reordered, or rewritten. A log is only ever extended.

Contexts

All Add entries in an AccountLog carry a context: a short string naming what the endorsement is for and how it can be used. An encryption key, for example, may only be usable within certain use-cases — such as messaging or storage. The context allows this information to be bound to the entry_data, making it possible for applications to find the relevant information.

context := <namespace> "." <label>

The namespace identifies which "use-case" this entry relates to. It is defined and governed by a context specification.

The label identifies one specific use within the namespace. The context chat.messaging is the label messaging in the namespace chat. Context specifications define the labels, their valid uses, as well as data parsing/validity rules.

A consumer selects data it needs by its context and ignores the rest, including contexts it does not recognize.

This document defines and allocates no context. There is no registry — a context specification defines its own namespace. Collision avoidance is out of scope.

Requirements:

  • A context specification SHOULD be created which governs each namespace.
  • A consumer MUST split a context at its first full stop (0x2E): everything before is the namespace, everything after is the label.
  • A namespace MUST be a non-empty sequence of at most 16 ASCII octets, each of which is a lowercase letter (0x61–0x7A), a digit (0x30–0x39), or a hyphen-minus (0x2D). No other octet is permitted.
  • A namespace MUST must begin with a lowercase letter (0x61–0x7A).
  • A label MUST be a non-empty sequence of at most 64 ASCII octets, each of which is a lowercase letter (0x61–0x7A), a digit (0x30–0x39), a hyphen-minus (0x2D), or a full stop (0x2E). No other octet is permitted.
  • A label MUST must begin with a lowercase letter (0x61–0x7A).
  • A consumer MUST only use a key or record for the purpose defined by its context specification.

Unknown Entries

A consumer must be able to skip an entry it does not recognize, or the first unrecognized entry locks it out of the account permanently. An entry's len gives its extent without the consumer understanding it, and Remove is the only subtractive operation — so an unrecognized entry can only be endorsing something, and a consumer that ignores it acts on less authority, never more.

Requirements:

  • A consumer that does not recognize an entry's opcode MUST retain the entry as an opaque live slot: counted, indexed, targetable by a later Remove, and never surfaced to the application. It MUST NOT reject the log.
  • A consumer that does not recognize an Add's data_tag MUST retain the entry as an opaque live slot rather than reject it.
  • A future opcode MUST only add. There will never be an operation other than Remove that takes something away, because an old consumer would skip it and go on trusting what it was meant to withdraw. To change what an old consumer honors, remove the entry and add a new one under a different context.

The last requirement is a constraint on this document's future editors. A consumer acts only on entries it understands, so the only way to change what an old consumer does is Remove, which every consumer understands by construction.

Log Updates

The history of an AccountLog cannot be rewritten. Every update appends to the end of the log, and a new version cannot differ from a previous version except for at the end.

A consumer checks this on every fetch: it only adopts a newly fetched candidate payload if its bytes start with the same bytes of the one it currently holds. Where the bytes match the fetched log is a continuation and is the newer of the two. Where the bytes do not match, the two are different histories — the account has equivocated.

A fetched payload identical to the one held is a valid candidate that adds nothing, and adopting it changes nothing.

Requirements:

  • A consumer MUST retain the latest verified payload.
  • A consumer MUST verify that the retained payload is a byte-prefix of any new candidate; if not the candidate MUST be discarded.
  • A longer log is a newer log. A consumer MUST use the newest log it has seen.
  • A protocol MAY provide an out-of-band check that two parties hold the same log for an account. This document defines none.

Validity and the Live Set

A log is valid when every Remove targets a strictly earlier, still-live entry that is not itself a Remove. Any other Remove makes the whole log invalid.

To derive the live set, walk the log in order and mark each Remove's target as dead. The live set is every unmarked entry that is not a Remove, in log order, including opaque slots the consumer could not decode. The typed live set — the keys and records an application acts on — is the live set restricted to entries the consumer understands.

Requirements:

  • A consumer MUST reject the whole log if any part of it is invalid or rejected.
  • A consumer MUST reject the whole log if it contains a Remove whose index is at or after its own position.
  • A consumer MUST reject the whole log if it contains a Remove targeting a Remove. Every other entry is an endorsement, including an unrecognized one, and is a valid target.
  • A consumer MUST reject the whole log if it contains a Remove targeting an entry already removed.
  • An owner MUST validate a log before signing it.
  • Only this document states what makes a log invalid. A specification built on the AccountLog MUST NOT add a condition, and a consumer MUST ignore an entry it cannot use rather than reject the log.

Versioning

The encoding version lives in the domain (logos:accounts:1\0), the constant prefix every payload begins with — see Log Encoding. A layout change means a new domain, and the domain is inside the signed bytes, so a signature over one version's payload can never verify as another's. Payloads of different versions cannot be confused, by construction.

The framing in Entry Encoding means most future changes do not need a version bump at all: a new opcode or data variant is an allocation, not a new encoding. The version is reserved for changes to the payload or entry framing itself.

Requirements:

  • A consumer SHOULD distinguish, in the error it reports, a payload bearing logos:accounts: with an unrecognized version from a payload that is malformed, so that an operator can tell "newer than me" from "corrupt".

Wire Format Specification / Syntax

This section defines four encodings: the account address, the identifier used to refer to the account; the signed log, the artifact that is stored and transmitted; the log encoding, which frames a sequence of entries within it; and the entry encoding, which defines the bytes of a single entry. All integers are little-endian.

Address Encoding

address := 32 bytes, an Ed25519 public key

An address is transmitted and stored in binary. Where it appears in text — a URI, a config file, a log line — it is 64 lowercase hexadecimal characters with no prefix.

Requirements:

  • A consumer MUST accept an address in string form only as 64 lowercase hexadecimal characters with no prefix, and MUST reject any other form.

Signed Log

signed log := signature || payload

signature : 64 bytes, Ed25519 over `payload`
payload   : the remainder, to the end of the artifact

The signature comes first because it is fixed-width: a consumer takes the first 64 bytes and knows the rest is the payload, whose extent is the artifact's own. No length is carried. A length would sit outside the signature and could disagree with it, and a payload of the wrong extent fails verification anyway.

An artifact too short to hold a payload needs no rule of its own: it either cannot yield a 64-byte signature at all, or leaves a remainder that fails the domain compare below.

Log Encoding

The payload — the bytes that are signed:

payload := domain || entry*

domain : "logos:accounts:1\0"   constant prefix; the segment after the
                                second colon is the encoding version
entry* : zero or more entries, concatenated, filling the payload exactly

The domain provides domain separation: the account key may live in an external signer (wallet, enclave) that signs other things, and the domain stops a signature obtained for another purpose from being replayed as an account-log signature, and vice-versa. The trailing NUL keeps the domain from being a prefix of any other domain.

There is no entry count. Entries are self-delimiting, so a consumer parses until the payload is exhausted; a stored count could only ever agree or disagree with what is already there.

Every entry is individually framed with its own length, so a consumer finds each entry boundary without interpreting the entry's contents. This is what makes an unrecognized entry skippable rather than fatal (see Unknown Entries).

Requirements:

  • A consumer MUST reject the whole log if it contains a payload whose domain does not match byte-for-byte with the value defined in this specification. including the trailing NUL.
  • A consumer MUST reject the whole log if the final entry does not end exactly at the end of the payload.

Entry Encoding

Entry

Every entry is an opcode byte, a body length, and a body:

entry := opcode || len || body

opcode : u8      the operation
len    : u16 LE  length of `body` in bytes
body   : exactly `len` bytes

Requirements:

  • A consumer MUST reject the whole log if it contains a payload in which an entry's len runs past the end of the payload.
  • A consumer MUST reject the whole log if it contains an entry with bytes left over after its body is decoded.
  • An owner MUST produce exactly the layout above; there is no alternative serialization of the same entry.

Opcodes

opcodeOperationbody
0x01Adda context and tagged data
0x02Removeu32 LE index of the target entry

Opcodes not assigned above are reserved.

Add

An Add body carries the context first, then a tagged data variant:

Add body := ctx_len || context || data_tag || data_body

ctx_len  : u8      length of `context` in bytes, 1-255
context  : ASCII   what this endorsement is for, `<namespace>.<label>`
data_tag : u8      selects the variant below
data_body: variant, to the end of the entry body
data_tagDatadata_body
0x01Ed25519KeyEd25519 32 bytes, fixed width
0x02TextUTF-8 value, to the end of the entry body

data_tag values not assigned above are reserved.

0x01 <len> <u8 ctx_len> <context> 0x01 <32 bytes>   Add(Ed25519Key)
0x01 <len> <u8 ctx_len> <context> 0x02 <value>      Add(Text), UTF-8

The opcode and data_tag spaces are independent: data_tag is only read after an Add opcode, so each may assign 0x01 for its own purpose. A future data variant and a future operation are therefore separate allocations.

Requirements:

  • A consumer MUST reject the whole log if it contains an Add whose ctx_len is zero, or whose context extends to or past the end of the entry body, leaving no room for data_tag. A context is at most 255 bytes; ctx_len cannot express more.
  • A consumer MUST reject the whole log if it contains a context whose namespace is invalid.
  • A consumer MUST NOT reject the whole log because it does not recognize a validly formatted context.
  • A consumer MUST compare contexts as raw bytes.
  • A consumer MUST reject the whole log if it contains a Text whose value is not valid UTF-8. A Text with an empty value is permitted and means the record is present but blank.

Remove

Remove body := index

index : u32 LE   position of the target entry
0x02 <len=0x0004> <u32 LE index>                    Remove

Requirements:

  • A consumer MUST reject the whole log if it contains a Remove whose len is not 4.

Resource Limits

An account log grows for the life of the account and is never compacted, so its size is unbounded. As AccountLogs need to be cached, an artificial limit is imposed to keep log sizes manageable.

Requirements:

  • A consumer MUST reject the whole log if the payload larger than 131072 bytes.
  • An owner MUST NOT publish a payload larger than 131072 bytes.

The limit is a lifetime budget, not a per-update one: an account that reaches it can neither endorse nor revoke again, and nothing in this document recovers the space.

Implementation Suggestions

  • Validate in one pass. A Remove can only target an earlier entry, so a single forward walk carrying a liveness bitmap suffices.
  • Keep the verified payload, not the decoded log, as the retained per-account state. Storing the bytes means the retained copy cannot drift from what was signed, and that an implementation updated to read a new data_tag can recover what it previously held as an opaque slot by re-parsing.
  • Signal freshness with the entry count. It is monotonic and non-secret, so a protocol can carry the count it last saw alongside its own messages, and a consumer holding fewer entries re-fetches. Act on such a claim only when it exceeds what you hold: an inflated claim costs an attacker nothing but a needless fetch, while an equal or lower claim is what a revoked key would send to stop you looking.
  • Expect publish races. Where two publishes extend the same log, at most one lands. An owner whose publish is refused re-fetches, re-applies its entries, re-signs, and retries.
  • Endorse a key under one context only. Nothing here rejects a key live under two, but a key serving two purposes is one whose compromise costs both.

Security/Privacy Considerations

Security

  • No account key rotation or recovery. There is no mechanism to rotate an account key. Both compromise and loss are permanent for the lifetime of the address.
  • No identity binding. An account is just a keypair, so anyone can make one and publish a log under it. Nothing here says whose account it is. A log fetched under the wrong address verifies and replays cleanly, because it is a real log — of someone else's account. Until you have established that an address belongs to the person you mean, the log tells you nothing about them (see Assumptions).
  • A compromised account key defeats every check. An attacker holding it extends the log arbitrarily — revoking every legitimate key and endorsing its own — without ever diverging from the history consumers hold, because a clean extension is exactly what a legitimate owner produces. Consumers accept it as a normal update.
  • A consumer can never know its log is the most recent. The log carries no time, and whatever served it may have served an older copy. An attacker holding a revoked key exploits this: it keeps presenting the key and says nothing about the log, which is indistinguishable from an account that has not changed. A protocol whose security depends on revocation must specify how its consumers learn that a log has advanced.
  • First contact is unprotected. A consumer with no retained payload has nothing to compare against, so it can detect neither staleness nor a rewritten history. The guarantee is relative to what a consumer has already seen, never absolute.
  • Key Context Binding in Consumers A consumer that accepts any live key, rather than the ones endorsed for what it is doing, gives an attacker who has stolen one key access to everything that consumer does. The context is what limits the damage, and only the consumer can apply it.

Privacy

  • Revocation is a tombstone, not an erasure. Revoked keys remain visible in the log forever. This is deliberate — history is the audit trail — but it publishes the account's full endorsement history to anyone who can fetch it.
  • The log is a linkable, pollable, permanent record. Anyone holding an address can fetch it repeatedly and observe, without any interaction with the user: how many keys are live, under which purposes, when each was endorsed, when each was retired (and therefore when a device was lost or replaced), and the history of any Text records such as display names. Entry count alone fingerprints an account across observations.
  • Interaction with sender anonymity. Where this is deployed alongside a protocol providing sender unlinkability, the AccountLog is a per-account identifier that is by design long-lived and publicly accessible. Deployments MUST consider whether fetch patterns against whatever serves logs reintroduce linkability that the messaging layer removes.

Test Vectors

All vectors use the RFC 8032 Section 7.1 test keys, so they are independently reproducible. Contexts are illustrative; this document allocates none.

account signing key (seed):  9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
account address (public key): d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
endorsed key 1:              3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
endorsed key 2:              fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025
contexts used:               chat.messaging, profile.displayname, storage.vault

An Add(Ed25519Key) under context chat.messaging decomposes as:

01              opcode: Add
3000            len = 48
0e              ctx_len = 14
636861742e...   context "chat.messaging"
01              data_tag: Ed25519Key
3d4017c3...     32-byte key

V1 — empty log (domain only, live set empty)

payload:   6c6f676f733a6163636f756e74733a3100
signature: d58b9c39e92232bfa686a6b60b445168162a11ea8a47730289085f51ac161f4b
           b57bda68f65eb0f7e7c5dd8345fb6fb5375a65d4086385da76a383213800940e

V2 — one Add(Ed25519Key) under chat.messaging

payload:   6c6f676f733a6163636f756e74733a3100
           0130000e636861742e6d6573736167696e6701
           3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
signature: a2b0384fc0b3b738e830f86ecd5b34c8139f9d0c2084ff57fee84e71ad1b47aa
           83b5ec9f7c38403234390c86be534349d07a045a5e239c6f9c648a262e9a9f04

V3 — two Add(Ed25519Key), same context (strictly extends V2)

payload:   6c6f676f733a6163636f756e74733a3100
           0130000e636861742e6d6573736167696e6701
           3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
           0130000e636861742e6d6573736167696e6701
           fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025
signature: 18ca203291c5f9ad74142635174113a4619f125ca05d4d0dafda46081096ddae
           bc10c05d10ddf29caa5e444bd975f2f0cc05dca9b34e3ea18d2e21fda5c85a02

V4 — first key revoked (Remove { index: 0 })

payload:   6c6f676f733a6163636f756e74733a3100
           0130000e636861742e6d6573736167696e6701
           3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
           0130000e636861742e6d6573736167696e6701
           fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025
           02040000000000
signature: 060ec48ee5c7d245417c5af6a433b7451cd325ab80547bd377b070b54871a5f6
           8e3b207e3ea678408ff9ace88caa1cfd774cb2bdb45f27b3091fd02387fc4501

V5 — unknown entry, skipped (opcode 0x0f)

A v1 consumer does not know opcode 15. It is not Remove, so it is additive: index 1 is retained as an opaque live slot, the Remove at index 2 validly targets index 0, and the typed live set is empty. A consumer that skipped index 1 without counting it would match the Remove to the wrong slot and MUST NOT do so.

payload:   6c6f676f733a6163636f756e74733a3100
           0130000e636861742e6d6573736167696e6701
           3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
           0f0400deadbeef
           02040000000000
signature: f8e9e2a71ea777f111f3ccd84f6429493101de77d65b4baa3450bb1d45c591e5
           2e100bdc7ef6b9ba8d035e464c656d21dccbfee97f52a66f9cb94c9b2cf05d00

V6 — two live entries sharing one context (profile.displayname)

Both are live; neither is removed. A consumer working under profile.displayname selects both, in log order: alice then alice j. Which of them the account's display name actually is is a question for whatever defines that context, not for this document.

payload:   6c6f676f733a6163636f756e74733a3100
           0130000e636861742e6d6573736167696e6701
           3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
           011a001370726f66696c652e646973706c61796e616d6502616c696365
           011c001370726f66696c652e646973706c61796e616d6502616c696365206a
signature: 744f0eef44732a307ea89c784d04fa34d43f0c6314c2ec5222e5c7db81a4380b
           a5d3326c4c25f4163432d4a267f40a9e4c2071e90308a5976f6ec897587a5f08

V7 — two different keys, two contexts (chat.messaging and storage.vault)

Both keys are live. A consumer working under chat.messaging selects key 1 only; key 2 does not match and MUST NOT be used for messaging. A consumer that knows neither context selects nothing and rejects nothing. Endorsing the same key under both contexts is likewise a valid log that a consumer MUST NOT reject; Implementation Suggestions advises owners against it, which is advice rather than a rule for consumers.

payload:   6c6f676f733a6163636f756e74733a3100
           0130000e636861742e6d6573736167696e6701
           3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
           012f000d73746f726167652e7661756c7401
           fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025
signature: b5a0171ebd934937724db4a8385021f53a2552c5b00a0c2e0da496ee76c1c821
           ec24e7de828fcf784d2dbc6b6303cb01a82f0555dfefbbb32936788067cd9f03

V8 — unknown data_tag, retained (data_tag 0x7f)

The companion to V5: there the opcode was unrecognized, here the opcode is Add and the data variant is not one a v1 consumer knows. Index 1 is retained as an opaque live slot rather than rejected, so the live set is both entries and the typed live set is key 1 alone.

payload:   6c6f676f733a6163636f756e74733a3100
           0130000e636861742e6d6573736167696e6701
           3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
           0114000e636861742e6d6573736167696e677fdeadbeef
signature: 8d7535a8595fdeb8c391043ecc1a2481afe9efe02dc6072dd1bea2ad58de916e
           2d501be2c97a93f6375652f384c50b7aa507a5960caee2ea488cafd10bf1670c

Payload hex is line-wrapped and internally spaced for presentation only; concatenate the fragments to recover the exact signed bytes. A signed log is the 64-byte signature followed by the payload, so each artifact above is signature || payload.

Rejection Vectors

Each vector below is a payload that a consumer MUST reject for the reason given. All are signed by the same account key as the vectors above, so every signature verifies under address d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a. A consumer that rejects one at the signature check has failed the test for the wrong reason.

N1 mismatched domain — domain byte-compare fails

payload:   6c6f676f733a6163636f756e74733a32000130000e636861742e6d6573736167
           696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f1
           2af4660c
signature: 66001bafa87f11b844ef6d10f6f685d5cbb87e052c2a1c93eea806b60ef2c250
           f1f8c177eac8a650d65479470b9b87e67b03c326302d4eb641674e48cc462d00

N2 small-order Ed25519Key — the key is the identity element

The encoding is canonical, so this is caught by the small-order check rather than the encoding check; see Ed25519 Verification Profile. The log's own signature verifies — it is the endorsed key that is rejected.

payload:   6c6f676f733a6163636f756e74733a31000130000e636861742e6d6573736167
           696e670101000000000000000000000000000000000000000000000000000000
           00000000
signature: e486e78cf17eb20d67ebc3a54db0d6dde9980a0903b5c38b05bf5420e0c8e5dd
           417ceaef61bf31b1e578f2a93830c1ab6fea319382b986dcfabf81cd4e74ad04

N3 trailing bytes after last entry — two bytes remain, not a parseable entry

payload:   6c6f676f733a6163636f756e74733a31000130000e636861742e6d6573736167
           696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f1
           2af4660cffff
signature: ab5501ace7945c63ff25df8680a64977d37af34910c781177a82acf49dceb7d7
           32454118a603f31a649ce9d8410c356239eddc08ee9abfb821287cdf132f8d0e

N4 entry len runs past payload — len=65535 with 4 bytes available

payload:   6c6f676f733a6163636f756e74733a310001ffff00000000
signature: c9ce34c9ae7a6448277db4f864e5fef89125b47e9a2bd9b72ffc25b967a06009
           d07c34650fa5f34aa5509e317bae668b9db7cebb11373ee42525f1fcf736b109

N5 Remove with wrong len — Remove body is 5 bytes, must be 4

payload:   6c6f676f733a6163636f756e74733a31000130000e636861742e6d6573736167
           696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f1
           2af4660c0205000000000000
signature: a4ca08ef15de1ed782b32e2cf6fd50cb9e2e59158513f714f81bc16e848cb358
           9ed6cb168a2eb6c1d4d3afd164a77b407c821802cd6495025103d36538690b01

N6 Add ctx_len zero — ctx_len = 0

payload:   6c6f676f733a6163636f756e74733a310001220000013d4017c3e843895a92b7
           0aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
signature: a60390e07cd2b1fc01197e207647c665586eed63c849b2ad42bb8ac129382ddf
           79057d418e00d845a354157f88287153a82a32e8ed3f0a117569c0b421218b0e

N7 context leaves no room for data_tag — body ends after context

payload:   6c6f676f733a6163636f756e74733a3100010f000e636861742e6d6573736167
           696e67
signature: e4abccb6d6570e237fd465d2cff39ca06c7364f428518331aa8d633a46a0fc9a
           cdeb571b09fc9ef7636e379461c179ee2be88fa342bc6a4689ce2ae5c955cc0a

N8 context has illegal byte — '@' outside a-z 0-9 . -

payload:   6c6f676f733a6163636f756e74733a31000130000e636861742e6d6573734067
           696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f1
           2af4660c
signature: 9bc59f4e2928a474af39caa45b379486554fa94a158ca78e27ace1b97a5727aa
           6c66fd954b01d956ee501af2d463226977b98b9499496fd7fabb939a1cbf600d

N9 context has no namespace boundary — no '.' present

payload:   6c6f676f733a6163636f756e74733a3100012b00096d6573736167696e67013d
           4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
signature: 09457e5c68e846f1362343e4eaa7dd5371d48359328ec9987ffc80ad5d7d3a18
           ae46232bc4515d3584f541af36c27dfab077b1192030ce9f425e6792064c4b09

N10 namespace does not begin with a letter — leading digit

payload:   6c6f676f733a6163636f756e74733a31000131000f31636861742e6d65737361
           67696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55
           f12af4660c
signature: b3c3b71783b1da9686f485a323b455c0a4eaf78ff3a596b5e3c5af60b6a0c4b8
           b97a37c0f2dd0e6dad55590cfb22a91969fa08bd2b06b37d0afde0adcceabf0e

N11 invalid UTF-8 in Text — 0xff 0xfe is not UTF-8

payload:   6c6f676f733a6163636f756e74733a31000117001370726f66696c652e646973
           706c61796e616d6502fffe
signature: d6c35f646506187cb3e1fce7fc36eb226be953ea2ec9b22b12249e67bdcd3d03
           31992d19347ad07c9bd0b38668d8420b93814fa4d6b8e03702e419c80cedec0b

N12 Remove index at its own position — index 1 == its own position

payload:   6c6f676f733a6163636f756e74733a31000130000e636861742e6d6573736167
           696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f1
           2af4660c02040001000000
signature: cc625f45f42f9cce8fa0fa53997fb67e5a844e3af9b8fa43804fe22a57fb2cb1
           075a541f815062ad85787279a0ae9b442c7968bce84da42cf1c598b0cf87260b

N13 Remove of an already-removed entry — index 0 already dead

payload:   6c6f676f733a6163636f756e74733a31000130000e636861742e6d6573736167
           696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f1
           2af4660c0204000000000002040000000000
signature: aae22448caae0f4f0fcbbe0557efe2bdfbf2e1f636cbbbcfbd75bf2a7a534d7f
           499e3f04f36204c5f127b4b3114554c7da0c5548573eb4866f3563554db8250f

N14 Remove targeting a Remove — index 1 is a Remove

payload:   6c6f676f733a6163636f756e74733a31000130000e636861742e6d6573736167
           696e67013d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f1
           2af4660c0204000000000002040001000000
signature: 1904b4216f8eee78035951706be188bbdd8d3250d37f2015cb81e9bc60dfa826
           1b152ea351d672108e22c14703fd4d3f0e3439ad68c093c5e7e69764749c5200

One further case is not a payload and so has no vector here: a replacement log that does not extend the one a consumer holds, which is a relation between two logs rather than a property of one.

Copyright and related rights waived via CC0.

References

Normative

  • RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels
  • RFC 8032 — Edwards-Curve Digital Signature Algorithm (EdDSA)

Informative

  • 1/COSS — Consensus-Oriented Specification System
  • ZIP-215 — Explicitly Defined Validity Criteria for Ed25519 Signatures
  • RFC 8446 Appendix E — on the value of pinning verification behavior rather than inheriting it