nostr-double-ratchet 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Session.ts CHANGED
@@ -1,17 +1,22 @@
1
- import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent } from "nostr-tools";
1
+ import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, getEventHash, validateEvent } from "nostr-tools";
2
2
  import { bytesToHex } from "@noble/hashes/utils";
3
3
  import {
4
4
  SessionState,
5
5
  Header,
6
6
  Unsubscribe,
7
7
  NostrSubscribe,
8
- MessageCallback,
8
+ EventCallback,
9
9
  MESSAGE_EVENT_KIND,
10
+ Rumor,
11
+ CHAT_MESSAGE_KIND,
10
12
  } from "./types";
11
13
  import { kdf, skippedMessageIndexKey } from "./utils";
12
14
 
13
15
  const MAX_SKIP = 1000;
14
16
 
17
+ // 64 zeros
18
+ const DUMMY_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000000'
19
+
15
20
  /**
16
21
  * Double ratchet secure communication session over Nostr
17
22
  *
@@ -21,7 +26,7 @@ const MAX_SKIP = 1000;
21
26
  export class Session {
22
27
  private nostrUnsubscribe?: Unsubscribe;
23
28
  private nostrNextUnsubscribe?: Unsubscribe;
24
- private internalSubscriptions = new Map<number, MessageCallback>();
29
+ private internalSubscriptions = new Map<number, EventCallback>();
25
30
  private currentInternalSubscriptionId = 0;
26
31
  public name: string;
27
32
 
@@ -70,17 +75,50 @@ export class Session {
70
75
  }
71
76
 
72
77
  /**
73
- * Sends an encrypted message through the session
74
- * @param data The plaintext message to send
78
+ * Sends a text message through the encrypted session
79
+ * @param text The plaintext message to send
75
80
  * @returns A verified Nostr event containing the encrypted message
76
81
  * @throws Error if we are not the initiator and trying to send the first message
77
82
  */
78
- send(data: string): VerifiedEvent {
83
+ send(text: string): VerifiedEvent {
84
+ return this.sendEvent({
85
+ content: text,
86
+ kind: CHAT_MESSAGE_KIND
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Send a partial Nostr event through the encrypted session.
92
+ * In addition to chat messages, it could be files, webrtc negotiation or many other types of messages.
93
+ * @param event Partial Nostr event to send. Must be unsigned. Id and will be generated if not provided.
94
+ * @returns A verified Nostr event containing the encrypted message
95
+ * @throws Error if we are not the initiator and trying to send the first message
96
+ */
97
+ sendEvent(event: Partial<UnsignedEvent>): VerifiedEvent {
79
98
  if (!this.state.theirNextNostrPublicKey || !this.state.ourCurrentNostrKey) {
80
99
  throw new Error("we are not the initiator, so we can't send the first message");
81
100
  }
82
101
 
83
- const [header, encryptedData] = this.ratchetEncrypt(data);
102
+ if ("sig" in event) {
103
+ throw new Error("Event must be unsigned " + JSON.stringify(event));
104
+ }
105
+
106
+ const rumor: Partial<Rumor> = {
107
+ ...event,
108
+ content: event.content || "",
109
+ kind: event.kind || MESSAGE_EVENT_KIND,
110
+ created_at: event.created_at || Math.floor(Date.now() / 1000),
111
+ tags: event.tags || [],
112
+ pubkey: event.pubkey || DUMMY_PUBKEY,
113
+ }
114
+
115
+ if (!rumor.tags!.some(([k]) => k === "ms")) {
116
+ rumor.tags!.push(["ms", Date.now().toString()])
117
+ }
118
+
119
+ rumor.id = getEventHash(rumor as Rumor);
120
+
121
+ const [header, encryptedData] = this.ratchetEncrypt(JSON.stringify(rumor));
84
122
 
85
123
  const sharedSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.theirNextNostrPublicKey);
86
124
  const encryptedHeader = nip44.encrypt(JSON.stringify(header), sharedSecret);
@@ -100,7 +138,7 @@ export class Session {
100
138
  * @param callback Function to be called when a message is received
101
139
  * @returns Unsubscribe function to stop receiving messages
102
140
  */
103
- onMessage(callback: MessageCallback): Unsubscribe {
141
+ onEvent(callback: EventCallback): Unsubscribe {
104
142
  const id = this.currentInternalSubscriptionId++
105
143
  this.internalSubscriptions.set(id, callback)
106
144
  this.subscribeToNostrEvents()
@@ -122,7 +160,6 @@ export class Session {
122
160
  const header: Header = {
123
161
  number: this.state.sendingChainMessageNumber++,
124
162
  nextPublicKey: this.state.ourNextNostrKey.publicKey,
125
- time: Date.now(),
126
163
  previousChainLength: this.state.previousSendingChainMessageCount
127
164
  };
128
165
  return [header, nip44.encrypt(plaintext, messageKey)];
@@ -283,9 +320,19 @@ export class Session {
283
320
  }
284
321
  }
285
322
 
286
- const data = this.ratchetDecrypt(header, e.content, e.pubkey);
323
+ const text = this.ratchetDecrypt(header, e.content, e.pubkey);
324
+ const innerEvent = JSON.parse(text);
325
+ if (!validateEvent(innerEvent)) {
326
+ console.error("Invalid event received", innerEvent);
327
+ return;
328
+ }
329
+
330
+ if (innerEvent.id !== getEventHash(innerEvent)) {
331
+ console.error("Event hash does not match", innerEvent);
332
+ return;
333
+ }
287
334
 
288
- this.internalSubscriptions.forEach(callback => callback({id: e.id, data, pubkey: header.nextPublicKey, time: header.time}));
335
+ this.internalSubscriptions.forEach(callback => callback(innerEvent, e));
289
336
  }
290
337
 
291
338
  private subscribeToNostrEvents() {
package/src/types.ts CHANGED
@@ -1,20 +1,9 @@
1
1
  import { Filter, UnsignedEvent, VerifiedEvent } from "nostr-tools";
2
2
 
3
- /**
4
- * An event that has been verified to be from the Nostr network.
5
- */
6
- export type Message = {
7
- id: string;
8
- data: string;
9
- pubkey: string;
10
- time: number; // unlike Nostr, we use milliseconds instead of seconds
11
- }
12
-
13
3
  export type Header = {
14
4
  number: number;
15
5
  previousChainLength: number;
16
6
  nextPublicKey: string;
17
- time: number;
18
7
  }
19
8
 
20
9
  /**
@@ -76,14 +65,26 @@ export type Unsubscribe = () => void;
76
65
  */
77
66
  export type NostrSubscribe = (filter: Filter, onEvent: (e: VerifiedEvent) => void) => Unsubscribe;
78
67
 
68
+ export type Rumor = UnsignedEvent & { id: string }
69
+
79
70
  /**
80
71
  * Callback function for handling decrypted messages
81
72
  * @param message - The decrypted message object
82
73
  */
83
- export type MessageCallback = (message: Message) => void;
74
+ export type EventCallback = (event: Rumor, outerEvent: VerifiedEvent) => void;
75
+
76
+ /**
77
+ * Message event kind
78
+ */
79
+ export const MESSAGE_EVENT_KIND = 30078;
84
80
 
85
- export const MESSAGE_EVENT_KIND = 4;
81
+ /**
82
+ * Invite event kind
83
+ */
86
84
  export const INVITE_EVENT_KIND = 30078;
85
+
86
+ export const CHAT_MESSAGE_KIND = 14;
87
+
87
88
  export const MAX_SKIP = 100;
88
89
 
89
90
  export type NostrEvent = {
@@ -96,11 +97,6 @@ export type NostrEvent = {
96
97
  sig: string;
97
98
  }
98
99
 
99
- export enum Sender {
100
- Us,
101
- Them
102
- }
103
-
104
100
  export type EncryptFunction = (plaintext: string, pubkey: string) => Promise<string>;
105
101
  export type DecryptFunction = (ciphertext: string, pubkey: string) => Promise<string>;
106
102
  export type NostrPublish = (event: UnsignedEvent) => Promise<VerifiedEvent>;
package/src/utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
2
- import { SessionState, Message } from "./types";
2
+ import { Rumor, SessionState } from "./types";
3
3
  import { Session } from "./Session.ts";
4
4
  import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf';
5
5
  import { sha256 } from '@noble/hashes/sha256';
@@ -71,16 +71,16 @@ export function deserializeSessionState(data: string): SessionState {
71
71
  };
72
72
  }
73
73
 
74
- export async function* createMessageStream(session: Session): AsyncGenerator<Message, void, unknown> {
75
- const messageQueue: Message[] = [];
76
- let resolveNext: ((value: Message) => void) | null = null;
74
+ export async function* createEventStream(session: Session): AsyncGenerator<Rumor, void, unknown> {
75
+ const messageQueue: Rumor[] = [];
76
+ let resolveNext: ((value: Rumor) => void) | null = null;
77
77
 
78
- const unsubscribe = session.onMessage((message) => {
78
+ const unsubscribe = session.onEvent((event) => {
79
79
  if (resolveNext) {
80
- resolveNext(message);
80
+ resolveNext(event);
81
81
  resolveNext = null;
82
82
  } else {
83
- messageQueue.push(message);
83
+ messageQueue.push(event);
84
84
  }
85
85
  });
86
86
 
@@ -89,7 +89,7 @@ export async function* createMessageStream(session: Session): AsyncGenerator<Mes
89
89
  if (messageQueue.length > 0) {
90
90
  yield messageQueue.shift()!;
91
91
  } else {
92
- yield new Promise<Message>(resolve => {
92
+ yield new Promise<Rumor>(resolve => {
93
93
  resolveNext = resolve;
94
94
  });
95
95
  }