This document describes how @corvidlabs/ts-algochat (version 0.6.1) works, end to end. It is written from the source in src/, the SpecSync contract in specs/algochat/, the conformance suite in conformance/ and the workflows in .github/workflows/. Where the code does not settle a question, the text says Unknown.
The protocol itself is specified in CorvidLabs/protocol-algochat. This page covers this implementation of it.
ts-algochat is the TypeScript implementation of AlgoChat, an end-to-end encrypted messaging protocol that uses the Algorand blockchain as its only transport. Application developers embed it in browser, Bun or Node.js apps. It turns a 25-word Algorand mnemonic into a chat identity, finds a correspondent's encryption key from their on-chain history, encrypts each message into a compact binary envelope that fits in a payment transaction's 4096-byte note, signs and submits that payment, and later rebuilds and decrypts conversations from the indexer. It adds an optional pre-shared-key (PSK) mode for defense in depth, an offline send queue and background sync. It protects message content. It does not hide who talks to whom, or when, because that metadata is public on chain (spec invariant 10).
flowchart LR
accTitle: System context: ts-algochat, its host app, algod, the indexer and the ledger
dev["App developer"] -->|"imports"| sdk
user["End user<br/>(holds the mnemonic)"] --> app
subgraph host["Host process: browser, Bun or Node.js"]
app["Chat application"] -->|"calls"| sdk["@corvidlabs/ts-algochat"]
end
sdk -->|"suggested params,<br/>submit signed payment,<br/>wait for confirmation"| algod["Algorand node (algod)"]
sdk -->|"search transactions,<br/>look up by id"| indexer["Algorand indexer"]
algod --> chain[("Algorand ledger<br/>payment notes")]
chain --> indexer
peers["Other AlgoChat clients<br/>swift / rs / py / kt"] <-->|"same wire format"| chain
app <-.->|"PSK exchange URI<br/>(QR, out of band)"| peerapp["Peer's app"]
sdk -.->|"optional, Node only"| fs[("~/.algochat/<br/>keys and queue")]
AlgorandService builds algosdk.Algodv2 and algosdk.Indexer clients from an AlgorandConfig (algodServer, algodToken, indexerServer, indexerToken, optional ports) that the caller supplies. The localnet(), testnet() and mainnet() helpers return AlgoKit LocalNet or public Nodely endpoints, overridable with ALGOCHAT_ALGOD_URL, ALGOCHAT_ALGOD_TOKEN, ALGOCHAT_INDEXER_URL and ALGOCHAT_INDEXER_TOKEN. They return the differently shaped BlockchainConfig (algodUrl, indexerUrl), so an app maps the fields itself before constructing the service.FileKeyStorage, FileSendQueueStorage).flowchart LR
accTitle: Module dependencies inside the package
app["Application"] -->|"imports @corvidlabs/ts-algochat"| api
subgraph api["src/index.ts re-exports from each module"]
queue["queue/<br/>SendQueue, SyncManager"]
services["services/<br/>AlgorandService, MessageIndexer,<br/>account creation"]
psk["psk/<br/>ratchet, PSK envelope,<br/>replay state, exchange URI"]
crypto["crypto/<br/>keys, encryption, envelope,<br/>header AAD, signatures"]
blockchain["blockchain/<br/>client interfaces, network configs,<br/>key discovery, tx builder"]
models["models/<br/>types, PROTOCOL, Conversation"]
storage["storage/ and cache/<br/>key storage, caches"]
errors["errors/<br/>ChatError, ChatErrorCode"]
end
queue --> services
queue --> models
services --> crypto
services --> blockchain
services --> errors
psk --> crypto
blockchain --> crypto
services --> algosdk["algosdk 3.7.0"]
services --> falcon["falcon-1024"]
crypto --> noble["@noble/curves,<br/>ciphers, hashes"]
| Module | Owns | Key files |
|---|---|---|
services/ |
The high-level API. AlgorandService sends, replies, fetches messages and conversations, publishes and discovers keys, and waits for confirmation or indexing. MessageIndexer offers paginated history reads. mnemonic.service creates ChatAccounts. |
algorand.service.ts, MessageIndexer.ts, mnemonic.service.ts |
crypto/ |
Standard protocol cryptography: X25519 key derivation, ChaCha20-Poly1305 envelope encryption with bidirectional decryption, the 126-byte envelope codec, header AAD, Ed25519 key-announcement signatures and fingerprints. | keys.ts, encryption.ts, envelope.ts, aad.ts, signature.ts |
psk/ |
PSK v1.1: the two-level ratchet, hybrid key derivation, the 130-byte PSK envelope, replay-window state and algochat-psk:// exchange URIs. Pure functions; no network. |
ratchet.ts, encryption.ts, envelope.ts, state.ts, exchange.ts |
blockchain/ |
SDK-agnostic AlgodClient / IndexerClient interfaces, network presets, standalone key discovery (discoverEncryptionKey, parseKeyAnnouncement), plus an unsigned-transaction builder and an interface-based message indexer. |
interfaces.ts, types.ts, discovery.ts, message-transaction.ts, message-indexer.ts |
queue/ |
Offline delivery. SendQueue holds pending messages with bounded retries. SyncManager drains the queue and polls known conversations on a timer. |
SendQueue.ts, SyncManager.ts, file-send-queue-storage.ts |
models/ |
Shared types (ChatEnvelope, Message, DiscoveredKey, PendingMessage, SendOptions), the PROTOCOL constants and the Conversation class. |
types.ts, Conversation.ts |
storage/, cache/ |
EncryptionKeyStorage (in-memory and password-encrypted file), a TTL PublicKeyCache, and MessageCache. cache/ holds an older cache API kept for compatibility (LegacyMessageCache). |
storage/index.ts, file-key-storage.ts, cache/index.ts |
errors/ |
ChatError with a stable ChatErrorCode, plus isChatError and wrapError. |
ChatError.ts |
Not everything in src/ is reachable from the package entry point. package.json declares a single export (.). These are not re-exported from src/index.ts: MessageTransaction, MessageTooLargeError, MAX_NOTE_SIZE, MINIMUM_PAYMENT, the interface-based blockchain/MessageIndexer and its PublicKeyNotFoundError, x25519ECDH, the header-AAD helpers, and the Node-only FileKeyStorage and FileSendQueueStorage. Source comments point to a ts-algochat/node subpath for the Node-only classes. Unknown: that subpath is not declared in package.json, so it is not clear how consumers are meant to import them today.
A ChatAccount (algorand.service.ts) has two independent key roles: an encryption key pair (X25519) and a payment signer (Ed25519 sig or Falcon-1024 pqsig). Both come from the same mnemonic.
flowchart LR
accTitle: How a mnemonic becomes encryption keys and a payment signer
mn["25-word mnemonic"] --> ent["32-byte entropy<br/>algosdk.seedFromMnemonic"]
ent --> hkdf["HKDF-SHA256<br/>salt 'AlgoChat-v1-encryption'<br/>info 'x25519-key'"]
hkdf --> x["X25519 key pair<br/>encryptionKeys"]
ent --> edpub["Ed25519 public key<br/>ed25519PublicKey"]
mn --> sch{"scheme"}
sch -->|"ed25519<br/>(import default)"| ed["algosdk.mnemonicToSecretKey<br/>address = Ed25519 key"]
sch -->|"falcon-1024<br/>(new-account default)"| fa["pq25WordMnemonicToSeed<br/>falcon-1024 generateKey"]
ed --> s1["txnSigner: basic account signer"]
fa --> s2["txnSigner: Falcon signCompressed<br/>address from Falcon public key"]
deriveEncryptionKeys always uses mnemonic entropy, never a Falcon secret-key prefix. The same words therefore give the same X25519 key under both schemes but different addresses (spec invariant 11).createRandomChatAccount() defaults to Falcon-1024. createChatAccountFromMnemonic(words) defaults to Ed25519 so a classical phrase recovers the same address as other Algorand wallets. Pass { scheme: 'falcon-1024' } to recover a Falcon account.account field. Its private key lives only inside the txnSigner closure.0x01)Every standard message gets a fresh ephemeral X25519 key pair and a random 12-byte nonce (encryptMessage). The sender also wraps the message key for itself, so a sender can read its own sent history (bidirectional decryption).
flowchart TB
accTitle: Standard envelope key schedule
eph["fresh ephemeral<br/>X25519 key pair"]
eph --> e1["ECDH(ephemeral, recipient)"]
eph --> e2["ECDH(ephemeral, sender)"]
e1 --> m1{"EncryptionOptions.psk set?"}
m1 -->|"yes"| p1["HKDF(ECDH, salt = PSK,<br/>info 'AlgoChatV1-PSK')"]
m1 -->|"no"| k1
p1 --> k1["HKDF-SHA256<br/>salt = ephemeral public key<br/>info = 'AlgoChatV1' + senderPub + recipientPub"]
k1 --> sym["32-byte message key"]
e2 --> k2["HKDF-SHA256 (same PSK mixing)<br/>salt = ephemeral public key<br/>info = 'AlgoChatV1-SenderKey' + senderPub"]
k2 --> skey["sender wrap key"]
skey --> wrap["ChaCha20-Poly1305(nonce)<br/>encrypt message key"]
sym -->|"wrapped for the sender"| wrap
wrap --> esk["encryptedSenderKey<br/>48 bytes"]
esk --> aad["header AAD:<br/>version, protocol, senderPub,<br/>ephemeralPub, nonce, encryptedSenderKey"]
sym --> enc["ChaCha20-Poly1305(nonce, AAD)<br/>encrypt UTF-8 payload"]
aad --> enc
enc --> ct["ciphertext + 16-byte tag"]
Wire layout (envelope.ts, aad.ts):
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | version: 0x02 (VERSION_AAD, written since 0.6.1) or 0x01 (legacy, still decrypted) |
| 1 | 1 | protocolId: 0x01 |
| 2 | 32 | sender X25519 public key |
| 34 | 32 | ephemeral X25519 public key |
| 66 | 12 | nonce |
| 78 | 48 | encryptedSenderKey (32-byte key + 16-byte tag) |
| 126 | n + 16 | ciphertext + Poly1305 tag |
version 0x02, the whole 126-byte header is the AEAD associated data. Rewriting any header byte, including 0x02 to 0x01, makes decryption fail (decryptCiphertextWithHeaderAAD). version 0x01 envelopes decrypt without AAD so old history stays readable.decryptMessage picks the path by comparing the caller's public key with senderPublicKey in constant time (uint8ArrayEquals). The recipient path re-derives the message key from ECDH. The sender path unwraps encryptedSenderKey first.{"text", "replyTo": {"txid", "preview"}} with the preview cut to 80 characters (encryptReply). A payload of {"type":"key-publish"} decrypts to null. Anything else that is not JSON with a text string is treated as plain text.PROTOCOL.MAX_PAYLOAD_SIZE = 3954 bytes, which is 4096 minus the 126-byte header minus the 16-byte tag. That plaintext encodes to a note of exactly 4096 bytes. A plaintext of 882 bytes still fits in 1024 bytes.The code has two ways to mix a pre-shared key into encryption. They are easy to confuse:
Standard envelope + EncryptionOptions.psk |
PSK v1.1 protocol (0x02) |
|
|---|---|---|
| Code | crypto/encryption.ts deriveIKM |
psk/ |
| Envelope | protocol 0x01, 126-byte header |
protocol 0x02, 130-byte header with a 4-byte big-endian ratchet counter at offset 2 |
| Key material | ikm = HKDF(ECDH, salt = PSK, info 'AlgoChatV1-PSK'), then the standard HKDF |
HKDF(ECDH ‖ positionPSK, salt = ephemeralPub, info 'AlgoChatV1-PSK' ‖ senderPub ‖ recipientPub). The sender wrap key uses info 'AlgoChatV1-PSK-SenderKey' ‖ senderPub |
| Ratchet | none: the same 32-byte PSK for every message | sessionPSK = HKDF(initialPSK, salt 'AlgoChat-PSK-Session', info = uint32BE(counter / 100)), then positionPSK = HKDF(sessionPSK, salt 'AlgoChat-PSK-Position', info = uint32BE(counter % 100)) |
| Replay control | on-chain transaction uniqueness only | validateCounter / recordReceive: reject seen counters and counters outside peerLastCounter ± 200 |
| Plaintext limit | 3954 bytes | 3950 bytes |
Used by AlgorandService |
yes: one PSK passed to the constructor applies to every send and fetch of that service | no: isChatMessage only accepts protocol 0x01, so the service skips PSK v1.1 notes. Apps compose the low-level functions and their own transport |
Both PSK envelope versions follow the same AAD rule as the standard envelope: 0x02 binds the 130-byte header, 0x01 is legacy.
PSK keys are shared out of band as algochat-psk://v1?addr=<address>&psk=<base64url>&label=<label> (exchange.ts). The parser requires addr and a 32-byte psk.
A correspondent's X25519 key is found in their own transactions (discovery.ts, paginatedKeyDiscovery in algorand.service.ts):
| Note on a self-payment (sender = receiver) | Meaning | Result |
|---|---|---|
| 96 bytes: X25519 key ‖ Ed25519 signature over it | Signed announcement, written by publishKey for Ed25519 accounts since 0.6.1 |
isVerified: true if the signature checks against the address's Ed25519 key. A bad signature is discarded, never downgraded to unverified |
| 32 bytes: X25519 key | Unsigned announcement | Trust-on-first-use candidate, isVerified: false |
| A chat envelope in any transaction the address sent | senderPublicKey in the header |
Trust-on-first-use candidate, isVerified: false |
Falcon accounts publish a self-encrypted {"type":"key-publish"} envelope instead, because a Falcon address is not an Ed25519 key and cannot verify an Ed25519 signature. Their keys are always discovered as unverified.
sequenceDiagram
accTitle: Sequence: publish and discover an encryption key
autonumber
participant B as Bob's app
participant SB as AlgorandService (Bob)
participant AD as algod
participant IX as Indexer
participant SA as AlgorandService (Alice)
participant A as Alice's app
B->>SB: publishKey(bobAccount)
alt Ed25519 account
SB->>SB: sign X25519 pub with Ed25519 seed, 96-byte note
else Falcon-1024 account
SB->>SB: encryptMessage key-publish payload to self
end
SB->>AD: getTransactionParams()
SB->>SB: build 0-amount self-payment, fee = notePaymentFee
SB->>AD: sendRawTransaction(txnSigner blob)
AD-->>SB: txid
Note over AD,IX: block confirmed, indexer catches up
A->>SA: discoverPublicKey(bobAddress)
SA->>SA: check LRU key cache (128 entries)
loop pages of 100 until verified key or no next-token
SA->>IX: searchForTransactions(address = bob, nextToken)
IX-->>SA: transactions
SA->>SA: 32/96-byte self-payment? parseKeyAnnouncement, verify
SA->>SA: else chat envelope? remember senderPublicKey as TOFU
end
alt verified announcement found
SA-->>A: key (isVerified true), cached
else only TOFU candidates
SA-->>A: first candidate (isVerified false), cached
else nothing
SA-->>A: ChatError PUBLIC_KEY_NOT_FOUND
end
discoverPublicKey returns only the key bytes. discoverPublicKeyWithMetadata also returns isVerified, the transaction id, round and time, so an app can warn about unverified keys and show fingerprint() for out-of-band comparison. The standalone discoverEncryptionKey does the same over any IndexerClient, looking only at self-payments.
sequenceDiagram
accTitle: Sequence: send a message
autonumber
participant App
participant S as AlgorandService
participant C as crypto
participant Sg as ChatAccount.txnSigner
participant AD as algod
participant IX as Indexer
App->>S: sendMessage(account, to, recipientKey, text, options)
S->>C: encryptMessage(text, senderPub, recipientPub, psk?)
C-->>S: ChatEnvelope (version 0x02)
S->>C: encodeEnvelope()
C-->>S: note bytes (at most 4096)
S->>AD: getTransactionParams()
AD-->>S: suggested params
S->>S: flatFee, fee = notePaymentFee(note, minFee, scheme)
S->>S: payment to recipient, amount 1,000 microAlgos default
S->>Sg: sign([txn])
Sg-->>S: signed blob (sig or pqsig)
S->>AD: sendRawTransaction(blob)
AD-->>S: txid
opt waitForConfirmation
S->>AD: waitForConfirmation(txid, 10 rounds)
AD-->>S: confirmedRound
end
opt waitForIndexer
loop backoff 500 ms x1.5 up to 5 s, jitter, 30 s budget
S->>IX: lookupTransactionByID(txid)
end
end
S-->>App: SendResult (txid, optimistic Message, fee, round?)
sendReply is the same flow with encryptReply. The returned Message is optimistic: confirmedRound is 0 unless the caller waited for confirmation. SendOptionsPresets offers default, confirmed and indexed.
sequenceDiagram
accTitle: Sequence: fetch and decrypt a conversation
autonumber
participant App
participant S as AlgorandService
participant IX as Indexer
participant C as crypto
App->>S: fetchMessages(account, participant, afterRound?, limit 50, beforeRound?)
S->>IX: searchForTransactions(address = me, limit, minRound, maxRound)
IX-->>S: one page of transactions
loop each transaction
S->>S: keep pay txns with a note that isChatMessage (protocol 0x01)
S->>S: keep only me to participant or participant to me
S->>C: decodeEnvelope(note)
S->>C: decryptMessage(envelope, myPriv, myPub, psk?)
alt my key is senderPublicKey
C->>C: sender path, unwrap encryptedSenderKey
else
C->>C: recipient path, ECDH with ephemeral key
end
C->>C: AEAD open with header AAD (version 0x02)
alt key-publish payload
C-->>S: null, skipped
else decrypt or decode error
S->>S: console.warn and skip
else
C-->>S: text and reply context
end
end
S-->>App: Message[] sorted by time, then intra-round offset
fetchConversations(account, limit 100) reads one page of the account's transactions and groups decrypted messages by the other party, newest conversation first. It records the other party's senderPublicKey from received messages as participantPublicKey; that key is unverified.
sequenceDiagram
accTitle: Sequence: offline queue and background sync
autonumber
participant App
participant SM as SyncManager
participant Q as SendQueue
participant S as AlgorandService
App->>SM: queueMessage({recipient, recipientPublicKey, content})
SM->>Q: enqueue() (max 100, else QUEUE_FULL)
opt online and idle
SM->>SM: processQueue()
end
App->>SM: start()
loop every syncInterval (30 s) while online and idle
SM->>SM: state = syncing
loop while a queued message exists
SM->>Q: dequeue(), markSending() (retryCount + 1)
SM->>S: sendMessage() or sendReply()
alt submitted
SM->>Q: markSent(txid), onMessageSent
else error
SM->>Q: markFailed(), requeue or expire after maxRetries (3)
end
end
loop each known conversation
SM->>S: fetchMessages(participant, afterRound = lastFetchedRound)
SM->>SM: Conversation.merge() dedupes by txid, onMessagesReceived
end
SM->>SM: state = idle
end
App->>SM: setOnline(false) then setOnline(true)
SM->>Q: retryFailed()
SM->>SM: immediate sync()
SyncManager only polls conversations it already knows (created by getOrCreateConversation, addConversation or a queued send). Finding new correspondents is the app's job, for example with fetchConversations. A sent queue entry means algod accepted the transaction, not that it was confirmed.
stateDiagram-v2
accTitle: Send queue message states
[*] --> queued: enqueue()
queued --> sending: markSending(), retryCount + 1
sending --> sent: markSent(txid)
sending --> queued: markFailed(), retryCount < maxRetries
sending --> failed: markFailed(), retryCount >= maxRetries
failed --> queued: retryFailed(), only if retryCount < maxRetries
sent --> [*]: purgeSent()
failed --> [*]: purgeFailed()
sequenceDiagram
accTitle: Sequence: PSK v1.1 session
autonumber
participant A as Alice's app
participant P as psk functions
participant L as Transport chosen by the app
participant B as Bob's app
A->>P: createPSKExchangeURI(aliceAddr, psk, label)
A-->>B: algochat-psk://v1 URI, out of band (QR)
B->>P: parsePSKExchangeURI(uri), check 32-byte psk
A->>P: advanceSendCounter(state)
P-->>A: counter n, new state
A->>P: derivePSKAtCounter(initialPSK, n)
A->>P: encryptPSKMessage(text, alicePub, bobPub, positionPSK, n)
A->>P: encodePSKEnvelope(), 130-byte header + ciphertext
A->>L: deliver bytes (for example a payment note)
L->>B: bytes
B->>P: isPSKMessage(), decodePSKEnvelope()
B->>P: validateCounter(state, n)
alt seen before or outside peerLastCounter plus or minus 200
P-->>B: false, reject
else
B->>P: derivePSKAtCounter(initialPSK, n)
B->>P: decryptPSKMessage(envelope, bobPriv, bobPub, positionPSK)
B->>P: recordReceive(state, n)
end
All PSK state is immutable: each call returns a new PSKState. Persisting it is up to the app. seenCounters is a Set, so it needs converting before JSON storage.
classDiagram
accTitle: Data model
class ChatAccount {
address: string
scheme: ed25519 or falcon-1024
account?: algosdk.Account
encryptionKeys: X25519KeyPair
ed25519PublicKey: Uint8Array
txnSigner: TransactionSigner
}
class ChatEnvelope {
version: number
protocolId: number
senderPublicKey: 32 bytes
ephemeralPublicKey: 32 bytes
nonce: 12 bytes
encryptedSenderKey: 48 bytes
ciphertext: Uint8Array
}
class PSKEnvelope {
ratchetCounter: uint32
}
class Message {
id: txid
sender: string
recipient: string
content: string
timestamp: Date
confirmedRound: number
direction: sent or received
replyContext?: ReplyContext
}
class Conversation {
participant: string
participantPublicKey?: Uint8Array
lastFetchedRound?: number
merge(messages) number
}
class DiscoveredKey {
publicKey: Uint8Array
isVerified: boolean
discoveredInTx?: string
}
class PendingMessage {
id: pending-n
status: queued, sending, sent, failed
retryCount: number
maxRetries: number
txid?: string
}
ChatEnvelope <|-- PSKEnvelope
Conversation "1" o-- "*" Message
ChatAccount ..> ChatEnvelope : encrypts and decrypts
DiscoveredKey ..> PendingMessage : recipientPublicKey
PSKEnvelope is drawn as an extension of ChatEnvelope for brevity. In the code it is a separate interface in psk/types.ts with the same fields plus ratchetCounter.
On-chain state. There is no application state, box or contract. Everything is a payment transaction note:
| Note | Transaction | Written by |
|---|---|---|
Standard envelope (0x01 protocol) |
payment sender to recipient, 1,000 microAlgos by default | sendMessage, sendReply |
| 96-byte signed announcement | 0-amount self-payment | publishKey, Ed25519 accounts |
Self-encrypted key-publish envelope |
0-amount self-payment | publishKey, Falcon accounts |
| 32-byte unsigned announcement | self-payment | older clients. Still accepted as TOFU |
PSK envelope (0x02 protocol) |
chosen by the app | the app, using psk/ |
Constants (models/types.ts, psk/types.ts, message-transaction.ts): HEADER_SIZE 126 / 130, TAG_SIZE 16, ENCRYPTED_SENDER_KEY_SIZE 48, MAX_NOTE_SIZE 4096, MAX_PAYLOAD_SIZE 3954 / 3950, MIN_PAYMENT 1,000 microAlgos, SESSION_SIZE 100, COUNTER_WINDOW 200, FALCON_FEE_MULTIPLIER 3. conformance/vectors/00-protocol-constants.json pins the payload maxima.
Local storage. The SDK never stores mnemonics. Storage is opt-in:
| Store | Where | Format |
|---|---|---|
InMemoryKeyStorage, InMemorySendQueueStorage, InMemoryMessageCache, PublicKeyCache |
process memory | plain objects. PublicKeyCache entries expire after 24 hours by default |
FileKeyStorage (Node only) |
~/.algochat/keys/<address>.key, directory mode 0700, file mode 0600 |
salt (32) ‖ nonce (12) ‖ AES-256-GCM ciphertext (32) ‖ tag (16). Key from PBKDF2-SHA256, 100,000 iterations, of a caller-supplied password |
FileSendQueueStorage (Node only) |
~/.algochat/queue.json, mode 0600 |
JSON with base64 public keys, written to a temp file then renamed |
AlgorandService also keeps its own in-memory LRU of discovered keys (128 entries, no expiry) until clearKeyCache().
The package is a library: it runs inside the consumer's process, with no server of its own. tsc compiles src/ to ES2022 ESM with NodeNext resolution in dist/ (tsconfig.json). package.json requires Node.js 18 or later. The browser needs Web Crypto for @noble/ciphers/webcrypto randomness. publicKeyToBase64 and base64ToPublicKey use Buffer, so browsers need a polyfill for those two helpers.
flowchart LR
accTitle: CI, release and documentation pipeline
dev["Pull request or push"] --> trust["trust.yml<br/>CorvidLabs Trust gate<br/>(required check)"]
dev --> conf["conformance.yml<br/>bun test, 79 vector checks,<br/>deterministic regeneration"]
dev -->|"src, package.json,<br/>tsconfig, bun.lock"| ci["ci.yml<br/>bun install, build, test"]
ci -->|"push to main and<br/>version changed"| pub["npm publish to<br/>GitHub Packages<br/>(GITHUB_TOKEN)"]
main["push to main<br/>src, docs, README, typedoc.json"] --> docs["docs.yml<br/>TypeDoc + HLD + Mermaid"]
docs --> pages["GitHub Pages<br/>corvidlabs.github.io/ts-algochat"]
fledge.toml: fledge run build (bun run tsc), fledge run test (bun test), fledge run docs (TypeDoc) and the verify lane (build, then test). fledge trust verify runs that lane, the SpecSync contract check, the Augur risk gate (.augur.toml: review at 35, block at 65) and provenance (.attest.json).ci.yml publishes @corvidlabs/ts-algochat to GitHub Packages when the version in package.json differs from the published one. Tags such as 0.6.1 exist in the repository. Unknown: how GitHub Releases are cut; no release workflow is checked in.docs.yml builds TypeDoc from typedoc.json into _site/. It includes this HLD as a project document and loads docs/assets/mermaid.js, which renders mermaid code blocks in the browser with Mermaid 11 from jsDelivr. The repository-root index.html is not part of the published site.*.test.ts files in src/ cover crypto, signatures, discovery, indexing, the service (against a stubbed algod), mnemonics, conversations, PSK, caches and the queue. They need no network or credentials (specs/algochat/testing.md). After a build, bun test also runs the compiled copies in dist/.flowchart LR
accTitle: Trust boundaries
subgraph trusted["Trusted: the host process"]
mn["mnemonic / entropy"]
keys["X25519 private key<br/>Ed25519 seed or Falcon signer"]
plain["plaintext"]
psk["PSK and PSKState"]
end
subgraph untrusted["Untrusted: network and chain"]
algod["algod"]
idx["indexer<br/>(can omit or invent results)"]
chain[("public ledger:<br/>addresses, amounts, timing,<br/>envelopes forever")]
end
oob["out-of-band channel<br/>(PSK URI, fingerprint check)"]
mn -->|"derives"| keys
keys -->|"signed payment only"| algod
plain -->|"AEAD envelope only"| algod
algod --> chain --> idx
idx -->|"envelopes and announcements<br/>to verify and decrypt"| keys
oob -.-> psk
oob -.->|"compare fingerprint()"| keys
disk[("~/.algochat<br/>AES-GCM keys, 0600 files")] <-.-> keys
What is protected, and how:
@noble libraries. Tampered ciphertext fails the tag check and never yields partial plaintext (conformance/vectors/06-negative-cases.json).0x02 envelopes authenticate the full fixed header as AAD, which blocks a PSK-to-standard or version downgrade (aad.ts). The recipient path never reads encryptedSenderKey. On a 0x02 envelope that field is covered by the AAD. On a legacy 0x01 envelope, tampering with it only breaks the sender path (conformance README, note 1).isVerified: false. The SDK does not check transaction signatures in indexer responses, so a dishonest indexer can hide a signed announcement or supply a fake envelope. Apps should surface isVerified and let users compare fingerprint() values.ChatAccount.txnSigner: Ed25519 sig or Falcon-1024 pqsig. Falcon protects the account against forged spends from a quantum adversary. It does not make the X25519 key exchange quantum-safe (README, Protocol).console.warn with a transaction id or participant address and the error object, on decode, decrypt or sync failure. FileKeyStorage encrypts X25519 private keys at rest, and the key-storage interface carries a requireBiometric flag for platform implementations. InMemoryKeyStorage is documented as test-only. CI uses only the built-in GITHUB_TOKEN.What is not protected (README, Security Properties):
| Area | Limit or failure | Behavior |
|---|---|---|
| Size | Plaintext above 3954 bytes (3950 for PSK v1.1) | EncryptionError / PSKEncryptionError before any network call |
| Funds | Balance too low for amount + fee | algod rejects the transaction and the algosdk error propagates. ChatErrorCode.INSUFFICIENT_FUNDS exists, but AlgorandService does not pre-check balances |
| Fees | Payment note | Ed25519 pays minFee plus the note surcharge. Falcon-1024 pays minFee times 3 plus the same surcharge. The surcharge is 0 when the note is 1024 bytes or fewer. |
| Confirmation | waitForConfirmation (default 10 rounds) |
algosdk throws if the transaction is not confirmed in time |
| Indexer lag | waitForIndexer (default 30 s) |
backoff from 500 ms ×1.5 up to 5 s with ±20% jitter. On timeout sendMessage throws ChatError TIMEOUT after the transaction was already submitted |
| History depth | fetchMessages (limit 50) and fetchConversations (limit 100) read one indexer page of all the account's transactions, chat or not |
older messages need beforeRound paging or MessageIndexer.fetchOlderMessages |
| Undecryptable notes | wrong key, wrong PSK, corruption, foreign protocol | skipped. AlgorandService.fetchMessages logs console.warn; fetchConversations and MessageIndexer skip silently |
| PSK v1.1 notes | protocol 0x02 |
ignored by AlgorandService and MessageIndexer (see 4.3) |
| Key discovery | long histories | exhaustive paging at 100 per request unless searchDepth is set. The result is cached without expiry, so a rotated key is not seen until clearKeyCache() |
| Discovery miss | no announcement or envelope | ChatError PUBLIC_KEY_NOT_FOUND |
| Round filters | indexer minRound is inclusive |
SyncManager re-reads the last round, and Conversation.merge drops duplicates by txid |
| Queue | more than 100 pending (default) | ChatError QUEUE_FULL |
| Retries | failures past maxRetries (3) |
status failed. SendQueue fires onMessageExpired, which SyncManager reports as onMessageFailed. retryFailed() only requeues failed entries still under their budget |
| Replay (PSK v1.1) | counter already seen or more than 200 from peerLastCounter |
validateCounter returns false. Messages older than the window become unreadable through this check |
| Errors | protocol vs service | decoders throw typed EnvelopeError / PSKEnvelopeError. AEAD failures surface as a generic Error from @noble/ciphers. Service code uses ChatError with a ChatErrorCode |
The design record lives in SpecSync rather than a DECISIONS file:
specs/algochat/algochat.spec.md: the contract: public API, 12 invariants, error cases.specs/algochat/context.md: design decisions.specs/algochat/requirements.md: REQ-algochat-* acceptance criteria..specsync/archive/changes/: accepted change records, including CHG-0007 Falcon-default accounts and removal of the raven mailbox transport.conformance/README.md: how the vectors are made, and why they are append-only.The decisions that shape the design:
txnSigner hides whether the account signs with Ed25519 or Falcon-1024. New accounts default to Falcon. Imports default to Ed25519 so existing phrases keep their addresses.0x02 adds header AAD. Ed25519 accounts publish signed announcements, and discovery rejects forged ones. Legacy 0x01 still decrypts.| Term | Meaning |
|---|---|
| algod | The Algorand node REST API: fetches transaction parameters, accepts signed transactions and reports confirmation. |
| Indexer | The Algorand service that answers queries over historical transactions. |
| Note | The free-form field, up to 4096 bytes, on an Algorand transaction. It carries the envelope. The first 1024 bytes cost one minimum fee. |
| Round | An Algorand block height. confirmedRound orders messages. |
| microAlgo | One millionth of an Algo. The default message payment is 1,000. |
| Envelope | The binary AlgoChat message: header plus ciphertext. |
| X25519 / ECDH | The elliptic-curve Diffie-Hellman key agreement used for encryption keys. |
| HKDF | HMAC-based key derivation (here with SHA-256). |
| AEAD / AAD | Authenticated encryption, and the associated data it authenticates without encrypting. Here: ChaCha20-Poly1305 with the envelope header as AAD. |
| Ephemeral key | A key pair generated for one message and then discarded (only its public half is kept, in the envelope). |
| Bidirectional decryption | The sender can decrypt its own messages via encryptedSenderKey. |
| Key announcement | A self-payment whose note publishes an X25519 public key, signed (96 bytes) or unsigned (32 bytes). |
| TOFU | Trust on first use: accepting a key without cryptographic proof of ownership. |
| PSK | Pre-shared key: a 32-byte secret exchanged out of band. |
| Ratchet counter | The per-message counter in PSK v1.1 that selects the session and position keys. |
sig / pqsig |
The Algorand transaction signature fields for Ed25519 and for post-quantum (Falcon-1024) signatures. |
| Falcon-1024 | A lattice-based post-quantum signature scheme. |
| Fingerprint | The first 8 bytes of SHA-256 of a public key, in hex groups, for people to compare. |