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/dist/Session.d.ts +14 -6
- package/dist/Session.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +724 -694
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +12 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Session.ts +58 -11
- package/src/types.ts +14 -18
- package/src/utils.ts +8 -8
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
|
-
|
|
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,
|
|
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
|
|
74
|
-
* @param
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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*
|
|
75
|
-
const messageQueue:
|
|
76
|
-
let resolveNext: ((value:
|
|
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.
|
|
78
|
+
const unsubscribe = session.onEvent((event) => {
|
|
79
79
|
if (resolveNext) {
|
|
80
|
-
resolveNext(
|
|
80
|
+
resolveNext(event);
|
|
81
81
|
resolveNext = null;
|
|
82
82
|
} else {
|
|
83
|
-
messageQueue.push(
|
|
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<
|
|
92
|
+
yield new Promise<Rumor>(resolve => {
|
|
93
93
|
resolveNext = resolve;
|
|
94
94
|
});
|
|
95
95
|
}
|