Version: 1.2 Status: Stable
AlgoChat is an end-to-end encrypted messaging protocol that uses the Algorand blockchain as a transport and storage layer. Messages are encrypted client-side using modern cryptographic primitives and transmitted as transaction notes.
- Privacy - Only sender and recipient can read messages
- Forward Secrecy - Compromised keys don't expose past messages
- Bidirectional Decryption - Sender can decrypt their own sent messages. This enables chat history display on the sender's device without storing plaintext locally—messages can be re-decrypted from the blockchain on any authorized device.
- Immutability - Messages cannot be altered after transmission
- Decentralization - No trusted third parties required
- Simplicity - Minimal protocol complexity
- Quantum Resistance (Optional) - Pre-shared key mode provides defense-in-depth against future quantum attacks on key exchange
- Account agility - Payments MAY be authorized by Ed25519 or by native Falcon-1024 (
pqsig). Envelope bytes do not depend on the authorizing scheme. Falcon identity does not make X25519 key exchange quantum-safe.
| Function | Algorithm | Reference |
|---|---|---|
| Key Agreement | X25519 ECDH | RFC 7748 |
| Authenticated Encryption | ChaCha20-Poly1305 | RFC 8439 |
| Key Derivation | HKDF-SHA256 | RFC 5869 |
See TEST-VECTORS.md for canonical test vectors to verify your implementation.
Each participant derives an X25519 key pair from the 32-byte entropy of their 25-word Algorand mnemonic. That entropy is the IKM. It is independent of whether payments are later authorized by Ed25519 or Falcon-1024.
entropy = 32-byte master seed from the 25-word Algorand mnemonic
salt = "AlgoChat-v1-encryption"
info = "x25519-key"
encryption_seed = HKDF-SHA256(entropy, salt, info, 32)
private_key = encryption_seed
public_key = X25519_PUBLIC(private_key)
For an Ed25519 Algorand account, entropy is identical to
algorand_account_private_key[0:32]. Implementations MUST NOT take the first
32 bytes of a Falcon-1024 secret key as entropy. Falcon secret keys are
much larger and are derived from the mnemonic through a domain-separated
hash (SHA512-256("PQK" || scheme || entropy)). Using that material as the
AlgoChat IKM would silently fork every encryption key.
The test vectors in TEST-VECTORS.md feed 32-byte seeds. Those seeds are mnemonic entropy. They are unchanged.
Each message uses a fresh ephemeral key pair:
ephemeral_private = RANDOM(32)
ephemeral_public = X25519_PUBLIC(ephemeral_private)
The envelope does not encode the Algorand signature scheme. The payment that carries the note does.
| Scheme | Transaction field | Address | Create | Import without a scheme |
|---|---|---|---|---|
| Ed25519 | sig |
Encodes the 32-byte Ed25519 public key | Allowed | Required default. Recovers the classical address (Pera / AlgoChat v1.1). |
| Falcon-1024 | pqsig (sch = "f1") |
SHA512-256("PQA" || "f1" || salt || pk) encoded as the usual 58-character address |
Allowed; implementations MAY default new accounts here | MUST be explicit. The same 25 words yield a different address. |
The 25-word mnemonic is a cross-scheme master secret. One mnemonic can derive both an Ed25519 account and a Falcon-1024 account. They do not share an address. They DO share AlgoChat encryption keys, because those keys come from the mnemonic entropy (section 4.1).
An existing Ed25519 account MAY rekey its auth address to a Falcon-1024
address. The public address stays the same. After rekey, every payment from
that address MUST carry a Falcon pqsig and MUST pay the Falcon fee
(section 10.3).
┌─────────────────────────────────────────────────────────────────┐
│ version (1) │ protocol (1) │ sender_pubkey (32) │ ephemeral (32)│
├─────────────────────────────────────────────────────────────────┤
│ nonce (12) │ encrypted_sender_key (48) │ ciphertext (variable) │
└─────────────────────────────────────────────────────────────────┘
| Field | Size | Description |
|---|---|---|
| version | 1 byte | Protocol version (0x01) |
| protocol | 1 byte | Protocol identifier (0x01 = AlgoChat) |
| sender_pubkey | 32 bytes | Sender's X25519 public key |
| ephemeral_pubkey | 32 bytes | Per-message ephemeral public key |
| nonce | 12 bytes | Random nonce for ChaCha20-Poly1305 |
| encrypted_sender_key | 48 bytes | Encrypted symmetric key for sender (32 + 16 tag) |
| ciphertext | variable | Encrypted message + 16-byte auth tag |
- Header size: 126 bytes (fixed)
- Algorand note limit: 1024 bytes
- Maximum ciphertext: 898 bytes (1024 - 126)
- Maximum plaintext: 882 bytes (898 - 16 auth tag)
| Byte Value | Mode | Status | Description |
|---|---|---|---|
0x01 |
Standard | Stable | X25519 ECDH with ephemeral keys |
0x02 |
Ratcheting PSK | Stable (v1.1) | Hybrid X25519 + ratcheting pre-shared key. Provides defense-in-depth against quantum key exchange attacks. |
┌──────────────────────────────────────────────────────────────────────────────┐
│ version (1) │ protocol (1) │ ratchet_counter (4) │ sender_pubkey (32) │
├──────────────────────────────────────────────────────────────────────────────┤
│ ephemeral (32) │ nonce (12) │ encrypted_sender_key (48) │ ciphertext (var) │
└──────────────────────────────────────────────────────────────────────────────┘
| Field | Size | Description |
|---|---|---|
| version | 1 byte | Protocol version (0x01) |
| protocol | 1 byte | Protocol identifier (0x02 = Ratcheting PSK) |
| ratchet_counter | 4 bytes | Big-endian message counter for PSK ratcheting |
| sender_pubkey | 32 bytes | Sender's X25519 public key |
| ephemeral_pubkey | 32 bytes | Per-message ephemeral public key |
| nonce | 12 bytes | Random nonce for ChaCha20-Poly1305 |
| encrypted_sender_key | 48 bytes | Encrypted symmetric key for sender (32 + 16 tag) |
| ciphertext | variable | Encrypted message + 16-byte auth tag |
The ratchet_counter field is a monotonically increasing 32-bit unsigned integer. It determines which derived PSK is used for the message. The counter starts at 0 for the first message in a PSK conversation and increments by 1 for each subsequent message sent by the same sender.
- PSK header size: 130 bytes (126 + 4 bytes ratchet counter)
- Algorand note limit: 1024 bytes
- Maximum ciphertext: 894 bytes (1024 - 130)
- Maximum plaintext: 878 bytes (894 - 16 auth tag)
// Generate ephemeral key pair
ephemeral_private, ephemeral_public = GENERATE_KEYPAIR()
// Derive shared secret with recipient
shared_secret = X25519(ephemeral_private, recipient_public_key)
// Derive symmetric key
info = "AlgoChatV1" || sender_public_key || recipient_public_key
symmetric_key = HKDF-SHA256(shared_secret, ephemeral_public, info, 32)
// Encrypt message
nonce = RANDOM(12)
ciphertext = ChaCha20-Poly1305-ENCRYPT(symmetric_key, nonce, plaintext)
To allow the sender to decrypt their own messages:
// Derive shared secret with self
sender_shared_secret = X25519(ephemeral_private, sender_public_key)
// Derive sender encryption key
sender_info = "AlgoChatV1-SenderKey" || sender_public_key
sender_encryption_key = HKDF-SHA256(sender_shared_secret, ephemeral_public, sender_info, 32)
// Encrypt the symmetric key
encrypted_sender_key = ChaCha20-Poly1305-ENCRYPT(sender_encryption_key, nonce, symmetric_key)
// Derive shared secret
shared_secret = X25519(recipient_private_key, ephemeral_public_key)
// Derive symmetric key
info = "AlgoChatV1" || sender_public_key || recipient_public_key
symmetric_key = HKDF-SHA256(shared_secret, ephemeral_public_key, info, 32)
// Decrypt message
plaintext = ChaCha20-Poly1305-DECRYPT(symmetric_key, nonce, ciphertext)
// Derive shared secret with self
sender_shared_secret = X25519(sender_private_key, ephemeral_public_key)
// Derive sender decryption key
sender_info = "AlgoChatV1-SenderKey" || sender_public_key
sender_decryption_key = HKDF-SHA256(sender_shared_secret, ephemeral_public_key, sender_info, 32)
// Decrypt the symmetric key
symmetric_key = ChaCha20-Poly1305-DECRYPT(sender_decryption_key, nonce, encrypted_sender_key)
// Decrypt message
plaintext = ChaCha20-Poly1305-DECRYPT(symmetric_key, nonce, ciphertext)
This section defines the ratcheting pre-shared key mode. All standard mode (sections 6-7) content remains unchanged. PSK mode is entirely additive.
PSK ratcheting uses a deterministic, session-based derivation scheme. Given an initial_psk shared between two parties and a ratchet_counter, the current PSK is derived in two stages:
Stage 1: Session PSK
Messages are grouped into sessions of PSK_SESSION_SIZE (100) messages. The session PSK is derived from the initial PSK and the session index:
session_index = floor(ratchet_counter / PSK_SESSION_SIZE)
session_psk = HKDF-SHA256(
ikm: initial_psk,
salt: "AlgoChat-PSK-Session",
info: session_index as 4-byte big-endian,
length: 32
)
Stage 2: Position PSK
Within a session, each message position derives a unique PSK:
position = ratchet_counter mod PSK_SESSION_SIZE
position_psk = HKDF-SHA256(
ikm: session_psk,
salt: "AlgoChat-PSK-Position",
info: position as 4-byte big-endian,
length: 32
)
The position_psk is the PSK used for the message at this counter value.
Properties:
- Any counter value can be derived independently (no sequential dependency)
- The
initial_pskis never used directly for encryption - Compromising a
session_pskexposes at most 100 messages in that session - Compromising a
position_pskexposes only one message
PSK mode uses a hybrid key derivation that mixes both the X25519 shared secret and the ratcheted PSK into the symmetric key. This ensures that an attacker must break both X25519 and know the PSK to decrypt messages.
Recipient Symmetric Key (PSK Mode):
// Standard ECDH shared secret
shared_secret = X25519(ephemeral_private, recipient_public_key)
// Derive ratcheted PSK for this counter value
current_psk = derive_position_psk(initial_psk, ratchet_counter)
// Hybrid key derivation: mix ECDH + PSK
info = "AlgoChatV1-PSK" || sender_public_key || recipient_public_key
psk_symmetric_key = HKDF-SHA256(
ikm: shared_secret || current_psk,
salt: ephemeral_public_key,
info: info,
length: 32
)
Sender Key (PSK Mode):
sender_shared_secret = X25519(ephemeral_private, sender_public_key)
sender_info = "AlgoChatV1-PSK-SenderKey" || sender_public_key
psk_sender_key = HKDF-SHA256(
ikm: sender_shared_secret || current_psk,
salt: ephemeral_public_key,
info: sender_info,
length: 32
)
// Generate ephemeral key pair
ephemeral_private, ephemeral_public = GENERATE_KEYPAIR()
// Derive ratcheted PSK
current_psk = derive_position_psk(initial_psk, ratchet_counter)
// Derive PSK symmetric key (hybrid ECDH + PSK)
shared_secret = X25519(ephemeral_private, recipient_public_key)
info = "AlgoChatV1-PSK" || sender_public_key || recipient_public_key
psk_symmetric_key = HKDF-SHA256(
ikm: shared_secret || current_psk,
salt: ephemeral_public,
info: info,
length: 32
)
// Encrypt message
nonce = RANDOM(12)
ciphertext = ChaCha20-Poly1305-ENCRYPT(psk_symmetric_key, nonce, plaintext)
// Encrypt symmetric key for sender
sender_shared_secret = X25519(ephemeral_private, sender_public_key)
sender_info = "AlgoChatV1-PSK-SenderKey" || sender_public_key
psk_sender_key = HKDF-SHA256(
ikm: sender_shared_secret || current_psk,
salt: ephemeral_public,
info: sender_info,
length: 32
)
encrypted_sender_key = ChaCha20-Poly1305-ENCRYPT(psk_sender_key, nonce, psk_symmetric_key)
// Increment ratchet counter for next message
ratchet_counter += 1
Recipient:
// Extract ratchet_counter from envelope
ratchet_counter = envelope.ratchet_counter
// Reject replays (see section 8.5 for full counter validation rules)
if has_seen_counter(sender, ratchet_counter):
reject("Replay detected")
// Validate counter is within acceptable window
if peer_last_counter > COUNTER_WINDOW and ratchet_counter < peer_last_counter - COUNTER_WINDOW:
reject("Counter too old")
if ratchet_counter > peer_last_counter + COUNTER_WINDOW:
reject("Counter too far ahead")
// Derive ratcheted PSK
current_psk = derive_position_psk(initial_psk, ratchet_counter)
// Derive PSK symmetric key
shared_secret = X25519(recipient_private_key, envelope.ephemeral_public_key)
info = "AlgoChatV1-PSK" || envelope.sender_public_key || recipient_public_key
psk_symmetric_key = HKDF-SHA256(
ikm: shared_secret || current_psk,
salt: envelope.ephemeral_public_key,
info: info,
length: 32
)
// Decrypt message
plaintext = ChaCha20-Poly1305-DECRYPT(psk_symmetric_key, nonce, ciphertext)
// Update counter tracking
update_peer_counter(envelope.sender_public_key, ratchet_counter)
Sender (Own Messages):
ratchet_counter = envelope.ratchet_counter
current_psk = derive_position_psk(initial_psk, ratchet_counter)
sender_shared_secret = X25519(sender_private_key, envelope.ephemeral_public_key)
sender_info = "AlgoChatV1-PSK-SenderKey" || sender_public_key
psk_sender_key = HKDF-SHA256(
ikm: sender_shared_secret || current_psk,
salt: envelope.ephemeral_public_key,
info: sender_info,
length: 32
)
symmetric_key = ChaCha20-Poly1305-DECRYPT(psk_sender_key, nonce, encrypted_sender_key)
plaintext = ChaCha20-Poly1305-DECRYPT(symmetric_key, nonce, ciphertext)
Implementations MUST maintain a counter window to handle out-of-order message delivery on the blockchain. The recommended window size is COUNTER_WINDOW = 200 (2 full sessions).
Counter validation rules:
- A counter value that has already been successfully decrypted MUST be rejected (replay protection)
- A counter value more than
COUNTER_WINDOWbehind the highest seen counter SHOULD be rejected - A counter value more than
COUNTER_WINDOWahead of the highest seen counter SHOULD be rejected (potential attack or state desynchronization) - Within the window, any unseen counter value MUST be accepted regardless of order
The initial PSK is exchanged out-of-band between participants. The recommended format for exchange is a URI:
algochat-psk://v1?addr=<algorand-address>&psk=<base64url-psk>&label=<display-name>
| Parameter | Required | Description |
|---|---|---|
addr |
Yes | Algorand address of the PSK owner |
psk |
Yes | 32-byte PSK, base64url-encoded (no padding) |
label |
No | Human-readable display name |
Example:
algochat-psk://v1?addr=ABC123...XYZ&psk=qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq&label=Alice
The PSK MUST be generated using a cryptographically secure random number generator (CSPRNG). Both parties must store the same initial_psk for their shared conversation. The exchange SHOULD occur in-person or over an authenticated, confidential channel.
{
"text": "Hello, world!"
}{
"text": "This is a reply",
"replyTo": {
"txid": "ABC123...",
"preview": "Original message preview..."
}
}Used to publish encryption public key for key discovery:
{
"type": "key-publish",
"publicKey": "<base64-encoded-public-key>"
}Messages are sent as payment transactions:
{
"type": "pay",
"sender": "<sender-algorand-address>",
"receiver": "<recipient-algorand-address>",
"amount": 0,
"note": "<encrypted-envelope-bytes>"
}
To message someone, you need their X25519 public key. Discovery methods:
- Transaction History - Scan recipient's sent transactions for
sender_pubkeyin envelopes - Key Publish Transaction - Look for key-publish messages sent to self
- Out-of-Band - Exchange keys through another channel
The binding between Algorand identity and that X25519 key is the
authorizing signature of the payment (sig or pqsig). A peer who can
spend from the sender address published the envelope. Implementations MAY
also attach an Ed25519 signature over the X25519 public key (the v1.1
announce format). That extra signature is optional, applies only when the
sender address is an Ed25519 public key, and MUST NOT be required for
Falcon-1024 senders.
AlgoChat does not set the network fee. It inherits Algorand's minimum.
| Authorizing scheme | Minimum fee at 1,000 µAlgo base | Notes |
|---|---|---|
| Ed25519 | 1,000 µAlgo (0.001 ALGO) | Historical AlgoChat default |
| Falcon-1024 | 3,000 µAlgo (0.003 ALGO) | Base + 2× Falcon contribution |
Implementations MUST set a Falcon payment's fee to at least three times
min-fee (or the value reported by simulate with a placeholder pqsig).
Underpaying is rejected by consensus. The payment amount field (often 0 or
1,000 µAlgo) is independent of this fee.
Filtering for AlgoChat transactions:
When querying the indexer, filter by note prefix to match the protocol mode:
| Mode | Note Prefix | Description |
|---|---|---|
Standard (0x01) |
0x0101 |
Version byte + standard protocol byte |
Ratcheting PSK (0x02) |
0x0102 |
Version byte + PSK protocol byte |
To discover all AlgoChat messages, query for both prefixes:
standardPrefix = [0x01, 0x01]
pskPrefix = [0x01, 0x02]
See IMPLEMENTATION.md for pseudocode examples.
Each message uses a fresh ephemeral key pair. Compromise of long-term keys does not reveal past messages.
The blockchain provides replay protection through transaction uniqueness. PSK mode adds counter-based replay protection within the ratchet window.
The following metadata is visible on-chain:
- Sender Algorand address
- Recipient Algorand address
- Timestamp (block time)
- Message size (approximate)
- Protocol mode (
0x01vs0x02) reveals whether PSK is in use
If a recipient's private key is compromised:
- Past messages remain secure (forward secrecy)
- Future messages to that key are compromised
- Sender's copies remain secure (different key derivation)
- With PSK mode: Future messages also require PSK compromise, providing an additional layer of protection
// Standard mode
PROTOCOL_VERSION = 0x01
PROTOCOL_STANDARD = 0x01
HEADER_SIZE = 126
MAX_PAYLOAD_SIZE = 882
// PSK ratcheting mode
PROTOCOL_PSK_RATCHET = 0x02
PSK_HEADER_SIZE = 130
PSK_MAX_PAYLOAD_SIZE = 878
PSK_SESSION_SIZE = 100
COUNTER_WINDOW = 200
// Shared
MAX_NOTE_SIZE = 1024
FALCON_FEE_MULTIPLIER = 3 // Falcon-1024 min fee = minFee * 3
// Key derivation constants
KEY_DERIVATION_SALT = "AlgoChat-v1-encryption"
KEY_DERIVATION_INFO = "x25519-key"
ENCRYPTION_INFO_PREFIX = "AlgoChatV1"
SENDER_KEY_INFO_PREFIX = "AlgoChatV1-SenderKey"
// PSK derivation constants
PSK_SESSION_SALT = "AlgoChat-PSK-Session"
PSK_POSITION_SALT = "AlgoChat-PSK-Position"
PSK_ENCRYPTION_INFO_PREFIX = "AlgoChatV1-PSK"
PSK_SENDER_KEY_INFO_PREFIX = "AlgoChatV1-PSK-SenderKey"
See TEST-VECTORS.md for canonical test vectors that enable cross-implementation verification.
| Version | Changes |
|---|---|
| 1.0 | Initial specification |
| 1.1 | Add ratcheting PSK mode (protocol 0x02). |
| 1.2 | Account agility: payments MAY be Falcon-1024 pqsig as well as Ed25519 sig. Encryption seed is mnemonic entropy, not a signing-key prefix. Falcon min fee is 3×. Envelope bytes and 1.1 test vectors are unchanged. |