The KX402 Protocol
A wallet-native, end-to-end encrypted messaging protocol for the Kaspa era. Identity is a wallet address. The server stores only ciphertext.
Abstract
KX402 is a wallet-native encrypted messaging protocol that operates on the Kaspa blockchain. It replaces traditional email infrastructure — email addresses, passwords, and centralized mail servers — with cryptocurrency wallet addresses as identity and browser-side end-to-end encryption.
The server, which may be operated by anyone including Fluxkmail, cannot decrypt message content. Encryption keys are derived locally from cryptographic wallet signatures and never leave the user's browser in plaintext. This paper describes the architecture, key derivation, message flow, and threat model of the protocol as implemented in production.
1. Protocol Overview
A user's identity is their Kaspa wallet address. There is no registry of usernames, no password database, and no recovery server. Authentication and authorization are established through cryptographic signatures.
1.1 Identity Paths
KX402 supports two authentication paths. The primary path relies on a browser extension wallet (Kasware) that signs messages with secp256k1 Schnorr signatures. An alternative path uses a TTT Agent ID from the tttz.xyz identity network, bound to a Kaspa wallet address. Both paths produce a deterministic input from which the user's encryption keys are derived.
1.2 Design Principles
2. Cryptographic Architecture
2.1 Algorithms
| Component | Specification |
|---|---|
| Key Exchange | ECDH on NIST P-256 (secp256r1) |
| Symmetric Cipher | AES-256-GCM (authenticated) |
| KDF | SHA-256 (signature → 256-bit storage key) |
| Encoding | Base64 (storage), UTF-8 (text) |
| IV | 96-bit random, unique per encryption |
2.2 Key Hierarchy
The ECDH public key is stored in plaintext in the user's profile record. The ECDH private key — the secret that enables decryption — is encrypted with a key derived from the wallet signature before being stored:
Wallet Signature (Schnorr / secp256k1)
│
▼
SHA-256
│
▼
Storage Key (AES-256) ──► encrypts ──► ECDH Private Key (P-256, JWK)
│
▼
Stored encrypted on the server3. Key Derivation
3.1 Kasware Identity Path
When a user connects via Kasware, the extension signs a domain-separated message:
"Fluxkmail E2E Encryption Key Derivation v1"
The returned signature is passed through SHA-256 to produce a 256-bit AES-GCM key:
storageKey = await crypto.subtle.importKey(
'raw',
await crypto.subtle.digest('SHA-256', signature),
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
);This storage key encrypts the user's ECDH private key before it is persisted. The private key never leaves the browser in plaintext.
3.2 TTT Identity Path
For TTT users without a hardware wallet, the storage key is derived from their agent credentials:
storageKey = SHA-256("<tttId>:<password>")3.3 First-Time Setup
On first authentication, a new ECDH keypair is generated in the browser:
keyPair = crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true,
['deriveKey', 'deriveBits']
);
// Export both halves
publicKeyJwk = crypto.subtle.exportKey('jwk', keyPair.publicKey); // plaintext
privateKeyJwk = crypto.subtle.exportKey('jwk', keyPair.privateKey);
// Encrypt the private key with the storage key
iv = crypto.getRandomValues(new Uint8Array(12));
encryptedPrivateKey = crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, storageKey,
JSON.stringify(privateKeyJwk)
);The server stores:
- encryption_public_key — ECDH public key (JWK, plaintext)
- encrypted_private_key — private key encrypted under AES-GCM (base64)
- encryption_key_iv — initialization vector (base64)
3.4 Session Unlock
On subsequent page loads — when the in-memory private key has been lost — the user re-signs the derivation message. The signature decrypts the stored private key inside the browser's crypto.subtle. No network round-trip ever exposes the private key.
4. Message Encryption Flow
4.1 Sending
// 1. Resolve recipient's public key
recipientKey = UserProfile.filter({ wallet_address: recipient });
// → encryption_public_key (JWK string)
if (recipientKey exists && sender has private key):
// 2. Derive shared secret via ECDH
sharedSecret = crypto.subtle.deriveBits(
{ name: 'ECDH', public: recipientPublicKey },
senderPrivateKey, 256
);
// 3. Encrypt the message body with AES-GCM
iv = crypto.getRandomValues(new Uint8Array(12));
aesKey = crypto.subtle.importKey('raw', sharedSecret,
{ name: 'AES-GCM' }, false, ['encrypt']);
ciphertext = AES-GCM.encrypt(aesKey, iv, utf8(body));
// 4. Store: only ciphertext, IV, and the counterparty key reach the server
Email.create({
body: base64(ciphertext),
encryption_iv: base64(iv),
is_encrypted: true,
enc_public_key: <counterparty's public key — see table below>
});
else:
// Fallback — recipient has no encryption key set up yet
Email.create({ body: plaintext, is_encrypted: false });Two copies of each message are created — one in the sender's Sent folder and one in the recipient's Inbox. Each copy stores the counterparty's public key as enc_public_key so the reader of that specific copy can re-derive the same ECDH shared secret:
| Copy | Reader | enc_public_key | Derivation |
|---|---|---|---|
| Sent | Sender | Recipient's pubkey | ECDH(sender_priv, recipient_pub) |
| Inbox | Recipient | Sender's pubkey | ECDH(recipient_priv, sender_pub) |
Both derivations yield an identical shared secret — a fundamental property of ECDH — so the same ciphertext decrypts for both parties.
4.2 Reading
// 1. Import the counterparty's public key stored on the email
otherKey = importJWK(email.enc_public_key);
// 2. Re-derive the same shared secret
sharedSecret = ECDH.deriveBits(
{ name: 'ECDH', public: otherKey },
readerPrivateKey, 256
);
// 3. Decrypt
aesKey = AES.import(sharedSecret);
plaintext = AES-GCM.decrypt(aesKey, email.encryption_iv, email.body);Decryption happens entirely inside window.crypto.subtle. The server only delivers ciphertext, the IV, and the counterparty's public key.
5. Privacy Model
5.1 What is stored on the server
| Field | Content | Server-readable? |
|---|---|---|
| subject | Plaintext | Yes |
| body | AES-GCM ciphertext (base64) | No |
| preview | Placeholder only | N/A |
| encryption_iv | 96-bit IV (public by design) | Yes (non-secret) |
| enc_public_key | Public key (JWK) | Yes (non-secret) |
The email subject remains plaintext to enable server-side sorting and search. Only the body is encrypted. Subject-line protection is a planned extension (Section 6.2).
5.2 What is never stored
- The wallet signature (ephemeral — used once per session to unlock)
- The SHA-256-derived storage key
- The ECDH private key (only its encrypted form persists)
- The per-message AES key (derived on the fly, never stored)
5.3 Session forward secrecy
The ECDH private key persists across sessions in its encrypted form. Strict forward secrecy (automatic key erasure) is achieved on sign-out: the in-memory private key is destroyed on page unload, and unlocking requires a fresh wallet signature.
6. Threat Model
6.1 Covered
6.2 Not yet covered (v1)
7. References
- — W3C Web Cryptography APIW3C Recommendation, January 2017
- — NIST SP 800-186Recommendations for Discrete Logarithm-Based Cryptography (P-256)
- — NIST SP 800-38DGalois / Counter Mode (GCM)
- — RFC 7517JSON Web Key (JWK)
- — Kaspa protocolkaspa.org — blockDAG protocol specification