nostr-double-ratchet 0.0.33 → 0.0.35
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/Invite.d.ts +4 -3
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +339 -296
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Invite.ts +22 -5
- package/src/Session.ts +35 -18
- package/src/utils.ts +35 -0
package/src/Invite.ts
CHANGED
|
@@ -220,10 +220,11 @@ export class Invite {
|
|
|
220
220
|
* @param nostrSubscribe - A function to subscribe to Nostr events
|
|
221
221
|
* @param inviteePublicKey - The invitee's public key
|
|
222
222
|
* @param encryptor - The invitee's secret key or a signing/encrypt function
|
|
223
|
+
* @param deviceId - Optional device ID to identify the invitee's device
|
|
223
224
|
* @returns An object containing the new session and an event to be published
|
|
224
225
|
*
|
|
225
226
|
* 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
|
|
226
|
-
* Purpose: Authenticate invitee. Contains invitee session key.
|
|
227
|
+
* Purpose: Authenticate invitee. Contains invitee session key and deviceId.
|
|
227
228
|
* 2. Envelope: No signature, content encrypted with DH(inviter, random key).
|
|
228
229
|
* Purpose: Contains inner event. Hides invitee from others who might have the shared Nostr key.
|
|
229
230
|
|
|
@@ -234,6 +235,7 @@ export class Invite {
|
|
|
234
235
|
nostrSubscribe: NostrSubscribe,
|
|
235
236
|
inviteePublicKey: string,
|
|
236
237
|
encryptor: Uint8Array | EncryptFunction,
|
|
238
|
+
deviceId?: string,
|
|
237
239
|
): Promise<{ session: Session, event: VerifiedEvent }> {
|
|
238
240
|
const inviteeSessionKey = generateSecretKey();
|
|
239
241
|
const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
|
|
@@ -248,7 +250,11 @@ export class Invite {
|
|
|
248
250
|
encryptor :
|
|
249
251
|
(plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(encryptor, pubkey)));
|
|
250
252
|
|
|
251
|
-
const
|
|
253
|
+
const payload = JSON.stringify({
|
|
254
|
+
sessionKey: inviteeSessionPublicKey,
|
|
255
|
+
deviceId: deviceId
|
|
256
|
+
});
|
|
257
|
+
const dhEncrypted = await encrypt(payload, inviterPublicKey);
|
|
252
258
|
|
|
253
259
|
const innerEvent = {
|
|
254
260
|
pubkey: inviteePublicKey,
|
|
@@ -272,7 +278,7 @@ export class Invite {
|
|
|
272
278
|
return { session, event: finalizeEvent(envelope, randomSenderKey) };
|
|
273
279
|
}
|
|
274
280
|
|
|
275
|
-
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity?: string) => void): Unsubscribe {
|
|
281
|
+
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string, _deviceId?: string) => void): Unsubscribe {
|
|
276
282
|
if (!this.inviterEphemeralPrivateKey) {
|
|
277
283
|
throw new Error("Inviter session key is not available");
|
|
278
284
|
}
|
|
@@ -304,12 +310,23 @@ export class Invite {
|
|
|
304
310
|
decryptor :
|
|
305
311
|
(ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(decryptor, pubkey)));
|
|
306
312
|
|
|
307
|
-
const
|
|
313
|
+
const decryptedPayload = await innerDecrypt(dhEncrypted, inviteeIdentity);
|
|
314
|
+
|
|
315
|
+
let inviteeSessionPublicKey: string;
|
|
316
|
+
let deviceId: string | undefined;
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const parsed = JSON.parse(decryptedPayload);
|
|
320
|
+
inviteeSessionPublicKey = parsed.sessionKey;
|
|
321
|
+
deviceId = parsed.deviceId;
|
|
322
|
+
} catch {
|
|
323
|
+
inviteeSessionPublicKey = decryptedPayload;
|
|
324
|
+
}
|
|
308
325
|
|
|
309
326
|
const name = event.id;
|
|
310
327
|
const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
|
|
311
328
|
|
|
312
|
-
onSession(session, inviteeIdentity);
|
|
329
|
+
onSession(session, inviteeIdentity, deviceId);
|
|
313
330
|
} catch {
|
|
314
331
|
}
|
|
315
332
|
});
|
package/src/Session.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
Rumor,
|
|
11
11
|
CHAT_MESSAGE_KIND,
|
|
12
12
|
} from "./types";
|
|
13
|
-
import { kdf } from "./utils";
|
|
13
|
+
import { kdf, deepCopyState } from "./utils";
|
|
14
14
|
|
|
15
15
|
const MAX_SKIP = 1000;
|
|
16
16
|
|
|
@@ -319,47 +319,64 @@ export class Session {
|
|
|
319
319
|
throw new Error("Failed to decrypt header with current and skipped header keys");
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
|
|
322
323
|
private handleNostrEvent(e: { tags: string[][]; pubkey: string; content: string }) {
|
|
324
|
+
const snapshot = deepCopyState(this.state);
|
|
325
|
+
let pendingSwitch = false;
|
|
326
|
+
|
|
323
327
|
try {
|
|
324
328
|
const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
|
|
329
|
+
if (!isSkipped && this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
|
|
330
|
+
this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
|
|
331
|
+
this.state.theirNextNostrPublicKey = header.nextPublicKey;
|
|
332
|
+
pendingSwitch = true;
|
|
333
|
+
}
|
|
325
334
|
|
|
326
335
|
if (!isSkipped) {
|
|
327
|
-
if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
|
|
328
|
-
this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
|
|
329
|
-
this.state.theirNextNostrPublicKey = header.nextPublicKey;
|
|
330
|
-
this.nostrUnsubscribe?.();
|
|
331
|
-
this.nostrUnsubscribe = this.nostrNextUnsubscribe;
|
|
332
|
-
this.nostrNextUnsubscribe = this.nostrSubscribe(
|
|
333
|
-
{authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
|
|
334
|
-
(e) => this.handleNostrEvent(e)
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
336
|
if (shouldRatchet) {
|
|
339
337
|
this.skipMessageKeys(header.previousChainLength, e.pubkey);
|
|
340
338
|
this.ratchetStep();
|
|
341
339
|
}
|
|
342
340
|
} else {
|
|
343
341
|
if (!this.state.skippedKeys[e.pubkey]?.messageKeys[header.number]) {
|
|
344
|
-
|
|
345
|
-
return
|
|
342
|
+
return;
|
|
346
343
|
}
|
|
347
344
|
}
|
|
348
345
|
|
|
349
346
|
const text = this.ratchetDecrypt(header, e.content, e.pubkey);
|
|
350
347
|
const innerEvent = JSON.parse(text);
|
|
348
|
+
|
|
351
349
|
if (!validateEvent(innerEvent)) {
|
|
350
|
+
this.state = snapshot;
|
|
352
351
|
return;
|
|
353
352
|
}
|
|
354
|
-
|
|
355
353
|
if (innerEvent.id !== getEventHash(innerEvent)) {
|
|
354
|
+
this.state = snapshot;
|
|
356
355
|
return;
|
|
357
356
|
}
|
|
358
357
|
|
|
359
|
-
|
|
358
|
+
if (pendingSwitch) {
|
|
359
|
+
this.nostrUnsubscribe?.();
|
|
360
|
+
this.nostrUnsubscribe = this.nostrNextUnsubscribe;
|
|
361
|
+
this.nostrNextUnsubscribe = this.nostrSubscribe(
|
|
362
|
+
{ authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND] },
|
|
363
|
+
(ev) => this.handleNostrEvent(ev)
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
|
|
360
368
|
} catch (error) {
|
|
361
|
-
|
|
362
|
-
|
|
369
|
+
this.state = snapshot;
|
|
370
|
+
if (error instanceof Error) {
|
|
371
|
+
if (error.message.includes("Failed to decrypt header")) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (error.message === "invalid MAC") {
|
|
376
|
+
// Duplicate or stale ciphertexts can hit decrypt() again after a state restore.
|
|
377
|
+
// nip44 throws "invalid MAC" in that case, but the message has already been handled.
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
363
380
|
}
|
|
364
381
|
throw error;
|
|
365
382
|
}
|
package/src/utils.ts
CHANGED
|
@@ -115,6 +115,41 @@ export function deserializeSessionState(data: string): SessionState {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
export function deepCopyState(s: SessionState): SessionState {
|
|
119
|
+
return {
|
|
120
|
+
rootKey: new Uint8Array(s.rootKey),
|
|
121
|
+
theirCurrentNostrPublicKey: s.theirCurrentNostrPublicKey,
|
|
122
|
+
theirNextNostrPublicKey: s.theirNextNostrPublicKey,
|
|
123
|
+
ourCurrentNostrKey: s.ourCurrentNostrKey
|
|
124
|
+
? {
|
|
125
|
+
publicKey: s.ourCurrentNostrKey.publicKey,
|
|
126
|
+
privateKey: new Uint8Array(s.ourCurrentNostrKey.privateKey),
|
|
127
|
+
}
|
|
128
|
+
: undefined,
|
|
129
|
+
ourNextNostrKey: {
|
|
130
|
+
publicKey: s.ourNextNostrKey.publicKey,
|
|
131
|
+
privateKey: new Uint8Array(s.ourNextNostrKey.privateKey),
|
|
132
|
+
},
|
|
133
|
+
receivingChainKey: s.receivingChainKey ? new Uint8Array(s.receivingChainKey) : undefined,
|
|
134
|
+
sendingChainKey: s.sendingChainKey ? new Uint8Array(s.sendingChainKey) : undefined,
|
|
135
|
+
sendingChainMessageNumber: s.sendingChainMessageNumber,
|
|
136
|
+
receivingChainMessageNumber: s.receivingChainMessageNumber,
|
|
137
|
+
previousSendingChainMessageCount: s.previousSendingChainMessageCount,
|
|
138
|
+
skippedKeys: Object.fromEntries(
|
|
139
|
+
Object.entries(s.skippedKeys).map(([author, entry]: any) => [
|
|
140
|
+
author,
|
|
141
|
+
{
|
|
142
|
+
headerKeys: entry.headerKeys.map((hk: Uint8Array) => new Uint8Array(hk)),
|
|
143
|
+
messageKeys: Object.fromEntries(
|
|
144
|
+
Object.entries(entry.messageKeys).map(([n, mk]: any) => [n, new Uint8Array(mk)])
|
|
145
|
+
),
|
|
146
|
+
},
|
|
147
|
+
])
|
|
148
|
+
),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
118
153
|
export async function* createEventStream(session: Session): AsyncGenerator<Rumor, void, unknown> {
|
|
119
154
|
const messageQueue: Rumor[] = [];
|
|
120
155
|
let resolveNext: ((_value: Rumor) => void) | null = null;
|