Protocol · version 1

Sealed, signed, settled.

Everything below is what the client and the server actually do — the same functions run in the browser, in the route handlers and in the test suite. Nothing here requires trusting the server for confidentiality or authenticity; it is trusted for delivery and for being honest about its own storage.

01

Keys from a signature

A wallet unlocks its vault by signing a fixed text with personal_sign. Ethereum wallets sign deterministically (RFC 6979), so the same wallet always produces the same bytes. The signature is hashed into a seed and expanded with HKDF into two keys:

seed      = SHA-256("vaultmail/seed/v1" || signature)
sealPriv  = HKDF-SHA256(seed, salt="vaultmail", info="seal/x25519/v1", 32)   → x25519
signSeed  = HKDF-SHA256(seed, salt="vaultmail", info="sign/ed25519/v1", 32)  → ed25519

The keys live in the tab's memory and are never stored or sent. Closing the tab locks the vault; signing again reopens the same one. The unlock signature is the secret: the text says so, and it should never be signed anywhere but here.

02

The registry

The first time, the wallet also signs a registration naming its two public keys. The server stores it and serves it to anyone: GET /api/keys/0x…. Before sealing to a recipient, a client can verify that record with nothing but the wallet address — the server cannot forge or swap a key without the wallet's private key.

{ v: 1, address, sealPub, signPub, walletSig }   // walletSig = personal_sign(registrationMessage(address, sealPub, signPub))

A wallet whose signatures are not deterministic (some hardware and smart-contract wallets) derives different keys on a later day. The client compares them with the registry and says so; republishing is the user's explicit choice, and older mail sealed to the previous key will not open.

03

Envelopes

An envelope is a cleartext header, a sealed body, one wrapped content key per reader, and the sender's signature over all of it.

header  = { v: 1, id, from, to, createdAt }              // id: 16 random bytes, chosen by the sender
K       = 32 random bytes
ct      = XChaCha20-Poly1305(K, nonce, JSON(payload), aad = canonical(header))
for R in { to, from }:
  eph      = x25519 keypair
  shared   = x25519(eph.priv, R.sealPub)
  wrapKey  = HKDF-SHA256(shared, salt = eph.pub || R.sealPub, info = "vaultmail/wrap/v1", 32)
  keys[R]  = { eph: eph.pub, nonce, box: XChaCha20-Poly1305(wrapKey, nonce, K, aad) }
sig     = Ed25519(signSeed, SHA-256(canonical({ ...header, sealed: true, nonce, ct, keys })))

The associated data binds the ciphertext to its header: change from, to, id or the date and the envelope no longer decrypts. The signature binds the sender: the server checks it against the sender's registered sign key before storing, and the reader checks it again before trusting who wrote it.

Sealed: subject, body, kind, amount, token, due date, line items, invoice number, and which envelope a reply answers. In the clear: the two addresses, the timestamp, the flag, and the receipts.

When the recipient has no vault, there is no key to seal to. The sender is told, and may send the envelope unsealed: same header, same signature, the payload in plain JSON, sealed: false. The server refuses an unsealed envelope to a wallet that has a vault, so a conversation is never quietly downgraded.

04

Authenticated requests

Reading an inbox, sending, marking read and attaching receipts are signed with the vault's ed25519 key — no session, no cookie, no server secret. The server looks the key up by address and verifies; the same request verifies on any instance that has the registry.

x-vault-address  the wallet
x-vault-ts       unix ms, within ±5 minutes of the server clock
x-vault-nonce    8 random bytes
x-vault-sig      Ed25519(signSeed, SHA-256("METHOD\npath?query\nts\nnonce\nSHA-256(body)"))

Replaying a captured request inside the window re-does something idempotent: reads, mark-as-read, or an insert keyed by the envelope's own id.

05

Payments and receipts

An invoice or a request is paid with an ordinary transfer on Robinhood Chain — native currency or an ERC-20 transfer — from the recipient's wallet straight to the sender's. No contract of ours sits in between, no fee is taken, nothing is held.

The payer then hands the transaction hash to POST /api/messages/:id/receipts. The server reads the transaction and its receipt from its own RPC and records it only if it succeeded, was sent by the envelope's recipient and moved value to the envelope's sender. One transaction pays one envelope.

The requested amount is sealed, so the server never compares it; it records what was paid, and each side's client — which can open the envelope — shows paid in full, partly paid, or due.

06

Limits and what is not claimed

Payload up to 32 KB, subject 140 characters, body 8,000, 40 line items. The server sees who writes to whom and when — this is mail, not a mixnet. It can refuse to deliver, or lose what it holds; it cannot read, forge or re-address. Vault keys are only as safe as the device that derives them and the wallet that signs.

Storage is the deployment's: /api/health says whether it survives a restart.