Protocol Specification
A complete description of NeverSaid's cryptographic protocol — a simplified X3DH key agreement with Double Ratchet message encryption and HMAC-only authentication. Designed for offline deniability against an adversary who is a participant in the conversation.
§1 — Threat Model
Adversary definition
The adversary is a participant in the chat who has the NeverSaid extension installed. They can decrypt every message in real time, inspect protocol state from their side, and attempt to produce evidence of authorship for a third party (a court, a journalist, an employer). They may also collude with Microsoft to correlate ciphertext blobs with metadata.
The goal is not to prevent the adversary from reading messages — they are a legitimate participant. The goal is to ensure they cannot produce cryptographic proof of authorship that a third party can independently verify.
Guarantees and limits
Content confidentiality: Messages are encrypted with AES-256-GCM using keys derived from X25519 Diffie-Hellman. Microsoft observes only opaque ciphertext.
Offline deniability: After a conversation ends, a simulator with access only to public keys can fabricate a transcript indistinguishable from a real one. No digital signatures exist in the message flow. Authentication uses HMAC from shared symmetric keys — both parties possess the key and either could have authored any message.
Forward secrecy: The DH ratchet ensures that compromising current key material does not expose past messages. Old chain keys are deleted after use.
Metadata is not protected. Microsoft retains full metadata: who communicates with whom, when, message sizes, typing indicators, read receipts, presence, and device information. NeverSaid operates at the content layer only.
Screenshots, screen recordings, physical observation, memory dumps via DevTools, compromised operating systems, and enterprise admin policies (Intune extension blocking) are outside the protocol's scope. Cryptography protects bits, not eyeballs.
§2 — Key Types
| Key | Algorithm | Purpose | Lifetime |
|---|---|---|---|
| Identity Key (IK) | X25519 | Long-term DH identity | Years |
| Signing Key | Ed25519 | Signs prekey bundles only | Same as IK |
| Signed PreKey (SPK) | X25519 | Medium-term DH prekey | Monthly rotation |
| One-Time PreKey (OPK) | X25519 | Single-use forward secrecy | Consumed on use |
| Ephemeral Key (EK) | X25519 | Per-session DH ratchet seed | Single use |
| Ratchet Key | X25519 | DH ratchet step | Per ratchet turn |
The Ed25519 signing key is used only to sign prekey bundles during distribution — proving a prekey was published by the identity holder. It is never used to sign messages. This is the critical design choice that enables deniability.
§3 — Session Establishment
Session establishment uses a simplified X3DH (Extended Triple Diffie-Hellman) key agreement. Alice initiates a session with Bob, who may be offline. Alice fetches Bob's prekey bundle from the key server and computes a shared secret without any interactive round trips.
DH computations
Alice Key Server Bob │ │ │ │──── GET /api/v1/prekeys/bob@company.com ──────────►│ │ │◄─── { IK_B, SPK_B, SPK_sig, OPK_B } ────────────│ │ │ │ │ │ verify Ed25519(SPK_sig, IK_B.signing_key) │ │ │ generate ephemeral EK_A │ │ │ │ │ │ DH1 = X25519(IK_A.priv, SPK_B.pub) │ │ │ DH2 = X25519(EK_A.priv, IK_B.pub) │ │ │ DH3 = X25519(EK_A.priv, SPK_B.pub) │ │ │ DH4 = X25519(EK_A.priv, OPK_B.pub) (if available) │ │ │ │ │ │ SK = HKDF(DH1‖DH2‖DH3‖DH4) │ │ │ │ │ │──── PreKeyMessage { IK_A, EK_A, SPK_id, OPK_id, ... } ──────────────────────►│ │ │ Bob recomputes SK │ │ │ from his private │ │ │ keys + Alice's │ │ │ public values │
The shared secret SK is derived via HKDF:
SK = HKDF-SHA256( salt = 0x00 * 32, ikm = DH1 ‖ DH2 ‖ DH3 ‖ DH4, info = "NeverSaid_v1", len = 32 ) // Initial chain keys sending_chain = HKDF(SK, "ns_send", len=32) receiving_chain = HKDF(SK, "ns_recv", len=32)
If no one-time prekey is available (OPK pool exhausted), DH4 is omitted. The
protocol remains secure but loses one layer of forward secrecy for the initial message.
The key server should alert clients when OPK supplies run low.
Deniability property
No signatures appear anywhere in the X3DH flow. The shared secret SK is
computable by both Alice and Bob. A third-party simulator with knowledge of both
public identity keys can fabricate all ephemeral DH values and produce a session
transcript that is computationally indistinguishable from a real one.
This provides offline deniability as defined by Di Raimondo, Gennaro, and Krawczyk (2005).
Online deniability requires the simulator to forge transcripts while the session is in progress, without knowledge of private keys. Vatandas et al. (2020) proved this is impossible in the async X3DH setting. NeverSaid does not claim online deniability.
§4 — Double Ratchet
After session establishment, message keys are derived via two interlocking ratchets: a symmetric ratchet (KDF chain) for per-message keys, and a DH ratchet for periodic forward secrecy renewal.
Symmetric ratchet (KDF chain)
Each message derives a unique key from the current chain key. After derivation, the old chain key is replaced and the message key is deleted after encryption.
message_key = HMAC-SHA256(chain_key, 0x01) next_chain_key = HMAC-SHA256(chain_key, 0x02) // chain_key is replaced with next_chain_key // message_key is deleted after encryption // old chain_key is destroyed
This provides forward secrecy within a chain: if the current chain key is compromised at step N, messages 0 through N−1 remain secure because their message keys were derived from prior chain states that no longer exist.
DH ratchet (inter-chain forward secrecy)
When a party sends a message, they include a fresh X25519 ephemeral public key. When the other party responds, they perform DH with this new key and mix the result into the root key, creating a new sending chain.
// Bob receives Alice's new ratchet public key dh_secret = X25519(bob_ratchet.priv, alice_new_ratchet.pub) // Derive new root key and chain key root_key, recv_chain = HKDF( salt = root_key, ikm = dh_secret, info = "ns_ratchet", len = 64 // split: first 32 = root, second 32 = chain ) // Bob generates his own new ratchet keypair for reply bob_ratchet = X25519.generateKeyPair() _, send_chain = HKDF( salt = root_key, ikm = X25519(bob_ratchet.priv, alice_new_ratchet.pub), info = "ns_ratchet", len = 64 ) // Old root key and old ratchet private keys are destroyed
This provides post-compromise security: after one DH ratchet step, a previously compromised session self-heals because the adversary cannot compute the new DH secret without the fresh ephemeral private key (which was generated after the compromise).
§5 — Message Encryption
Each message is encrypted with AES-256-GCM using the derived message key. The GCM authentication tag provides integrity — but because the key is derived from a shared DH secret, both parties can produce valid ciphertexts, preserving deniability.
nonce = HKDF(message_key, "ns_nonce", len=12) associated_data = version ‖ sender_identity_key ‖ recipient_identity_key ciphertext = AES-256-GCM( key = message_key, nonce = nonce, pt = pad(plaintext), aad = associated_data ) // message_key deleted immediately after encryption
Message padding
All plaintext is padded to the nearest 256-byte boundary before encryption to resist traffic analysis via message length correlation. Padding uses PKCS#7: append N bytes each with value N, where N is the number of bytes needed to reach the next 256-byte boundary. Minimum padding is 1 byte; maximum is 256 bytes.
function pad(plaintext) { const block = 256 const padLen = block - (plaintext.length % block) || block return concat(plaintext, new Uint8Array(padLen).fill(padLen)) }
§6 — Wire Format
PreKeyMessage (session initiation)
Sent when Alice initiates a new session with Bob.
Message (normal)
All subsequent messages within an established session.
Teams envelope
The serialized message is base64url-encoded and wrapped in a recognizable envelope that is pasted into the Teams compose box:
🔒 This message is encrypted with NeverSaid. ━━━━━━━━━━━━━━━━━━━━ neversaid://v1/<base64url-encoded-message> ━━━━━━━━━━━━━━━━━━━━ Get NeverSaid → neversaid.eu
The neversaid://v1/ prefix allows the extension's MutationObserver to
efficiently identify encrypted messages in the DOM without false positives. The
human-readable wrapper instructs non-extension users how to decrypt.
§7 — Key Server
The key server at keys.neversaid.eu provides key discovery and prekey
distribution. It never sees plaintext messages. It is a minimal REST API backed by
SQLite and authenticated via email verification.
| Endpoint | Method | Purpose |
|---|---|---|
| /api/v1/keys | POST | Publish identity key + prekey bundle |
| /api/v1/keys/verify | POST | Request email verification |
| /api/v1/keys/by-email/:email | GET | Lookup key bundle by email |
| /api/v1/prekeys/:email | GET | Fetch prekeys (consumes one OPK) |
| /api/v1/keys/:fingerprint | PUT | Rotate keys (signed by IK) |
| /api/v1/keys/:fingerprint | DELETE | Revoke key (signed by IK) |
Key fingerprints are JWK Thumbprints (RFC 7638) using SHA-256, base64url-encoded.
One-time prekeys are consumed on fetch: the server returns one OPK and marks it as
used. Clients should maintain a pool of 50 OPKs and replenish when the server
indicates the supply is low (via an X-OPK-Remaining response header).
| Endpoint | Rate limit |
|---|---|
| /api/v1/keys/by-email | 60 req/min per IP |
| /api/v1/prekeys | 120 req/min per IP |
| /api/v1/keys (POST) | 5 req/hour per IP |
| /api/v1/keys/verify | 3 emails/hour per address |
§8 — Trust Model
NeverSaid uses enhanced Trust-On-First-Use (TOFU) with optional out-of-band verification, following a three-tier model designed for progressive trust.
| Tier | Mechanism | Behaviour on key change |
|---|---|---|
| Tier 1 — TOFU | First key seen for email, post email-verification | Advisory notice (non-blocking) |
| Tier 2 — Safety code | 60-digit numeric code verified out-of-band | Blocking alert, re-verification required |
| Tier 3 — Log | Append-only transparency log (future v2) | Cryptographic proof of key history |
Safety code derivation: The 60-digit numeric code is derived from both parties' identity keys and presented as 12 groups of 5 digits for readability.
hash = HKDF-SHA256( ikm = sort(IK_A.pub ‖ IK_B.pub), // deterministic ordering salt = 0x00 * 32, info = "ns_safety_code", len = 30 ) // Convert to decimal digits, format as 12 groups of 5 code = "38291 04827 19384 50172 ..."
§9 — Group Chats
Group encryption uses a hybrid approach based on group size, following the principle that deniable authentication in groups inherently requires pairwise operations.
| Group size | Approach | Deniability | PCS |
|---|---|---|---|
| 2–10 | Pairwise Double Ratchet | Full | Full |
| 11–50 | Sender Keys | Within group | On re-key only |
| 50+ | Not supported (v1) | — | — |
Pairwise mode (≤ 10): The sender encrypts the message N−1 times, once per pairwise ratchet session. Each session has independent forward secrecy, post-compromise security, and deniability. The O(N) cost is negligible for small groups.
Sender Keys mode (11–50): Each member generates a Sender Key (symmetric chain key + X25519 authentication keypair) and distributes it to all others via pairwise channels. Messages are encrypted once with the Sender Key's derived message key. This sacrifices post-compromise security within a Sender Key epoch but scales to O(1) per message.
Member departure triggers re-keying: When a member leaves, all remaining members generate fresh Sender Keys and redistribute pairwise. The departed member cannot decrypt subsequent messages.
If N−1 members of an N-person group collude, they can attribute messages to the remaining member by elimination. This is a fundamental limitation of group deniability and is not specific to NeverSaid.
§10 — Transcript Forgery Tool
NeverSaid ships a built-in transcript forgery tool that makes deniability concrete and demonstrable. The tool generates cryptographically valid conversation transcripts between any two public key holders with arbitrary message content.
The forgery process exploits the core deniability property: since session establishment uses only DH operations (no signatures), anyone with knowledge of two public identity keys can simulate a complete session.
function forgeTranscript(IK_A_pub, IK_B_pub, messages[]) { // Generate fake ephemeral keys for session init fake_EK = X25519.generateKeyPair() fake_SPK = X25519.generateKeyPair() fake_OPK = X25519.generateKeyPair() // Derive a plausible (but fabricated) shared secret fake_SK = HKDF( random(128), // stand-in for DH1‖DH2‖DH3‖DH4 "NeverSaid_v1" ) // Run the ratchet forward, encrypting each fake message for (msg of messages) { ciphertext = encryptWithRatchet(fake_SK, msg.text) emit formatEnvelope(ciphertext, msg.timestamp) } // Output: valid neversaid://v1/... blobs // Indistinguishable from real transcripts // without knowledge of private keys }
The resulting transcript is structurally identical to a real NeverSaid conversation. A forensic examiner cannot distinguish forged ciphertext from authentic ciphertext without access to the participants' private identity keys — which neither the adversary nor the examiner possesses.
If anyone can trivially produce "evidence" of any conversation, then evidence of a conversation proves nothing. The forgery tool transforms deniability from an abstract mathematical property into a demonstrable, courtroom-ready argument.
§11 — Honest Limitations
Transparency is a security property. NeverSaid's documentation and settings page explicitly communicate what the protocol cannot do.
| Attack vector | Mitigated? | Notes |
|---|---|---|
| Message content interception | ✓ Yes | AES-256-GCM with per-message keys |
| Authorship attribution | ✓ Yes | HMAC-only auth, offline deniability |
| Retroactive decryption | ✓ Yes | Forward secrecy via DH ratchet |
| Key compromise recovery | ✓ Yes | Post-compromise security via DH ratchet |
| Metadata (who, when, size) | ✗ No | Microsoft retains full metadata |
| Screenshots / recording | ✗ No | Outside cryptographic scope |
| Browser memory inspection | ✗ No | DevTools can read decrypted content |
| Compromised OS / keylogger | ✗ No | Applies to all software encryption |
| Enterprise admin blocking | ✗ No | Intune can block extension by ID |
| Hardware attestation | ✗ No | SGX/TrustZone can defeat deniability |
NeverSaid protects content at the cryptographic layer. It does not attempt to solve metadata leakage (use Tor), physical observation (use a private room), or compromised endpoints (use a trusted device). Each layer of defense has its own tools. NeverSaid is one layer, not all of them.