Each message has a fresh random 256-bit content key used with ChaCha20-Poly1305. How that key reaches the recipient depends on their key type:
| Recipient key | How the content key is shared | Sender signature |
|---|---|---|
ssh-ed25519 | X25519 ECDH with an ephemeral key (Edwards → Montgomery mapping, as age does), then HKDF-SHA256 | Ed25519 |
ecdsa-sha2-nistp256/384/521 | ECDH on the key’s own curve with an ephemeral key; the peer’s point is validated; then HKDF-SHA256 | ECDSA (SHA-256/384/512, RFC 6979 nonces) |
ssh-rsa ≥ 2048 bits | RSA-OAEP (SHA-256, MGF1-SHA-256) wraps the content key | rsa-sha2-512 |
The envelope
envelope := "WHSP" ‖ 0x01 ‖ string(kem) ‖ string(kem_data) ‖ nonce[12] ‖ ciphertext ‖ tag[16]
kem := "x25519" | "ecdh-nistp256" | "ecdh-nistp384" | "ecdh-nistp521" | "rsa-oaep-sha256"
payload := string(sender public key, SSH wire format) ‖ uint64(time_ms)
‖ string(SSH signature blob) ‖ string(utf8 text) ‖ [string(utf8 sender name)]
‖ zero padding to a multiple of 64 bytes
string(x) is the SSH wire encoding: a big-endian uint32 length followed by
the bytes.
- Authenticated as a whole. Everything up to and including the nonce is the AEAD’s associated data, so the header can’t be altered.
- Signed inside. The sender’s SSH signature covers the recipient’s key, the key-exchange data, the timestamp and the text. That binds the message to one recipient and one ciphertext: it can’t be decrypted and re-encrypted to someone else as if the sender had written it to them.
- Recipient not named. Nothing in the envelope says who it’s for; only the right private key can open it.
- Padded. The payload is padded to a multiple of 64 bytes to hide the exact length of the text.
The sender name
The optional sender name (at most 80 characters) lets someone who hasn’t added you yet reply without an invite back. Older readers stop after the text, and when there’s no name the zero padding reads as an empty one. Like the name on an invite, it is only what the sender says: the encryption protects it on the way, but the signature does not cover it. The ✓ in the UI comes from matching the signing key against the address book, never from the name.
Encodings
The same envelope is shown either as a link (base64url in the fragment) or as an armored block for mail apps that mangle links:
-----BEGIN WHISPER MESSAGE-----
V0hTUAEAAAAGeDI1NTE5AAAAIEYWU0ucvl2lPUeB5YcqkA2nt2F2FP/6HsBU/D5U
…
-----END WHISPER MESSAGE-----
Emails carry both; texts carry just the link.
Sizes
Measured when sender and recipient use the same key type:
| Keys | Empty message | Link, empty | Link, 100-character message |
|---|---|---|---|
| ed25519 | 271 bytes | 374 chars | 459 chars |
| ECDSA P-256 | 375 bytes | 512 chars | 683 chars |
| ECDSA P-521 | 571 bytes | 774 chars | 944 chars |
| RSA 2048 | 888 bytes | 1,196 chars | 1,367 chars |
A sender name adds its length plus 4 bytes before padding, so usually at most one more 64-byte block. There is no hard limit, but SMS and some chat apps truncate long links — which is why ed25519 is the default.