1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
|
// NIP-17 private DMs: kind 14 rumor, kind 13 seal (sender-signed), kind 1059
// wrap under a single-use ephemeral key to the recipient. Interops with
// 0xchat/Amethyst/etc. Works with a raw secret key (Uint8Array) or a
// nostr-login signer adapter carrying { pubkey, signEvent, encryptTo,
// decryptFrom } — the seal is encrypted to the peer, so remote signers need
// the peer variants (added alongside encryptSelf/decryptSelf).
import { finalizeEvent, generateSecretKey, getPublicKey, getEventHash, verifyEvent } from 'nostr-tools/pure';
import * as nip44 from 'nostr-tools/nip44';
const now = () => Math.floor(Date.now() / 1000);
// NIP-59: outer layers carry timestamps tweaked into the past two days so the
// wrap's time reveals nothing; the rumor keeps the true time.
const tweaked = () => now() - Math.floor(Math.random() * 2 * 24 * 3600);
const asSigner = (skOrSigner) =>
skOrSigner instanceof Uint8Array
? {
pubkey: getPublicKey(skOrSigner),
signEvent: async (e) => finalizeEvent(e, skOrSigner),
encryptTo: async (peer, txt) => nip44.encrypt(txt, nip44.getConversationKey(skOrSigner, peer)),
decryptFrom: async (peer, ct) => nip44.decrypt(ct, nip44.getConversationKey(skOrSigner, peer)),
}
: skOrSigner;
// Build the wrap for one receiver (the recipient, or the sender's own copy).
// Returns the kind 1059 event to publish at the receiver's inbox.
// extraTags ride the outer wrap unencrypted — used only for the Direct
// Invite index hint ["k","3313"] (CORD-05 §6); never put content there.
export async function wrapDM(skOrSigner, receiverPk, rumor, extraTags = []) {
const signer = asSigner(skOrSigner);
const seal = await signer.signEvent({
kind: 13,
content: await signer.encryptTo(receiverPk, JSON.stringify(rumor)),
tags: [],
// NIP-59 backdates the seal too, but only the recipient ever sees it
// (it rides encrypted inside the wrap) — and some remote signers, the
// pomegranate FROST operators among them, refuse to sign anything dated
// in the past ("can't sign event in past" killed every Google-account
// DM). A local key keeps the spec's letter; a remote signer signs now.
// The wrap below keeps its tweak either way — the ephemeral key that
// signs it is ours and local, and that's the layer the public sees.
created_at: skOrSigner instanceof Uint8Array ? tweaked() : now(),
});
const eph = generateSecretKey();
return finalizeEvent(
{
kind: 1059,
content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(eph, receiverPk)),
tags: [['p', receiverPk], ...extraTags],
created_at: tweaked(),
},
eph
);
}
// The kind 14 rumor alone, id included — synchronous, so a sender can put
// the message on screen before any signing or encryption has run. Wrapping
// the same rumor later yields the same id, so the sent-copy echo dedupes.
export function makeDMRumor(senderPk, peerPk, text) {
const rumor = {
kind: 14,
pubkey: senderPk,
content: text,
tags: [['p', peerPk]],
created_at: now(),
};
rumor.id = getEventHash(rumor);
return rumor;
}
// A DM to a peer: the kind 14 rumor (unsigned, true timestamp) plus wraps for
// the recipient and for the sender's own inbox (the sent-copy).
export async function makeDM(skOrSigner, peerPk, text) {
const signer = asSigner(skOrSigner);
const rumor = makeDMRumor(signer.pubkey, peerPk, text);
return {
rumor,
toPeer: await wrapDM(signer, peerPk, rumor),
toSelf: await wrapDM(signer, signer.pubkey, rumor),
};
}
// Open an inbound kind 1059 addressed to us. Returns
// { rumor, author, peer } or null (not ours / not a DM / bad layer).
// `peer` is the conversation partner: the author for received messages,
// the p-tagged recipient for our own sent-copies.
export async function unwrapDM(wrap, skOrSigner) {
const signer = asSigner(skOrSigner);
try {
const seal = JSON.parse(await signer.decryptFrom(wrap.pubkey, wrap.content));
if (seal.kind !== 13 || !verifyEvent(seal)) return null;
const rumor = JSON.parse(await signer.decryptFrom(seal.pubkey, seal.content));
if (rumor.pubkey !== seal.pubkey) return null;
if (rumor.id !== getEventHash({ ...rumor, id: undefined })) return null;
if (rumor.kind !== 14) return { rumor, author: seal.pubkey, peer: null };
const to = rumor.tags?.find((t) => t[0] === 'p')?.[1];
const mine = rumor.pubkey === signer.pubkey;
return { rumor, author: seal.pubkey, peer: mine ? to : rumor.pubkey };
} catch {
return null;
}
}
|