AlgoChat Implementation Guide
January 28, 2026 ยท View on GitHub
Language-agnostic pseudocode for implementing the AlgoChat protocol. PSK sections (added in v1.1) use Swift-style pseudocode.
Dependencies
Your implementation needs:
- X25519 - Elliptic curve Diffie-Hellman
- ChaCha20-Poly1305 - Authenticated encryption
- HKDF-SHA256 - Key derivation
- Algorand SDK - Transaction creation and submission
Core Functions
Key Derivation
function deriveEncryptionKeys(algorandSeed: bytes[32]) -> KeyPair:
salt = encode("AlgoChat-v1-encryption")
info = encode("x25519-key")
encryptionSeed = HKDF_SHA256(
ikm: algorandSeed,
salt: salt,
info: info,
length: 32
)
privateKey = encryptionSeed
publicKey = X25519_GetPublicKey(privateKey)
return KeyPair(privateKey, publicKey)
Ephemeral Key Generation
function generateEphemeralKeyPair() -> KeyPair:
privateKey = secureRandomBytes(32)
publicKey = X25519_GetPublicKey(privateKey)
return KeyPair(privateKey, publicKey)
Message Encryption
function encryptMessage(
plaintext: string,
senderPrivateKey: bytes[32],
senderPublicKey: bytes[32],
recipientPublicKey: bytes[32]
) -> Envelope:
messageBytes = encode(plaintext)
if length(messageBytes) > 882:
throw Error("Message too large")
// Generate ephemeral key pair
ephemeral = generateEphemeralKeyPair()
// Derive shared secret with recipient
sharedSecret = X25519_ECDH(ephemeral.privateKey, recipientPublicKey)
// Derive symmetric key
info = concat("AlgoChatV1", senderPublicKey, recipientPublicKey)
symmetricKey = HKDF_SHA256(
ikm: sharedSecret,
salt: ephemeral.publicKey,
info: info,
length: 32
)
// Encrypt message
nonce = secureRandomBytes(12)
ciphertext = ChaCha20Poly1305_Encrypt(symmetricKey, nonce, messageBytes)
// Encrypt symmetric key for sender (bidirectional decryption)
senderSharedSecret = X25519_ECDH(ephemeral.privateKey, senderPublicKey)
senderInfo = concat("AlgoChatV1-SenderKey", senderPublicKey)
senderEncryptionKey = HKDF_SHA256(
ikm: senderSharedSecret,
salt: ephemeral.publicKey,
info: senderInfo,
length: 32
)
encryptedSenderKey = ChaCha20Poly1305_Encrypt(senderEncryptionKey, nonce, symmetricKey)
return Envelope(
version: 0x01,
protocolId: 0x01,
senderPublicKey: senderPublicKey,
ephemeralPublicKey: ephemeral.publicKey,
nonce: nonce,
encryptedSenderKey: encryptedSenderKey,
ciphertext: ciphertext
)
Message Decryption
function decryptMessage(
envelope: Envelope,
myPrivateKey: bytes[32],
myPublicKey: bytes[32]
) -> string | null:
weAreSender = (myPublicKey == envelope.senderPublicKey)
if weAreSender:
plaintext = decryptAsSender(envelope, myPrivateKey, myPublicKey)
else:
plaintext = decryptAsRecipient(envelope, myPrivateKey, myPublicKey)
// Check for special payload types
payload = parseJSON(plaintext)
if payload.type == "key-publish":
return null // Not a user message
return payload.text
function decryptAsRecipient(
envelope: Envelope,
recipientPrivateKey: bytes[32],
recipientPublicKey: bytes[32]
) -> bytes:
// Derive shared secret
sharedSecret = X25519_ECDH(recipientPrivateKey, envelope.ephemeralPublicKey)
// Derive symmetric key
info = concat("AlgoChatV1", envelope.senderPublicKey, recipientPublicKey)
symmetricKey = HKDF_SHA256(
ikm: sharedSecret,
salt: envelope.ephemeralPublicKey,
info: info,
length: 32
)
// Decrypt message
return ChaCha20Poly1305_Decrypt(symmetricKey, envelope.nonce, envelope.ciphertext)
function decryptAsSender(
envelope: Envelope,
senderPrivateKey: bytes[32],
senderPublicKey: bytes[32]
) -> bytes:
// Derive shared secret with self
sharedSecret = X25519_ECDH(senderPrivateKey, envelope.ephemeralPublicKey)
// Derive sender decryption key
senderInfo = concat("AlgoChatV1-SenderKey", senderPublicKey)
senderDecryptionKey = HKDF_SHA256(
ikm: sharedSecret,
salt: envelope.ephemeralPublicKey,
info: senderInfo,
length: 32
)
// Decrypt the symmetric key
symmetricKey = ChaCha20Poly1305_Decrypt(
senderDecryptionKey,
envelope.nonce,
envelope.encryptedSenderKey
)
// Decrypt message
return ChaCha20Poly1305_Decrypt(symmetricKey, envelope.nonce, envelope.ciphertext)
Envelope Serialization
function serializeEnvelope(envelope: Envelope) -> bytes:
return concat(
[envelope.version], // 1 byte
[envelope.protocolId], // 1 byte
envelope.senderPublicKey, // 32 bytes
envelope.ephemeralPublicKey, // 32 bytes
envelope.nonce, // 12 bytes
envelope.encryptedSenderKey, // 48 bytes
envelope.ciphertext // variable
)
function deserializeEnvelope(data: bytes) -> Envelope:
if length(data) < 2:
throw Error("Envelope too short")
version = data[0]
protocolId = data[1]
if version != 0x01:
throw Error("Unknown protocol version")
if protocolId == 0x01:
// Standard mode
if length(data) < 126:
throw Error("Envelope too short for standard mode")
return Envelope(
version: version,
protocolId: protocolId,
ratchetCounter: nil,
senderPublicKey: data[2:34],
ephemeralPublicKey: data[34:66],
nonce: data[66:78],
encryptedSenderKey: data[78:126],
ciphertext: data[126:]
)
else if protocolId == 0x02:
// PSK ratcheting mode
if length(data) < 130:
throw Error("Envelope too short for PSK mode")
ratchetCounter = bigEndianUInt32(data[2:6])
return Envelope(
version: version,
protocolId: protocolId,
ratchetCounter: ratchetCounter,
senderPublicKey: data[6:38],
ephemeralPublicKey: data[38:70],
nonce: data[70:82],
encryptedSenderKey: data[82:130],
ciphertext: data[130:]
)
else:
throw Error("Unknown protocol identifier")
Transaction Creation
function createMessageTransaction(
sender: AlgorandAddress,
recipient: AlgorandAddress,
envelope: Envelope,
algodClient: AlgodClient
) -> SignedTransaction:
params = algodClient.getTransactionParams()
txn = PaymentTransaction(
sender: sender,
receiver: recipient,
amount: 0,
note: serializeEnvelope(envelope),
suggestedParams: params
)
return txn
Key Discovery
function discoverPublicKey(
address: AlgorandAddress,
indexerClient: IndexerClient
) -> bytes[32] | null:
// Search sent transactions for standard mode
standardTxns = indexerClient.searchTransactions(
sender: address,
notePrefix: [0x01, 0x01], // version + standard protocol
limit: 100
)
for txn in standardTxns:
try:
envelope = deserializeEnvelope(txn.note)
return envelope.senderPublicKey
catch:
continue
// Also search PSK mode transactions
pskTxns = indexerClient.searchTransactions(
sender: address,
notePrefix: [0x01, 0x02], // version + PSK protocol
limit: 100
)
for txn in pskTxns:
try:
envelope = deserializeEnvelope(txn.note)
return envelope.senderPublicKey
catch:
continue
return null
Data Structures
struct KeyPair:
privateKey: bytes[32]
publicKey: bytes[32]
struct Envelope:
version: uint8
protocolId: uint8
ratchetCounter: uint32? // Present only for protocol 0x02 (PSK)
senderPublicKey: bytes[32]
ephemeralPublicKey: bytes[32]
nonce: bytes[12]
encryptedSenderKey: bytes[48]
ciphertext: bytes[]
struct Message:
id: string // Transaction ID
sender: string // Algorand address
recipient: string // Algorand address
content: string // Decrypted text
timestamp: DateTime
direction: "sent" | "received"
replyContext?: ReplyContext
struct ReplyContext:
txid: string
preview: string
struct MessagePayload:
text: string
replyTo?: ReplyReference
struct ReplyReference:
txid: string
preview: string
struct KeyPublishPayload:
type: "key-publish"
publicKey: string // Base64 encoded
Error Handling
enum AlgoChatError:
INVALID_ENVELOPE // Malformed envelope data
UNKNOWN_VERSION // Unsupported protocol version
UNKNOWN_PROTOCOL // Unrecognized protocol byte
DECRYPTION_FAILED // Authentication tag mismatch
MESSAGE_TOO_LARGE // Exceeds payload limit (882 standard, 878 PSK)
KEY_NOT_FOUND // Could not discover recipient key
TRANSACTION_FAILED // Blockchain submission error
PSK_NOT_FOUND // No PSK established for this contact
PSK_COUNTER_OUT_OF_RANGE // Ratchet counter outside acceptable window
PSK_COUNTER_REPLAY // Ratchet counter already seen (replay)
Testing Vectors
Key Derivation Test
Input seed (hex): 0x0000...0000 (32 zero bytes)
Salt: "AlgoChat-v1-encryption"
Info: "x25519-key"
Expected private key (hex): <implementation-specific>
Expected public key (hex): <implementation-specific>
Encryption Test
Use known test vectors to verify your implementation matches other implementations.
Security Checklist
- Use cryptographically secure random number generator
- Clear sensitive data from memory after use
- Validate envelope format before processing
- Handle decryption failures gracefully
- Never log or expose private keys
- Use constant-time comparison for authentication tags
- (PSK) Store initial PSK in secure storage (Keychain, secure enclave)
- (PSK) Persist ratchet counter state to prevent replay and counter drift
- (PSK) Clear PSK state from memory after use
PSK Ratcheting Mode (v1.1)
The following sections use Swift-style pseudocode to describe PSK ratcheting mode (protocol 0x02).
PSK Data Structures
struct PSKContact: Sendable {
let address: AlgorandAddress
let initialPSK: Data // 32 bytes, from out-of-band exchange
let label: String? // Human-readable display name
}
struct PSKState: Sendable {
let contact: PSKContact
var sendCounter: UInt32 // Next counter to use when sending
var peerLastCounter: UInt32 // Highest counter seen from peer
var seenCounters: Set<UInt32> // Counters successfully decrypted (within window)
}
struct PSKEnvelope: Sendable {
let version: UInt8 // 0x01
let protocolId: UInt8 // 0x02
let ratchetCounter: UInt32
let senderPublicKey: Data // 32 bytes
let ephemeralPublicKey: Data // 32 bytes
let nonce: Data // 12 bytes
let encryptedSenderKey: Data // 48 bytes
let ciphertext: Data // variable
}
PSK Ratchet Derivation
/// Derives the position PSK for a given ratchet counter.
/// This is a pure function: the same inputs always produce the same output.
func derivePSKAtCounter(
initialPSK: Data,
counter: UInt32
) -> Data {
let sessionSize: UInt32 = 100
let sessionIndex = counter / sessionSize
let position = counter % sessionSize
// Stage 1: Session PSK
let sessionPSK = hkdfSHA256(
ikm: initialPSK,
salt: Data("AlgoChat-PSK-Session".utf8),
info: sessionIndex.bigEndianData, // 4 bytes
length: 32
)
// Stage 2: Position PSK
let positionPSK = hkdfSHA256(
ikm: sessionPSK,
salt: Data("AlgoChat-PSK-Position".utf8),
info: position.bigEndianData, // 4 bytes
length: 32
)
return positionPSK
}
/// Derives the session PSK for a given session index.
/// Useful for cache optimization (cache per-session, derive per-position).
func deriveSessionPSK(
initialPSK: Data,
sessionIndex: UInt32
) -> Data {
return hkdfSHA256(
ikm: initialPSK,
salt: Data("AlgoChat-PSK-Session".utf8),
info: sessionIndex.bigEndianData,
length: 32
)
}
PSK Symmetric Key Derivation
/// Derives the hybrid symmetric key for PSK mode.
/// Mixes X25519 shared secret with the ratcheted PSK.
func derivePSKSymmetricKey(
sharedSecret: Data,
currentPSK: Data,
ephemeralPublicKey: Data,
senderPublicKey: Data,
recipientPublicKey: Data
) -> Data {
let ikm = sharedSecret + currentPSK
let info = Data("AlgoChatV1-PSK".utf8) + senderPublicKey + recipientPublicKey
return hkdfSHA256(
ikm: ikm,
salt: ephemeralPublicKey,
info: info,
length: 32
)
}
/// Derives the sender key for PSK mode (bidirectional decryption).
func derivePSKSenderKey(
senderSharedSecret: Data,
currentPSK: Data,
ephemeralPublicKey: Data,
senderPublicKey: Data
) -> Data {
let ikm = senderSharedSecret + currentPSK
let info = Data("AlgoChatV1-PSK-SenderKey".utf8) + senderPublicKey
return hkdfSHA256(
ikm: ikm,
salt: ephemeralPublicKey,
info: info,
length: 32
)
}
PSK Message Encryption
func encryptMessagePSK(
plaintext: String,
senderKeyPair: KeyPair,
recipientPublicKey: Data,
pskState: inout PSKState
) throws -> PSKEnvelope {
let messageBytes = Data(plaintext.utf8)
guard messageBytes.count <= 878 else {
throw AlgoChatError.messageTooLarge
}
// Derive ratcheted PSK for current counter
let counter = pskState.sendCounter
let currentPSK = derivePSKAtCounter(
initialPSK: pskState.contact.initialPSK,
counter: counter
)
// Generate ephemeral key pair
let ephemeral = generateEphemeralKeyPair()
// Derive hybrid symmetric key (ECDH + PSK)
let sharedSecret = x25519ECDH(ephemeral.privateKey, recipientPublicKey)
let symmetricKey = derivePSKSymmetricKey(
sharedSecret: sharedSecret,
currentPSK: currentPSK,
ephemeralPublicKey: ephemeral.publicKey,
senderPublicKey: senderKeyPair.publicKey,
recipientPublicKey: recipientPublicKey
)
// Encrypt message
let nonce = secureRandomBytes(12)
let ciphertext = chacha20Poly1305Encrypt(symmetricKey, nonce, messageBytes)
// Encrypt symmetric key for sender (bidirectional decryption)
let senderSharedSecret = x25519ECDH(ephemeral.privateKey, senderKeyPair.publicKey)
let senderKey = derivePSKSenderKey(
senderSharedSecret: senderSharedSecret,
currentPSK: currentPSK,
ephemeralPublicKey: ephemeral.publicKey,
senderPublicKey: senderKeyPair.publicKey
)
let encryptedSenderKey = chacha20Poly1305Encrypt(senderKey, nonce, symmetricKey)
// Increment counter for next message
pskState.sendCounter = counter + 1
return PSKEnvelope(
version: 0x01,
protocolId: 0x02,
ratchetCounter: counter,
senderPublicKey: senderKeyPair.publicKey,
ephemeralPublicKey: ephemeral.publicKey,
nonce: nonce,
encryptedSenderKey: encryptedSenderKey,
ciphertext: ciphertext
)
}
PSK Message Decryption
func decryptMessagePSK(
envelope: PSKEnvelope,
myKeyPair: KeyPair,
pskState: inout PSKState
) throws -> String {
let counter = envelope.ratchetCounter
let counterWindow: UInt32 = 200
let weAreSender = (myKeyPair.publicKey == envelope.senderPublicKey)
// Validate counter window (for received messages)
if !weAreSender {
if counter > pskState.peerLastCounter + counterWindow {
throw AlgoChatError.pskCounterOutOfRange
}
if pskState.peerLastCounter > counterWindow,
counter < pskState.peerLastCounter - counterWindow {
throw AlgoChatError.pskCounterOutOfRange
}
if pskState.seenCounters.contains(counter) {
throw AlgoChatError.pskCounterReplay
}
}
// Derive ratcheted PSK
let currentPSK = derivePSKAtCounter(
initialPSK: pskState.contact.initialPSK,
counter: counter
)
let plaintext: Data
if weAreSender {
// Decrypt as sender (via encrypted_sender_key)
let senderSharedSecret = x25519ECDH(myKeyPair.privateKey, envelope.ephemeralPublicKey)
let senderKey = derivePSKSenderKey(
senderSharedSecret: senderSharedSecret,
currentPSK: currentPSK,
ephemeralPublicKey: envelope.ephemeralPublicKey,
senderPublicKey: myKeyPair.publicKey
)
let symmetricKey = try chacha20Poly1305Decrypt(
senderKey,
envelope.nonce,
envelope.encryptedSenderKey
)
plaintext = try chacha20Poly1305Decrypt(
symmetricKey,
envelope.nonce,
envelope.ciphertext
)
} else {
// Decrypt as recipient
let sharedSecret = x25519ECDH(myKeyPair.privateKey, envelope.ephemeralPublicKey)
let symmetricKey = derivePSKSymmetricKey(
sharedSecret: sharedSecret,
currentPSK: currentPSK,
ephemeralPublicKey: envelope.ephemeralPublicKey,
senderPublicKey: envelope.senderPublicKey,
recipientPublicKey: myKeyPair.publicKey
)
plaintext = try chacha20Poly1305Decrypt(
symmetricKey,
envelope.nonce,
envelope.ciphertext
)
// Update counter tracking
pskState.seenCounters.insert(counter)
if counter > pskState.peerLastCounter {
pskState.peerLastCounter = counter
}
}
guard let text = String(data: plaintext, encoding: .utf8) else {
throw AlgoChatError.decryptionFailed
}
return text
}
PSK Envelope Serialization
func serializePSKEnvelope(_ envelope: PSKEnvelope) -> Data {
var data = Data()
data.append(envelope.version) // 1 byte
data.append(envelope.protocolId) // 1 byte
data.append(envelope.ratchetCounter.bigEndianData) // 4 bytes
data.append(envelope.senderPublicKey) // 32 bytes
data.append(envelope.ephemeralPublicKey) // 32 bytes
data.append(envelope.nonce) // 12 bytes
data.append(envelope.encryptedSenderKey) // 48 bytes
data.append(envelope.ciphertext) // variable
return data
}
PSK Exchange URI
/// Generates a PSK exchange URI for sharing via QR code or link.
func generatePSKExchangeURI(
address: AlgorandAddress,
psk: Data,
label: String?
) -> String {
let pskBase64URL = psk.base64URLEncodedString() // No padding
var uri = "algochat-psk://v1?addr=\(address)&psk=\(pskBase64URL)"
if let label {
let encoded = label.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? label
uri += "&label=\(encoded)"
}
return uri
}
/// Parses a PSK exchange URI.
func parsePSKExchangeURI(_ uri: String) throws -> PSKContact {
guard uri.hasPrefix("algochat-psk://v1?") else {
throw AlgoChatError.invalidEnvelope
}
let queryString = String(uri.dropFirst("algochat-psk://v1?".count))
let params = parseQueryParameters(queryString)
guard let addrString = params["addr"],
let pskString = params["psk"],
let pskData = Data(base64URLEncoded: pskString),
pskData.count == 32 else {
throw AlgoChatError.invalidEnvelope
}
return PSKContact(
address: AlgorandAddress(addrString),
initialPSK: pskData,
label: params["label"]?.removingPercentEncoding
)
}
UI/UX Guidance
Implementations SHOULD provide the following user experience for PSK mode:
Visual Indicators:
- Display a distinct icon or badge for PSK-protected conversations
- Show the ratchet counter or session number in developer/debug views
- Indicate when a PSK conversation is established vs. standard mode
Setup Flow:
- User generates a PSK for a contact (32 bytes from CSPRNG)
- App encodes PSK as
algochat-psk://URI and displays QR code - Contact scans QR code, app parses URI and stores PSK
- Both apps confirm PSK is established and switch to protocol
0x02
Error UX:
PSK_NOT_FOUND: Prompt user to set up PSK with this contactPSK_COUNTER_OUT_OF_RANGE: Warn about possible desynchronization; offer to reset counter statePSK_COUNTER_REPLAY: Silently discard (do not display to user, but log for debugging)