Build an encrypted messaging app
End-to-end encrypted messages between Tenzro accounts with a hybrid X25519 and ML-KEM-768 key exchange, keys derived from each user's passkey and bound to their account.
This tutorial builds end-to-end encrypted messaging between two Tenzro passkey accounts. The relay that carries the messages sees only ciphertext, and the key exchange is hybrid: a classical X25519 exchange and a post-quantum ML-KEM-768 encapsulation are combined, so an attacker must break both to read a message. The same construction keys the channels between Network 1 validators.
Nothing is stored on the device. Each user's messaging keys are derived from their passkey on demand, and their public keys are published in a key bundle that their passkey signs. Anyone can check that signature against the account on Network 1.
Prerequisites
- Two passkey accounts built as in Build a passkey wallet, with its helpers (
client,hex,fromHex,assertWithPrf,hybridAssert). - A relay: any HTTPS endpoint that stores and forwards opaque blobs. The example uses
https://relay.example.com.
npm install @noble/curves @noble/post-quantum @noble/hashes1. Derive the messaging keys from the passkey
Use the PRF extension with a salt dedicated to messaging, so these keys are independent of the signing keys. HKDF splits the PRF output into an X25519 secret and a 64-byte ML-KEM-768 seed.
import { x25519 } from "@noble/curves/ed25519.js";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
const MSG_SALT = sha256(new TextEncoder().encode("my-app/messaging/v1"));
export async function messagingKeys(credentialId: Uint8Array) {
const { prf } = await assertWithPrf(
credentialId,
crypto.getRandomValues(new Uint8Array(32)),
MSG_SALT,
);
const ikm = new Uint8Array(prf);
const xSecret = hkdf(sha256, ikm, undefined, "x25519", 32);
const kemSeed = hkdf(sha256, ikm, undefined, "ml-kem-768", 64);
ikm.fill(0);
const kem = ml_kem768.keygen(kemSeed);
kemSeed.fill(0);
return {
xSecret,
xPublic: x25519.getPublicKey(xSecret),
kemSecret: kem.secretKey,
kemPublic: kem.publicKey, // 1184 bytes
wipe() {
xSecret.fill(0);
kem.secretKey.fill(0);
},
};
}assertWithPrf here is the helper from the passkey wallet tutorial with the salt made a parameter. The same passkey and salt always give the same keys, so a user can read their messages in any session without anything saved to disk.
Keys are per passkey. A user with several linked devices publishes one bundle per device, and senders encrypt each message to every bundle on the recipient's account.
2. Publish a signed key bundle
The bundle holds the two public keys. The user's passkey signs its hash with both legs (P-256 and ML-DSA-65), which binds the keys to the account.
import { sha256 } from "@noble/hashes/sha2.js";
export async function publishBundle(account: string, credentialId: Uint8Array) {
const k = await messagingKeys(credentialId);
const keys = { account, x25519: hex(k.xPublic), mlkem768: hex(k.kemPublic) };
k.wipe();
const digest = sha256(new TextEncoder().encode(JSON.stringify(keys)));
const proof = await hybridAssert(credentialId, digest);
const bundle = {
keys,
credential_id_hex: hex(credentialId),
assertion: proof.assertion,
ml_dsa_signature_hex: proof.mlDsaSignatureHex,
};
await fetch(`https://relay.example.com/bundles/${account}/${hex(credentialId)}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(bundle),
});
}3. Fetch and verify the recipient's bundle
The sender fetches the bundle and asks the network whether the recipient account's enrolled passkey really signed it. tenzro_signWithPasskey checks both legs against the credentials enrolled on that account; it does not move funds.
export async function trustedKeys(recipient: string, device: string) {
const url = `https://relay.example.com/bundles/${recipient}/${device}`;
const b = await (await fetch(url)).json();
if (b.keys.account.toLowerCase() !== recipient.toLowerCase()) {
throw new Error("bundle is for a different account");
}
const digest = sha256(new TextEncoder().encode(JSON.stringify(b.keys)));
const check = await client.passkeyRpc.sign({
account_address: recipient,
op_hash_hex: hex(digest),
credential_id_hex: b.credential_id_hex,
assertion: b.assertion,
ml_dsa_signature_hex: b.ml_dsa_signature_hex,
});
if (!check.verified) throw new Error("key bundle signature did not verify");
return { x25519: fromHex(b.keys.x25519), mlkem768: fromHex(b.keys.mlkem768) };
}Expected result of the check:
{ "verified": true, "validator": "0x...", "op_hash_hex": "..." }A relay that swaps in its own keys cannot produce this signature, so a tampered bundle is rejected before any message is encrypted to it.
4. Encrypt with the hybrid key exchange
For each message, generate an ephemeral X25519 key, encapsulate to the recipient's ML-KEM-768 key, and derive the message key from both shared secrets plus the full transcript. Encrypt with AES-256-GCM.
import { x25519 } from "@noble/curves/ed25519.js";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
const concat = (...a: Uint8Array[]) => {
const out = new Uint8Array(a.reduce((n, x) => n + x.length, 0));
let o = 0;
for (const x of a) { out.set(x, o); o += x.length; }
return out;
};
async function aesKey(raw: Uint8Array) {
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
}
export async function seal(
to: { x25519: Uint8Array; mlkem768: Uint8Array },
plaintext: string,
) {
const eph = crypto.getRandomValues(new Uint8Array(32));
const ephPub = x25519.getPublicKey(eph);
const ssX = x25519.getSharedSecret(eph, to.x25519);
const { cipherText: kemCt, sharedSecret: ssK } = ml_kem768.encapsulate(to.mlkem768);
eph.fill(0);
const info = concat(ephPub, kemCt, to.x25519, to.mlkem768);
const key = hkdf(sha256, concat(ssX, ssK), undefined, info, 32);
ssX.fill(0);
ssK.fill(0);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await aesKey(key),
new TextEncoder().encode(plaintext)),
);
key.fill(0);
return { v: 1, alg: "x25519+mlkem768+aes256gcm", ephPub: hex(ephPub),
kemCt: hex(kemCt), iv: hex(iv), ct: hex(ct) };
}Binding the ephemeral key, the KEM ciphertext and both recipient keys into the derivation means the envelope cannot be re-targeted or spliced.
5. Send through the relay
const to = await trustedKeys(BOB_ACCOUNT, BOB_DEVICE); // one call per device bundle
const envelope = await seal(to, "The training run finished. Results attached.");
await fetch(`https://relay.example.com/inbox/${BOB_ACCOUNT}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ from: ALICE_ACCOUNT, envelope }),
});The relay stores the envelope. It never sees a key or a plaintext.
6. Decrypt on the recipient's device
The recipient touches their passkey once to derive the messaging keys, opens every pending envelope, and wipes the keys.
type Envelope = Awaited<ReturnType<typeof seal>>;
export async function open(credentialId: Uint8Array, e: Envelope) {
const k = await messagingKeys(credentialId);
try {
const ephPub = fromHex(e.ephPub);
const kemCt = fromHex(e.kemCt);
const ssX = x25519.getSharedSecret(k.xSecret, ephPub);
const ssK = ml_kem768.decapsulate(kemCt, k.kemSecret);
const info = concat(ephPub, kemCt, k.xPublic, k.kemPublic);
const key = hkdf(sha256, concat(ssX, ssK), undefined, info, 32);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromHex(e.iv) }, await aesKey(key), fromHex(e.ct));
key.fill(0);
return new TextDecoder().decode(pt);
} finally {
k.wipe();
}
}Expected output:
The training run finished. Results attached.If the envelope was altered, AES-GCM authentication fails and decrypt throws.
7. Authenticate the sender (optional)
Encryption hides the content; it does not prove who sent it. To prove authorship, have the sender sign the SHA-256 of the envelope with hybridAssert and attach the result, and have the recipient check it with client.passkeyRpc.sign against the sender's account, exactly as in step 3.
Next steps
- Read the design notes: Secure messaging and Cryptography.
- Send messages between agents over A2A: A2A protocol.
- Link more devices, each with its own bundle: Link a device and set up recovery.