quoting naddr1qvâŚhejcNIP-117
The Double Ratchet Algorithm
The Double Ratchet is a key rotation algorithm for secure private messaging.
It allows us to 1) communicate on Nostr without revealing metadata (who you are communicating with and when), and 2) keep your message history and future messages safe even if your main Nostr key is compromised.
Additionally, it enables disappearing messages that become undecryptable when past message decryption keys are discarded after use.
See also: NIP-118: Nostr Double Ratchet Invites
Overview
âDouble ratchetâ means we use 2 âratchetsâ: cryptographic functions that can be rotated forward, but not backward: current keys can be used to derive next keys, but not the other way around.
Ratchet 1 uses Diffie-Hellman (DH) shared secrets and is rotated each time the other participant acknowledges a new key we have sent along with a previous message.
Ratchet 2 generates encryption keys for each message. It rotates after every message, using the previous messageâs key as input (and the Ratchet 1 key when it rotates). This process ensures forward secrecy for consecutive messages from the same sender in between Ratchet 1 rotations.
Nostr implementation
We implement the Double Ratchet Algorithm on Nostr similarly to Signalâs Double Ratchet with header encryption, but encrypting the message headers with NIP-44 conversation keys instead of symmetric header keys.
Ratchet 1 keys are standard Nostr keys. In addition to encryption, they are also used for publishing and subscribing to messages on Nostr. As they are rotated and not linked to public Nostr identities, metadata privacy is preserved.
Nostr event format
Message
Outer event
{ kind: 1060, content: encryptedInnerEvent, tags: [["header", encryptedHeader]], pubkey: ratchetPublicKey, created_at, id, sig }
We subscribe to Double Ratchet events based on author public keys which are ephemeral â not used for other purposes than the Double Ratchet session. We use the regular event kind
1060
to differentiate it from other DM kinds, retrieval of which may be restricted by relays.The encrypted header contains our next nostr public key, our previous sending chain length and the current message number.
Inner event
Inner events must be NIP-59 Rumors (unsigned Nostr events) allowing plausible deniability.
With established Nostr event kinds, clients can implement all kinds of features, such as replies, reactions, and encrypted file sharing in private messages.
Direct message and encrypted file messages are defined in NIP-17.
Algorithm
Signalâs Double Ratchet with header encryption document is a comprehensive description and explanation of the algorithm.
In this NIP, the algorithm is only described in code, in order to highlight differences to the Signal implementation.
External functions
We use the following Nostr functions (NIP-01):
generateSecretKey()
for creating Nostr private keysfinalizeEvent(partialEvent, secretKey)
for creating valid Nostr events with pubkey, id and signatureWe use NIP-44 functions for encryption:
nip44.encrypt
nip44.decrypt
nip44.getConversationKey
- createRumor
Key derivation function:
export function kdf( input1: Uint8Array, input2: Uint8Array = new Uint8Array(32), numOutputs: number = 1 ): Uint8Array[] { const prk = hkdf_extract(sha256, input1, input2); const outputs: Uint8Array[] = []; for (let i = 1; i <= numOutputs; i++) { outputs.push(hkdf_expand(sha256, prk, new Uint8Array([i]), 32)); } return outputs; }
Session state
With this information you can start or continue a Double Ratchet session. Save it locally after each sent and received message.
interface SessionState { theirCurrentNostrPublicKey?: string; theirNextNostrPublicKey: string; ourCurrentNostrKey?: KeyPair; ourNextNostrKey: KeyPair; rootKey: Uint8Array; receivingChainKey?: Uint8Array; sendingChainKey?: Uint8Array; sendingChainMessageNumber: number; receivingChainMessageNumber: number; previousSendingChainMessageCount: number; // Cache of message & header keys for handling out-of-order messages // Indexed by Nostr public key, which you can use to resubscribe to unreceived messages skippedKeys: { [pubKey: string]: { headerKeys: Uint8Array[]; messageKeys: { [msgIndex: number]: Uint8Array }; }; }; }
Initialization
Alice is the chat initiator and Bob is the recipient. Ephemeral keys were exchanged earlier.
static initAlice( theirEphemeralPublicKey: string, ourEphemeralNostrKey: KeyPair, sharedSecret: Uint8Array ) { // Generate ephemeral key for the next ratchet step const ourNextNostrKey = generateSecretKey(); // Use ephemeral ECDH to derive rootKey and sendingChainKey const [rootKey, sendingChainKey] = kdf( sharedSecret, nip44.getConversationKey(ourEphemeralNostrKey.private, theirEphemeralPublicKey), 2 ); return { rootKey, theirNextNostrPublicKey: theirEphemeralPublicKey, ourCurrentNostrKey: ourEphemeralNostrKey, ourNextNostrKey, receivingChainKey: undefined, sendingChainKey, sendingChainMessageNumber: 0, receivingChainMessageNumber: 0, previousSendingChainMessageCount: 0, skippedKeys: {}, }; } static initBob( theirEphemeralPublicKey: string, ourEphemeralNostrKey: KeyPair, sharedSecret: Uint8Array ) { return { rootKey: sharedSecret, theirNextNostrPublicKey: theirEphemeralPublicKey, // Bob has no âcurrentâ key at init time â Alice will send to next and trigger a ratchet step ourCurrentNostrKey: undefined, ourNextNostrKey: ourEphemeralNostrKey, receivingChainKey: undefined, sendingChainKey: undefined, sendingChainMessageNumber: 0, receivingChainMessageNumber: 0, previousSendingChainMessageCount: 0, skippedKeys: {}, }; }
Sending messages
sendEvent(event: Partial<UnsignedEvent>) { const innerEvent = nip59.createRumor(event) const [header, encryptedData] = this.ratchetEncrypt(JSON.stringify(innerEvent)); const conversationKey = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.theirNextNostrPublicKey); const encryptedHeader = nip44.encrypt(JSON.stringify(header), conversationKey); const outerEvent = finalizeEvent({ content: encryptedData, kind: MESSAGE_EVENT_KIND, tags: [["header", encryptedHeader]], created_at: Math.floor(now / 1000) }, this.state.ourCurrentNostrKey.privateKey); // Publish outerEvent on Nostr, store inner locally if needed return {outerEvent, innerEvent}; } ratchetEncrypt(plaintext: string): [Header, string] { // Rotate sending chain key const [newSendingChainKey, messageKey] = kdf(this.state.sendingChainKey!, new Uint8Array([1]), 2); this.state.sendingChainKey = newSendingChainKey; const header: Header = { number: this.state.sendingChainMessageNumber++, nextPublicKey: this.state.ourNextNostrKey.publicKey, previousChainLength: this.state.previousSendingChainMessageCount }; return [header, nip44.encrypt(plaintext, messageKey)]; }
Receiving messages
handleNostrEvent(e: NostrEvent) { const [header, shouldRatchet, isSkipped] = this.decryptHeader(e); if (!isSkipped) { if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) { // Received a new key from them this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey; this.state.theirNextNostrPublicKey = header.nextPublicKey; this.updateNostrSubscriptions() } if (shouldRatchet) { this.skipMessageKeys(header.previousChainLength, e.pubkey); this.ratchetStep(header.nextPublicKey); } } decryptHeader(event: any): [Header, boolean, boolean] { const encryptedHeader = event.tags[0][1]; if (this.state.ourCurrentNostrKey) { const conversationKey = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, event.pubkey); try { const header = JSON.parse(nip44.decrypt(encryptedHeader, conversationKey)) as Header; return [header, false, false]; } catch (error) { // Decryption with currentSecret failed, try with nextSecret } } const nextConversationKey = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, event.pubkey); try { const header = JSON.parse(nip44.decrypt(encryptedHeader, nextConversationKey)) as Header; return [header, true, false]; } catch (error) { // Decryption with nextSecret also failed } const skippedKeys = this.state.skippedKeys[event.pubkey]; if (skippedKeys?.headerKeys) { // Try skipped header keys for (const key of skippedKeys.headerKeys) { try { const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header; return [header, false, true]; } catch (error) { // Decryption failed, try next secret } } } throw new Error("Failed to decrypt header with current and skipped header keys"); } ratchetDecrypt(header: Header, ciphertext: string, nostrSender: string): string { const plaintext = this.trySkippedMessageKeys(header, ciphertext, nostrSender); if (plaintext) return plaintext; this.skipMessageKeys(header.number, nostrSender); // Rotate receiving key const [newReceivingChainKey, messageKey] = kdf(this.state.receivingChainKey!, new Uint8Array([1]), 2); this.state.receivingChainKey = newReceivingChainKey; this.state.receivingChainMessageNumber++; return nip44.decrypt(ciphertext, messageKey); } ratchetStep(theirNextNostrPublicKey: string) { this.state.previousSendingChainMessageCount = this.state.sendingChainMessageNumber; this.state.sendingChainMessageNumber = 0; this.state.receivingChainMessageNumber = 0; this.state.theirNextNostrPublicKey = theirNextNostrPublicKey; // 1st step yields the new conversation key they used const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!); // and our corresponding receiving chain key const [theirRootKey, receivingChainKey] = kdf(this.state.rootKey, conversationKey1, 2); this.state.receivingChainKey = receivingChainKey; // Rotate our Nostr key this.state.ourCurrentNostrKey = this.state.ourNextNostrKey; const ourNextSecretKey = generateSecretKey(); this.state.ourNextNostrKey = { publicKey: getPublicKey(ourNextSecretKey), privateKey: ourNextSecretKey }; // 2nd step yields the new conversation key we'll use const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!); // And our corresponding sending chain key const [rootKey, sendingChainKey] = kdf(theirRootKey, conversationKey2, 2); this.state.rootKey = rootKey; this.state.sendingChainKey = sendingChainKey; } skipMessageKeys(until: number, nostrSender: string) { if (this.state.receivingChainMessageNumber + MAX_SKIP < until) { throw new Error("Too many skipped messages"); } if (!this.state.skippedKeys[nostrSender]) { this.state.skippedKeys[nostrSender] = { headerKeys: [], messageKeys: {} }; if (this.state.ourCurrentNostrKey) { const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender); this.state.skippedKeys[nostrSender].headerKeys.push(currentSecret); } const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender); this.state.skippedKeys[nostrSender].headerKeys.push(nextSecret); } while (this.state.receivingChainMessageNumber < until) { const [newReceivingChainKey, messageKey] = kdf(this.state.receivingChainKey!, new Uint8Array([1]), 2); this.state.receivingChainKey = newReceivingChainKey; this.state.skippedKeys[nostrSender].messageKeys[this.state.receivingChainMessageNumber] = messageKey; this.state.receivingChainMessageNumber++; } } trySkippedMessageKeys(header: Header, ciphertext: string, nostrSender: string): string | null { const skippedKeys = this.state.skippedKeys[nostrSender]; if (!skippedKeys) return null; const messageKey = skippedKeys.messageKeys[header.number]; if (!messageKey) return null; delete skippedKeys.messageKeys[header.number]; if (Object.keys(skippedKeys.messageKeys).length === 0) { delete this.state.skippedKeys[nostrSender]; } return nip44.decrypt(ciphertext, messageKey); }
YakiHonne on Nostr: A new algorithm for secure private messagingâDouble Ratchetđ, enables ...
A new algorithm for secure private messagingâDouble Ratchetđ, enables communication on Nostr without revealing metadata and ensures message security even if the main key is compromisedđ. Learn about what Double Ratchet is and how it works. Check out the article, written by Martti Malmi (npub1g53âŚdrvk)