mirror of
https://github.com/nostr-protocol/nips.git
synced 2025-03-25 17:21:52 +01:00
Merge 3d5b279108c712de9b3288a68e86d96da8f7d73a into 0619f370bca3485bb9c5870bc2defa03c7c3d10e
This commit is contained in:
commit
ec5c88dba1
361
117.md
Normal file
361
117.md
Normal file
@ -0,0 +1,361 @@
|
||||
# NIP-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](./118.md): 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](https://signal.org/docs/specifications/doubleratchet/#double-ratchet-with-header-encryption), but encrypting the message headers with [NIP-44](./44.md) 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
|
||||
|
||||
```typescript
|
||||
{
|
||||
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.
|
||||
|
||||
The events should be sent to the recipient's inbox relays.
|
||||
|
||||
#### Inner event
|
||||
|
||||
Inner events must be [NIP-59](59.md) 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](17.md).
|
||||
|
||||
## Algorithm
|
||||
|
||||
Signal's [Double Ratchet with header encryption](https://signal.org/docs/specifications/doubleratchet/#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](01.md)):
|
||||
|
||||
- `generateSecretKey()` for creating Nostr private keys
|
||||
- `finalizeEvent(partialEvent, secretKey)` for creating valid Nostr events with pubkey, id and signature
|
||||
|
||||
We use [NIP-44](44.md) functions for encryption:
|
||||
|
||||
- `nip44.encrypt`
|
||||
- `nip44.decrypt`
|
||||
- `nip44.getConversationKey`
|
||||
|
||||
[NIP-59](59.md):
|
||||
|
||||
- createRumor
|
||||
|
||||
Key derivation function:
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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);
|
||||
}
|
||||
```
|
86
118.md
Normal file
86
118.md
Normal file
@ -0,0 +1,86 @@
|
||||
# NIP-118
|
||||
|
||||
## Nostr Double Ratchet Invites
|
||||
|
||||
In order to start a [NIP-117](./117.md) Nostr Double Ratchet session, Alice and Bob need to exchange ephemeral public keys and a shared secret.
|
||||
|
||||
In a Nostr Double Ratchet invite, Alice gives Bob her
|
||||
|
||||
- ephemeral public key, which is also used to respond to the invite on Nostr,
|
||||
- shared secret, and
|
||||
- Nostr identity public key, which is used to authenticate the exchange.
|
||||
|
||||
### Nostr event
|
||||
|
||||
Publishing an invite event on Nostr allows other users to start a Double Ratchet session with you.
|
||||
|
||||
```typescript
|
||||
{
|
||||
pubkey: inviterIdentityKey,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', `double-ratchet/invites/${uid}`], // uid: unique name for the invitation, e.g. "public"
|
||||
['l', 'double-ratchet/invites'], // label, for listing all your invites
|
||||
['ephemeralKey', inviterEphemeralKey],
|
||||
['sharedSecret', sharedSecret]
|
||||
],
|
||||
kind: 30078,
|
||||
created_at,
|
||||
id,
|
||||
sig
|
||||
}
|
||||
```
|
||||
|
||||
`l` is a [NIP-32](32.md) label tag that can be used to list your invites. We use this approach with [NIP-78](./78.md) application specific data kind `30078` in order to not bloat the event kind space and instead have a human-readable label.
|
||||
|
||||
### URL
|
||||
|
||||
Invites can be also shared privately, without requiring a Nostr event. This improves privacy, removing the public association between the inviter and response.
|
||||
|
||||
URL and QR code are often convenient ways to share privately, especially when the other user is not yet on Nostr. Format:
|
||||
|
||||
```typescript
|
||||
const invite = { inviter, ephemeralKey, sharedSecret };
|
||||
const json = JSON.stringify(invite);
|
||||
const url = `https://example.com/#${encodeURIComponent(json)}`;
|
||||
```
|
||||
|
||||
Encoding the invite into the URL hash ensures it's not sent to the server and logged by default.
|
||||
|
||||
`nostr:` URI scheme is another possible way to share invites, when we have native clients supporting the feature.
|
||||
|
||||
### Invite response
|
||||
|
||||
#### Outer event
|
||||
|
||||
Invite response outer event is a [NIP-59](59.md) gift wrap event of kind `1059`, sent from a random, one-time-use pubkey, hiding the responder's identity from the public.
|
||||
|
||||
It is addressed to the ephemeral key in the invite. If the invite was publicly shared, responses can be publicly associated to the inviter.
|
||||
|
||||
#### Inner event
|
||||
|
||||
[NIP-59](59.md) Rumor.
|
||||
|
||||
```typescript
|
||||
const conversationKey = nip44.getConversationKey(
|
||||
inviteeKeyPair,
|
||||
inviterPublicKey
|
||||
);
|
||||
const encrypted1 = encrypt(inviteeEphemeralKey, conversationKey);
|
||||
const encrypted2 = encrypt(encrypted1, sharedSecret);
|
||||
|
||||
const rumor = {
|
||||
pubkey: inviteePublicKey,
|
||||
kind: 1060,
|
||||
content: encrypted2,
|
||||
tags: [],
|
||||
createdAt,
|
||||
id,
|
||||
};
|
||||
```
|
||||
|
||||
After receiving the invite response, both parties have what they need to start a double ratchet session: each others' ephemeral public keys and a shared secret.
|
||||
|
||||
Both parties have authenticated by encrypting or decrypting using their nip44 conversation key.
|
||||
|
||||
The shared secret from the invite is used in the response to ensure that only actual recipients of the invite can follow it. Otherwise, attackers could initiate double ratchet sessions by sending invite responses to all addresses that received gift wraps, some of which are private invite addresses.
|
Loading…
x
Reference in New Issue
Block a user