AlgoChat Protocol

End-to-end encrypted messaging protocol for Algorand


Project maintained by CorvidLabs Hosted on GitHub Pages — Theme by mattgraham

AlgoChat Protocol: High-Level Design

This document explains how AlgoChat works end to end: the keys, the envelope on the wire, how a message is encrypted and read, how peers find each other’s keys, and how the envelope rides on an Algorand payment. It also covers the machinery in this repository (validation, the status dashboard, the trust gate, GitHub Pages).

It describes protocol 1.2. The normative text is PROTOCOL.md. If this document and PROTOCOL.md ever disagree, PROTOCOL.md wins. Pseudocode lives in IMPLEMENTATION.md, byte-exact examples in TEST-VECTORS.md, and the threat model in SECURITY.md.

Notation: ‖ means byte concatenation (PROTOCOL.md writes it ||). HKDF(ikm, salt, info) is HKDF-SHA256 with a 32-byte output. seal and open are ChaCha20-Poly1305 encrypt and decrypt.

Contents

  1. Purpose
  2. Context
  3. Components
  4. Key flows
  5. Data
  6. Runtime and deployment
  7. Security and trust boundaries
  8. Failure modes and limits
  9. Decisions
  10. Open questions found while writing this
  11. Glossary

1. Purpose

AlgoChat lets two people attach an end-to-end encrypted note to an ordinary Algorand payment, so that only the sender and the recipient can read it, with no mailbox contract, relay server or router app in between (INTENT.md, hi/send.md SEND-1). This repository is the protocol itself, not an app: it holds the specification, the implementation guide, the test vectors, the threat model, and a registry of the implementations that follow it. Its readers are the people who build AlgoChat libraries (Swift, TypeScript, Python, Rust, Kotlin, Go) and the clients built on them. The protocol is honest about its limits: addresses, timing and message size stay public on-chain, and it does not provide forward secrecy (hi/honest.md HONEST-1, HONEST-2).

2. Context

2.1 The protocol at run time

Two clients talk only through the public Algorand chain. A sender builds an envelope, puts it in the note of a payment, and submits it to an algod node. Readers find AlgoChat payments through an indexer by note prefix. The only things ever exchanged outside the chain are the optional pre-shared key (PSK) for mode 0x02 and, when a peer has never sent an AlgoChat payment, their public key.

flowchart LR
    subgraph devA["Sender device"]
        A["AlgoChat client<br/>(built on an implementation library)"]
    end
    subgraph devB["Recipient device"]
        B["AlgoChat client"]
    end
    OOB["Out-of-band channel<br/>in person, QR code"]
    subgraph algo["Algorand network: public and permanent"]
        ALGOD["algod node<br/>suggested params, submit"]
        LEDGER[("Ledger<br/>payment transactions")]
        IDX["Indexer<br/>search by note prefix"]
    end
    A -- "signed payment<br/>note = envelope" --> ALGOD
    ALGOD --> LEDGER
    LEDGER --> IDX
    IDX -- "notes with prefix<br/>0x0101 or 0x0102" --> B
    IDX -- "own sent notes,<br/>re-decrypted as sender" --> A
    A <-. "algochat-psk URI<br/>(mode 0x02 only)" .-> OOB
    OOB <-.-> B

Sources: PROTOCOL.md §10, §8.6, IMPLEMENTATION.md (Transaction Creation, Key Discovery).

2.2 This repository in the ecosystem

flowchart TB
    subgraph repo["protocol-algochat (this repo)"]
        SPEC["PROTOCOL.md<br/>normative spec"]
        GUIDE["IMPLEMENTATION.md<br/>pseudocode"]
        TV["TEST-VECTORS.md<br/>byte-exact cases"]
        SEC["SECURITY.md<br/>threat model"]
        REG["implementations.json<br/>registry"]
        STATUS["status.yml + generate-status.ts"]
    end
    LIBS["Implementation libraries<br/>swift-, ts-, py-, rs-, kt-, go-algochat"]
    HARNESS["test-algochat<br/>conformance harness"]
    CLIENTS["Clients<br/>algochat-web (archived), Raven"]
    PAGES["GitHub Pages<br/>landing page + status dashboard"]
    SPEC --> LIBS
    GUIDE --> LIBS
    TV --> LIBS
    SEC --> LIBS
    HARNESS -- "cross-checks" --> LIBS
    LIBS --> CLIENTS
    REG --> STATUS
    STATUS -- "runs each library's own tests" --> LIBS
    STATUS --> PAGES

The implementation and client lists come from README.md and implementations.json. The two lists differ slightly (see §10).

3. Components

3.1 Protocol layers

The protocol is four thin, loosely coupled layers: protocol 1.2 added Falcon-1024 at the account layer without changing a single envelope byte (PROTOCOL.md §14).

flowchart TB
    subgraph L1["Identity and keys: on the device, never on chain"]
        MN["25-word mnemonic<br/>32-byte entropy"]
        XK["X25519 key pair<br/>HKDF from entropy"]
        ED["Ed25519 account<br/>signs with sig"]
        FA["Falcon-1024 account<br/>signs with pqsig"]
        PSK["initial PSK, 32 bytes<br/>out of band, optional"]
    end
    subgraph L2["Payload: inside the ciphertext"]
        PL["UTF-8 JSON<br/>text, replyTo, or key-publish"]
    end
    subgraph L3["Envelope: the note bytes"]
        E1["0x01 standard<br/>126-byte header"]
        E2["0x02 PSK<br/>130-byte header"]
    end
    subgraph L4["Transport and discovery: public chain"]
        TX["Payment transaction<br/>note up to 1024 bytes"]
        DISC["Indexer search<br/>note prefix 0x0101 or 0x0102"]
    end
    MN --> XK
    MN --> ED
    MN --> FA
    PL --> E1
    PL --> E2
    XK --> E1
    XK --> E2
    PSK --> E2
    E1 --> TX
    E2 --> TX
    ED -- "authorizes" --> TX
    FA -- "authorizes" --> TX
    TX --> DISC
Layer What it owns Spec
Identity and keys Mnemonic entropy, the long-term X25519 key pair, the Algorand account (Ed25519 or Falcon-1024), the optional PSK and its counter state §4, §8.6
Payload JSON message shapes: text, reply, key-publish §9
Envelope Byte layout, protocol id, per-message ephemeral key, nonce, sender key wrap, ciphertext §5 to §8
Transport and discovery Payment fields, fees, note-prefix filtering, finding a peer’s X25519 key §10

3.2 Key derivation

One mnemonic feeds everything. The AlgoChat X25519 key comes from the 32-byte mnemonic entropy, so it is the same whether the account signs with Ed25519 or Falcon-1024. The same words give different addresses under the two schemes, but the same AlgoChat encryption keys (PROTOCOL.md §4.1, §4.3).

flowchart LR
    M["25-word mnemonic"] --> ENT["entropy<br/>32 bytes"]
    ENT -- "HKDF<br/>salt AlgoChat-v1-encryption<br/>info x25519-key" --> SEED["encryption_seed<br/>= X25519 private key"]
    SEED -- "X25519 base-point mult" --> PUB["X25519 public key<br/>sender_pubkey on the wire"]
    ENT -- "Ed25519 key gen<br/>sk[0:32] = entropy" --> EDA["Ed25519 address"]
    ENT -- "SHA512-256 of PQK, scheme, entropy" --> FSK["Falcon-1024 key pair"]
    FSK -- "SHA512-256 of PQA, f1, salt, pk" --> FAA["Falcon address<br/>same 58-char format"]
    FSK -. "never slice as IKM" .-x SEED

Rules that matter (all from PROTOCOL.md §4):

3.3 Repository components

Path Owns
PROTOCOL.md The normative protocol, version 1.2
IMPLEMENTATION.md Language-neutral pseudocode, data structures, error names, PSK UX guidance
TEST-VECTORS.md Byte-exact vectors for key derivation, envelopes, round trips, PSK schedule, size limits
SECURITY.md Threat model, PSK threat matrix, key handling guidance, vulnerability reporting
README.md Overview, limits, economics, implementation list
INTENT.md, hi/ Human intent: what the protocol should be, with permanent criterion ids
implementations.json Registry of implementations the status dashboard tests
scripts/validate.ts The validate task: the five protocol documents exist and are non-empty, registry ids are present and unique
scripts/generate-status.ts Builds status.html and badges/*.svg from per-implementation test results
index.html, status.html, _config.yml, _includes/head-custom.html The GitHub Pages site: hand-written landing page, generated dashboard, Jekyll config, Mermaid rendering
.github/workflows/status.yml Daily implementation tests, dashboard generation, Pages deploy
.github/workflows/trust.yml The CorvidLabs trust gate on every pull request and push to main
fledge.toml, .trust.toml, .augur.toml, .attest.json, .specsync/ Task, lane and trust-gate configuration, and SpecSync change records

4. Key flows

4.1 Find a peer’s encryption key

To write to someone you need their X25519 public key. Every AlgoChat envelope carries its sender’s key in clear, in the header, so any AlgoChat payment someone has sent reveals it, with no decryption needed. The pseudocode in IMPLEMENTATION.md (Key Discovery) searches the peer’s sent payments for standard-mode notes first, then PSK-mode notes, and returns the first envelope that parses.

sequenceDiagram
    autonumber
    participant A as Alice's client
    participant I as Indexer
    actor B as Bob
    A->>I: search payments sent by Bob, note prefix 0x0101, limit 100
    I-->>A: Bob's standard-mode AlgoChat payments
    alt an envelope parses
        Note over A: Bob's key = envelope.sender_pubkey, read from the header
    else none found
        A->>I: search payments sent by Bob, note prefix 0x0102, limit 100
        I-->>A: Bob's PSK-mode AlgoChat payments
        alt an envelope parses
            Note over A: Bob's key = envelope.sender_pubkey, read from the header
        else still none
            A-->>B: ask out of band, or Bob sends a key-publish payment to himself
            Note over A: KEY_NOT_FOUND until one of those happens
        end
    end
    Note over A,I: The binding between Bob's address and this key is the payment's authorizing signature, sig or pqsig

Three discovery methods are defined (PROTOCOL.md §10.2): scan the recipient’s sent transactions, look for a key-publish message the recipient sent to themselves, or exchange keys out of band. Only someone who can spend from an address can publish a payment from it, and that is what ties the X25519 key to the address. An extra Ed25519 signature over the X25519 key (the v1.1 announce format) is optional, only defined for Ed25519 addresses, and MUST NOT be required from Falcon-1024 senders.

4.2 Send a standard message (0x01)

Every message gets a fresh ephemeral X25519 key pair. The message key comes from ephemeral-to-recipient ECDH. The same message key is then wrapped for the sender under a second key from ephemeral-to-sender ECDH, which is what lets the sender re-read their own history from the chain on any device, without keeping plaintext (PROTOCOL.md §2 goal 3, §6).

sequenceDiagram
    autonumber
    actor U as Sender
    participant C as Sender client
    participant K as Local crypto
    participant N as algod node
    participant L as Algorand ledger
    U->>C: text for a recipient address
    Note over C: payload = JSON with "text", at most 882 bytes<br/>recipient X25519 key from flow 4.1
    C->>K: encrypt payload for recipient_pub
    Note over K: eph = fresh X25519 key pair from RANDOM(32)<br/>shared = X25519(eph_priv, recipient_pub)<br/>key = HKDF(shared, eph_pub, "AlgoChatV1" ‖ sender_pub ‖ recipient_pub)<br/>nonce = RANDOM(12)<br/>ciphertext = seal(key, nonce, payload)
    Note over K: sender_shared = X25519(eph_priv, sender_pub)<br/>sender_key = HKDF(sender_shared, eph_pub, "AlgoChatV1-SenderKey" ‖ sender_pub)<br/>encrypted_sender_key = seal(sender_key, nonce, key)<br/>eph_priv is discarded, never stored
    K-->>C: envelope = 01 01 ‖ sender_pub ‖ eph_pub ‖ nonce ‖ encrypted_sender_key ‖ ciphertext
    C->>N: get suggested params
    N-->>C: suggested params
    Note over C: payment sender to recipient, amount 0, note = envelope<br/>sign with sig (Ed25519) or pqsig (Falcon-1024, fee at least 3 x min-fee)
    C->>N: submit signed transaction
    N->>L: included in a block, public and permanent
    L-->>C: transaction id confirmed

Points that trip implementers:

4.3 Read a message, as recipient or as sender

A client reads its conversations back from the indexer, filtering by note prefix (§10.3). For each note it decides whether it is the sender or the recipient by comparing its own X25519 public key with sender_pubkey (IMPLEMENTATION.md, Message Decryption).

sequenceDiagram
    autonumber
    participant C as Client
    participant K as Local crypto, holds my X25519 key
    participant I as Indexer
    C->>I: search payments by note prefix 0x0101 and 0x0102
    I-->>C: payments with note bytes
    loop each note
        C->>K: parse and decrypt the note
        alt not version 01, unknown protocol byte, or too short
            K-->>C: INVALID_ENVELOPE, UNKNOWN_VERSION or UNKNOWN_PROTOCOL, skip it
        else parsed
            alt my key equals sender_pubkey, so I sent it
                Note over K: sender_shared = X25519(my_priv, eph_pub)<br/>sender_key = HKDF(sender_shared, eph_pub, "AlgoChatV1-SenderKey" ‖ my_pub)<br/>key = open(sender_key, nonce, encrypted_sender_key)<br/>payload = open(key, nonce, ciphertext)
            else I am the recipient
                Note over K: shared = X25519(my_priv, eph_pub)<br/>key = HKDF(shared, eph_pub, "AlgoChatV1" ‖ sender_pub ‖ my_pub)<br/>payload = open(key, nonce, ciphertext)
            end
            alt a tag check fails
                K-->>C: DECRYPTION_FAILED, fail closed, no plaintext
            else payload type is key-publish
                K-->>C: not a user message, filter it out
            else user message
                K-->>C: text, and replyTo txid and preview if present
            end
        end
    end

A flipped bit anywhere in the authenticated fields makes open fail, and the client must return nothing rather than partial plaintext (hi/encrypt.md ENCRYPT-1.a). SECURITY.md asks for the same error for every authentication failure.

4.4 Set up a PSK and send in mode 0x02

PSK mode mixes a pre-shared secret into every message key, so reading a message needs both an X25519 private key and the PSK (hi/psk.md PSK-1, PROTOCOL.md §8). Setup follows the flow in IMPLEMENTATION.md (UI/UX Guidance).

sequenceDiagram
    autonumber
    actor Alice
    participant AC as Alice's client
    participant CH as Algorand, algod and indexer
    participant BC as Bob's client
    actor Bob
    Note over Alice,Bob: Setup, once, out of band
    Note over AC: initial_psk = CSPRNG(32)
    AC->>Alice: QR code of algochat-psk://v1?addr=...&psk=...&label=...
    Alice-->>Bob: shows the QR code, ideally in person
    Bob->>BC: scans it
    Note over BC: parse URI, psk must decode to 32 bytes<br/>keep it in secure storage
    Note over AC,BC: each side persists sendCounter, peerLastCounter, seenCounters
    Note over AC,BC: Send, Alice to Bob
    Note over AC: c = sendCounter<br/>position_psk = schedule(initial_psk, c), see 5.3<br/>key = HKDF(shared ‖ position_psk, eph_pub, "AlgoChatV1-PSK" ‖ sender_pub ‖ recipient_pub)<br/>sender_key = HKDF(sender_shared ‖ position_psk, eph_pub, "AlgoChatV1-PSK-SenderKey" ‖ sender_pub)
    AC->>CH: payment, note = 01 02 ‖ c as 4 bytes big-endian ‖ rest as in 0x01
    Note over AC: sendCounter = c + 1, persisted
    Note over CH,BC: Receive
    CH-->>BC: note with prefix 0x0102
    Note over BC: replay and window checks on c, see 4.5<br/>position_psk from c, same hybrid key, open<br/>only on success: record c as seen, raise peerLastCounter

The sender reads its own PSK messages the same way as in 4.3, through encrypted_sender_key, and the counter checks apply only to messages received from the peer (IMPLEMENTATION.md, PSK Message Decryption).

4.5 Accept or reject a PSK counter

Algorand can deliver notes out of order, so the receiver accepts any unseen counter inside a window around the highest counter seen so far (PROTOCOL.md §8.5).

flowchart TD
    S["PSK envelope from the peer<br/>counter c, highest seen h, window W = 200"] --> R{"c already decrypted?"}
    R -- "yes" --> X1["reject: PSK_COUNTER_REPLAY<br/>MUST; discard silently, log"]
    R -- "no" --> O{"h > W and c < h - W?"}
    O -- "yes" --> X2["reject: PSK_COUNTER_OUT_OF_RANGE<br/>too old, SHOULD"]
    O -- "no" --> F{"c > h + W?"}
    F -- "yes" --> X3["reject: PSK_COUNTER_OUT_OF_RANGE<br/>too far ahead, SHOULD"]
    F -- "no" --> D["derive position_psk for c<br/>hybrid key, open"]
    D --> T{"tag valid?"}
    T -- "no" --> X4["DECRYPTION_FAILED<br/>counter state unchanged"]
    T -- "yes" --> OK["accept: add c to seen set<br/>h = max(h, c)"]

Test Case 4.4 in TEST-VECTORS.md pins the edges: with h = 50, counters 0, 51 and 249 pass, 251 fails, and 50 fails if it was already decrypted.

4.6 Publish the status dashboard

This is the repository’s own run-time flow. It keeps the public dashboard honest about which implementations pass their own tests.

sequenceDiagram
    autonumber
    participant GA as status.yml test jobs
    participant R as Implementation repos
    participant G as generate-status job
    participant D as deploy job
    participant P as GitHub Pages
    Note over GA: daily at 06:00 UTC, manual dispatch, or a push to main or PR that touches implementations.json, scripts, or status.yml
    par one job per implementation
        GA->>R: check out swift, ts, py, rs, kt -algochat and algochat-web
        R-->>GA: run that repo's own tests, upload ID.json with passing or failing
    end
    GA->>G: download every result into test-results
    Note over G: bun scripts/generate-status.ts writes status.html and badges
    alt not a pull request
        G->>D: status page artifact
        D->>P: check out, overlay the fresh status page, Jekyll build, deploy
    else pull request
        Note over G: stop after generating, nothing deployed
    end

Each test job records a failure as data instead of failing, and the generate and deploy jobs run with if: always(), so a failing library shows up as failing on the dashboard rather than blocking the deploy. The status.html and badges/ committed in the repo are snapshots; the deployed copies are regenerated on every run.

5. Data

5.1 Envelope byte layout

Offsets are from the deserializer in IMPLEMENTATION.md (Envelope Serialization). Ranges are half-open, [start, end).

Field Standard 0x01 PSK 0x02 Size
version (always 0x01) [0] [0] 1
protocol [1] = 0x01 [1] = 0x02 1
ratchet_counter, big-endian u32 not present [2, 6) 4
sender_pubkey, X25519 [2, 34) [6, 38) 32
ephemeral_pubkey, X25519 [34, 66) [38, 70) 32
nonce [66, 78) [70, 82) 12
encrypted_sender_key, 32-byte key + 16-byte tag [78, 126) [82, 130) 48
ciphertext, payload + 16-byte tag [126, end) [130, end) variable
Limit Standard PSK
Header 126 bytes 130 bytes
Smallest valid envelope (empty payload, tag only) 142 bytes 146 bytes
Largest envelope (Algorand note limit) 1024 bytes 1024 bytes
Largest plaintext payload 882 bytes 878 bytes
Note prefix for indexer filtering 0x0101 0x0102

5.2 Payload

The plaintext inside ciphertext is UTF-8 JSON (PROTOCOL.md §9, TEST-VECTORS.md §6):

Shape Fields Client behaviour
Text text Show it
Reply text, replyTo.txid, replyTo.preview Show it, linked to the earlier transaction
Key publish type: "key-publish", publicKey (base64) Not a user message; filter it out

The 882 and 878-byte limits apply to the encrypted plaintext, which is this JSON, so the usable text is somewhat shorter.

5.3 PSK key schedule

The “ratchet” is a deterministic two-stage schedule. Any counter’s key can be computed directly from initial_psk, which is what makes out-of-order delivery work, and also why it is not forward secrecy (PROTOCOL.md §8.1).

flowchart LR
    IP["initial_psk<br/>32 bytes, out of band"] -- "HKDF<br/>salt AlgoChat-PSK-Session<br/>info session_index, u32 BE" --> SP["session_psk<br/>session_index = c div 100"]
    SP -- "HKDF<br/>salt AlgoChat-PSK-Position<br/>info position, u32 BE" --> PP["position_psk<br/>position = c mod 100"]
    SS["X25519 shared secret<br/>ephemeral with recipient"] --> IKM["IKM = shared ‖ position_psk"]
    PP --> IKM
    IKM -- "HKDF<br/>salt eph_pub<br/>info AlgoChatV1-PSK ‖ sender_pub ‖ recipient_pub" --> K["message key"]

Leaking one position_psk exposes one message’s PSK half; leaking a session_psk exposes up to 100; leaking initial_psk exposes every past and future PSK key for that pair. Test Cases 4.1 to 4.3 in TEST-VECTORS.md pin the schedule and a full round trip.

5.4 Client-side state and on-chain objects

There is no database, smart contract or application state on chain. The chain stores only payment transactions; everything else lives on the client. The structures below are from IMPLEMENTATION.md (Data Structures, PSK Data Structures).

classDiagram
    class PaymentTransaction {
        +type pay
        +Address sender
        +Address receiver
        +uint64 amount
        +uint64 fee
        +bytes note
        +sig or pqsig authorization
    }
    class Envelope {
        +uint8 version
        +uint8 protocolId
        +Optional~uint32~ ratchetCounter
        +bytes32 senderPublicKey
        +bytes32 ephemeralPublicKey
        +bytes12 nonce
        +bytes48 encryptedSenderKey
        +bytes ciphertext
    }
    class MessagePayload {
        +string text
        +Optional~ReplyReference~ replyTo
    }
    class KeyPublishPayload {
        +string type
        +string publicKey
    }
    class KeyPair {
        +bytes32 privateKey
        +bytes32 publicKey
    }
    class PSKContact {
        +Address address
        +bytes32 initialPSK
        +Optional~string~ label
    }
    class PSKState {
        +uint32 sendCounter
        +uint32 peerLastCounter
        +Set~uint32~ seenCounters
    }
    class Message {
        +string id
        +string sender
        +string recipient
        +string content
        +DateTime timestamp
        +string direction
    }
    PaymentTransaction "1" *-- "1" Envelope : note
    Envelope ..> MessagePayload : ciphertext opens to
    Envelope ..> KeyPublishPayload : or
    PSKState --> PSKContact : contact
    Message ..> PaymentTransaction : id is the txid

What must be kept, and where (SECURITY.md):

6. Runtime and deployment

This repository ships documents, not binaries. The “release” is a protocol version.

flowchart LR
    PR["pull request or push to main"] --> T["trust.yml<br/>CorvidLabs/trust action"]
    T --> LC["lifecycle<br/>fledge lanes run verify"]
    T --> CT["contract<br/>SpecSync, no module specs here"]
    T --> RK["risk<br/>Augur, review 35, block 65"]
    T --> PV["provenance<br/>Attest, soft mode"]
    LC --> V["scripts/validate.ts<br/>5 docs + registry ids"]
    LC & CT & RK & PV --> G{"all pass?"}
    G -- "yes" --> OK["trust check green"]
    G -- "no" --> NO["trust check red, merge blocked"]

7. Security and trust boundaries

flowchart LR
    subgraph dev["Trusted: each user's own device"]
        MN["mnemonic and entropy"]
        PK["X25519 private key"]
        PS["initial PSK and counter state"]
        PT["plaintext"]
    end
    subgraph oob["Must be authenticated and confidential"]
        CH["PSK exchange<br/>in person, QR code"]
    end
    subgraph pub["Public and permanent: the Algorand chain"]
        ENV["envelope: sender_pubkey, eph_pub,<br/>nonce, ciphertexts, counter"]
        META["sender and receiver addresses,<br/>round and time, size, protocol byte"]
        SIG["authorizing signature<br/>sig or pqsig"]
    end
    subgraph relay["Relays: algod and indexer"]
        RL["see only public bytes"]
    end
    dev -- "signed payment" --> relay
    relay --> pub
    dev <-. "PSK only" .-> oob

What is protected, and how:

Property Mechanism Status
Content confidentiality X25519 ECDH per message, HKDF-SHA256, ChaCha20-Poly1305 Protected (hi/encrypt.md ENCRYPT-1)
Integrity 16-byte Poly1305 tag on both ciphertexts; chain immutability Protected, fails closed (ENCRYPT-1.a)
Replay Transaction uniqueness on chain; PSK counter window and seen set Protected
Who authorized the payment Ed25519 sig or Falcon-1024 pqsig on the transaction Protected; Falcon also resists quantum key recovery of the account
Quantum attack on the key exchange PSK mixed into HKDF (0x02) Defense in depth only; Falcon identity does not fix this (hi/account.md ACCOUNT-2)
Forward secrecy None Not provided (§11.1, HONEST-2)
Metadata privacy, traffic analysis None Not provided: addresses, timing, size and mode are public (HONEST-1)
Deniability None Not provided: sender attribution is visible

Things worth understanding before building on it:

8. Failure modes and limits

What goes wrong Where it shows up What happens
Payload over 882 bytes (878 in PSK mode) Encrypt MESSAGE_TOO_LARGE; the protocol has no fragmentation, so the application must split
Envelope too short, wrong version, unknown protocol byte Parse INVALID_ENVELOPE, UNKNOWN_VERSION, UNKNOWN_PROTOCOL; the note is skipped
Tampered bytes, wrong key, wrong PSK or counter Decrypt DECRYPTION_FAILED; no plaintext is returned
Peer has never sent an AlgoChat payment and published no key Discovery KEY_NOT_FOUND; fall back to out of band
Falcon payment pays less than 3 x min-fee, or any submit error Transport Rejected by consensus; TRANSACTION_FAILED
No PSK stored for this contact PSK send or receive PSK_NOT_FOUND; prompt the user to set one up
Counter older or further ahead than the window PSK receive PSK_COUNTER_OUT_OF_RANGE; warn about desync, offer a counter reset
Counter already decrypted PSK receive PSK_COUNTER_REPLAY; discard silently, log for debugging
Lost counter state (reinstall, new device) PSK send Counters may land outside the peer’s window; recover by manual reset or a new PSK

Error names are from IMPLEMENTATION.md (Error Handling, UI/UX Guidance). Other limits, from README.md and SECURITY.md:

9. Decisions

The big choices, and where they are recorded:

Decision Why Record
The message is the note of a normal payment; no mailbox, contract or router Delivery is the payment itself hi/send.md SEND-1, PROTOCOL.md §10.1
X25519 + HKDF-SHA256 + ChaCha20-Poly1305 Widely audited primitives, the same family as Signal, WireGuard and TLS 1.3 PROTOCOL.md §3, README.md
A fresh ephemeral key per message plus a sender key wrap Per-message key separation, and senders can re-read history from the chain on any device PROTOCOL.md §2, §6.2
Encryption keys derived from mnemonic entropy Recoverable from the 25 words alone, and independent of the signing scheme PROTOCOL.md §4.1, CHG-0002 design
PSK as a new protocol byte 0x02, purely additive Old envelopes and vectors stay valid PROTOCOL.md §5.4, §8, hi/psk.md
Deterministic PSK key schedule with a 200-counter window Works with out-of-order delivery; the price is no forward secrecy and a 100-message session blast radius PROTOCOL.md §8.1, SECURITY.md
Falcon-1024 lives only at the account layer; import defaults to Ed25519 Quantum-resistant payment authorization without moving existing wallets or changing envelopes PROTOCOL.md §4.3, hi/account.md, CHG-0002
Envelopes stay within 1,024 bytes; hybrid PQ-KEM and PQ multisig are out of scope for 1.2 Keep 1.2 small; a PQ-KEM envelope would be a later protocol id CHG-0002 design (Out of scope)
Say plainly that there is no forward secrecy Honesty about what the chain keeps forever hi/honest.md HONEST-2, PROTOCOL.md §11.1, .specsync/changes/

10. Open questions found while writing this

These are places where the documents disagree or are silent. None changes the wire format. They are listed so a reviewer can settle them in the spec.

11. Glossary

Term Meaning
algod An Algorand node’s API: suggested transaction parameters, transaction submission
Envelope The AlgoChat bytes placed in a payment’s note
Ephemeral key A one-message X25519 key pair made by the sender; only its public half is kept, in the envelope
Encrypted sender key The message key sealed for the sender, so the sender can re-read their own message
Indexer Algorand’s query service; AlgoChat uses it to search by note prefix
Key publish A self-addressed AlgoChat payment whose payload is type: "key-publish", so others can find the sender’s X25519 key
Mnemonic entropy The 32-byte secret behind a 25-word Algorand mnemonic; the IKM for AlgoChat keys
min-fee Algorand’s minimum transaction fee; PROTOCOL.md §10.3 prices at 1,000 µAlgo
Note The free-form bytes field of an Algorand transaction, 1,024 bytes at most
pqsig Algorand’s native Falcon-1024 transaction signature, scheme f1
PSK Pre-shared key: 32 random bytes two people share out of band for mode 0x02
Ratchet counter The 4-byte counter in a PSK envelope that picks the position key; a schedule index, not a Signal-style ratchet
Rekey Pointing an Algorand address’s spending authority at a different key, here Ed25519 to Falcon-1024
Session and position PSK The two stages of the PSK schedule: one key per 100 counters, then one per counter
sig The classical Ed25519 transaction signature
µAlgo One millionth of an ALGO