Skip to content

Commit 91621b5

Browse files
committed
add hidden content helper methods
1 parent 1ccc1b9 commit 91621b5

9 files changed

+181
-53
lines changed

.changeset/flat-mirrors-hang.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"applesauce-core": minor
3+
---
4+
5+
Add gift-wrap helper methods

.changeset/plenty-jeans-leave.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"applesauce-core": minor
3+
---
4+
5+
Add direct message helper methods

.changeset/silent-drinks-wink.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"applesauce-core": minor
3+
---
4+
5+
Add hidden content helper methods
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { NostrEvent } from "nostr-tools";
2+
import { getHiddenContent, HiddenContentSigner, unlockHiddenContent } from "./hidden-content.js";
3+
4+
/** Returns the decrypted content of a direct message */
5+
export async function decryptDirectMessage(message: NostrEvent, signer: HiddenContentSigner): Promise<string> {
6+
return getHiddenContent(message) || (await unlockHiddenContent(message, signer));
7+
}
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { NostrEvent, UnsignedEvent, verifyEvent } from "nostr-tools";
2+
import { getHiddenContent, HiddenContentSigner, isHiddenContentLocked, unlockHiddenContent } from "./hidden-content.js";
3+
import { getOrComputeCachedValue } from "./cache.js";
4+
5+
export const GiftWrapSealSymbol = Symbol.for("gift-wrap-seal");
6+
export const GiftWrapEventSymbol = Symbol.for("gift-wrap-event");
7+
8+
/** Returns the unsigned seal event in a gift-wrap event */
9+
export function getGiftWrapSeal(gift: NostrEvent): NostrEvent {
10+
return getOrComputeCachedValue(gift, GiftWrapSealSymbol, () => {
11+
const plaintext = getHiddenContent(gift);
12+
if (!plaintext) throw new Error("Gift-wrap is locked");
13+
const seal = JSON.parse(plaintext) as NostrEvent;
14+
15+
// verify the seal is valid
16+
verifyEvent(seal);
17+
18+
return seal;
19+
});
20+
}
21+
22+
/** Returns the unsigned event in the gift-wrap seal */
23+
export function getGiftWrapEvent(gift: NostrEvent) {
24+
return getOrComputeCachedValue(gift, GiftWrapEventSymbol, () => {
25+
const seal = getGiftWrapSeal(gift);
26+
const plaintext = getHiddenContent(seal);
27+
if (!plaintext) throw new Error("Gift-wrap seal is locked");
28+
const event = JSON.parse(plaintext) as UnsignedEvent;
29+
30+
if (event.pubkey !== seal.pubkey) throw new Error("Seal author does not match content");
31+
32+
return event;
33+
});
34+
}
35+
36+
/** Returns if a gift-wrap event or gift-wrap seal is locked */
37+
export function isGiftWrapLocked(gift: NostrEvent): boolean {
38+
return isHiddenContentLocked(gift) || isHiddenContentLocked(getGiftWrapSeal(gift));
39+
}
40+
41+
/** Unlocks and returns the unsigned seal event in a gift-wrap */
42+
export async function unlockGiftWrap(gift: NostrEvent, signer: HiddenContentSigner): Promise<UnsignedEvent> {
43+
if (isHiddenContentLocked(gift)) await unlockHiddenContent(gift, signer);
44+
const seal = getGiftWrapSeal(gift);
45+
if (isHiddenContentLocked(seal)) await unlockHiddenContent(seal, signer);
46+
return getGiftWrapEvent(gift);
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import * as kinds from "nostr-tools/kinds";
2+
import { UnsignedEvent, type EventTemplate, type NostrEvent } from "nostr-tools";
3+
4+
import { GROUPS_LIST_KIND } from "./groups.js";
5+
6+
export const HiddenContentSymbol = Symbol.for("hidden-content");
7+
8+
export type HiddenContentSigner = {
9+
nip04?: {
10+
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
11+
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
12+
};
13+
nip44?: {
14+
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
15+
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
16+
};
17+
};
18+
19+
/** Various event kinds that can have encrypted tags in their content and which encryption method they use */
20+
export const EventContentEncryptionMethod: Record<number, "nip04" | "nip44"> = {
21+
// NIP-60 wallet
22+
17375: "nip44",
23+
7375: "nip44",
24+
7376: "nip44",
25+
26+
// DMs
27+
[kinds.EncryptedDirectMessage]: "nip04",
28+
29+
// Gift wraps
30+
[kinds.GiftWrap]: "nip44",
31+
32+
// NIP-51 lists
33+
[kinds.BookmarkList]: "nip04",
34+
[kinds.InterestsList]: "nip04",
35+
[kinds.Mutelist]: "nip04",
36+
[kinds.CommunitiesList]: "nip04",
37+
[kinds.PublicChatsList]: "nip04",
38+
[kinds.SearchRelaysList]: "nip04",
39+
[GROUPS_LIST_KIND]: "nip04",
40+
41+
// NIP-51 sets
42+
[kinds.Bookmarksets]: "nip04",
43+
[kinds.Relaysets]: "nip04",
44+
[kinds.Followsets]: "nip04",
45+
[kinds.Curationsets]: "nip04",
46+
[kinds.Interestsets]: "nip04",
47+
};
48+
49+
/** Checks if an event can have hidden content */
50+
export function canHaveHiddenContent(kind: number): boolean {
51+
return EventContentEncryptionMethod[kind] !== undefined;
52+
}
53+
54+
/** Checks if an event has hidden content */
55+
export function hasHiddenContent(event: { kind: number; content: string }): boolean {
56+
return canHaveHiddenContent(event.kind) && event.content.length > 0;
57+
}
58+
59+
/** Returns the hidden tags for an event if they are unlocked */
60+
export function getHiddenContent(event: NostrEvent | EventTemplate): string | undefined {
61+
return Reflect.get(event, HiddenContentSymbol) as string | undefined;
62+
}
63+
64+
/** Checks if the hidden tags are locked */
65+
export function isHiddenContentLocked(event: NostrEvent | UnsignedEvent): boolean {
66+
return hasHiddenContent(event) && getHiddenContent(event) === undefined;
67+
}
68+
69+
/** Returns either nip04 or nip44 encryption methods depending on event kind */
70+
export function getHiddenContentEncryptionMethods(kind: number, signer: HiddenContentSigner) {
71+
const method = EventContentEncryptionMethod[kind];
72+
const encryption = signer[method];
73+
if (!encryption) throw new Error(`Signer does not support ${method} encryption`);
74+
75+
return encryption;
76+
}
77+
78+
/**
79+
* Unlocks the encrypted content in an event
80+
* @param event The event with content to decrypt
81+
* @param signer A signer to use to decrypt the tags
82+
* @throws
83+
*/
84+
export async function unlockHiddenContent(
85+
event: { kind: number; pubkey: string; content: string },
86+
signer: HiddenContentSigner,
87+
): Promise<string> {
88+
if (!canHaveHiddenContent(event.kind)) throw new Error("Event kind does not support hidden content");
89+
const encryption = getHiddenContentEncryptionMethods(event.kind, signer);
90+
const plaintext = await encryption.decrypt(event.pubkey, event.content);
91+
92+
Reflect.set(event, HiddenContentSymbol, plaintext);
93+
94+
return plaintext;
95+
}

packages/core/src/helpers/hidden-tags.ts

+14-46
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,20 @@
1-
import { EventTemplate, kinds, NostrEvent } from "nostr-tools";
1+
import { EventTemplate, NostrEvent } from "nostr-tools";
22

3-
import { GROUPS_LIST_KIND } from "./groups.js";
43
import { EventStore } from "../event-store/event-store.js";
54
import { unixNow } from "./time.js";
65
import { isEvent } from "./event.js";
7-
8-
export type HiddenTagsSigner = {
9-
nip04?: {
10-
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
11-
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
12-
};
13-
nip44?: {
14-
encrypt: (pubkey: string, plaintext: string) => Promise<string> | string;
15-
decrypt: (pubkey: string, ciphertext: string) => Promise<string> | string;
16-
};
17-
};
6+
import {
7+
canHaveHiddenContent,
8+
getHiddenContentEncryptionMethods,
9+
HiddenContentSigner,
10+
unlockHiddenContent,
11+
} from "./hidden-content.js";
1812

1913
export const HiddenTagsSymbol = Symbol.for("hidden-tags");
2014

21-
/** Various event kinds that can have encrypted tags in their content and which encryption method they use */
22-
export const EventEncryptionMethod: Record<number, "nip04" | "nip44"> = {
23-
// NIP-60 wallet
24-
17375: "nip44",
25-
26-
// NIP-51 lists
27-
[kinds.BookmarkList]: "nip04",
28-
[kinds.InterestsList]: "nip04",
29-
[kinds.Mutelist]: "nip04",
30-
[kinds.CommunitiesList]: "nip04",
31-
[kinds.PublicChatsList]: "nip04",
32-
[kinds.SearchRelaysList]: "nip04",
33-
[GROUPS_LIST_KIND]: "nip04",
34-
35-
// NIP-51 sets
36-
[kinds.Bookmarksets]: "nip04",
37-
[kinds.Relaysets]: "nip04",
38-
[kinds.Followsets]: "nip04",
39-
[kinds.Curationsets]: "nip04",
40-
[kinds.Interestsets]: "nip04",
41-
};
42-
4315
/** Checks if an event can have hidden tags */
4416
export function canHaveHiddenTags(kind: number): boolean {
45-
return EventEncryptionMethod[kind] !== undefined;
17+
return canHaveHiddenContent(kind);
4618
}
4719

4820
/** Checks if an event has hidden tags */
@@ -61,12 +33,8 @@ export function isHiddenTagsLocked(event: NostrEvent): boolean {
6133
}
6234

6335
/** Returns either nip04 or nip44 encryption method depending on list kind */
64-
export function getListEncryptionMethods(kind: number, signer: HiddenTagsSigner) {
65-
const method = EventEncryptionMethod[kind];
66-
const encryption = signer[method];
67-
if (!encryption) throw new Error(`Signer does not support ${method} encryption`);
68-
69-
return encryption;
36+
export function getListEncryptionMethods(kind: number, signer: HiddenContentSigner) {
37+
return getHiddenContentEncryptionMethods(kind, signer);
7038
}
7139

7240
/**
@@ -78,12 +46,11 @@ export function getListEncryptionMethods(kind: number, signer: HiddenTagsSigner)
7846
*/
7947
export async function unlockHiddenTags<T extends { kind: number; pubkey: string; content: string }>(
8048
event: T,
81-
signer: HiddenTagsSigner,
49+
signer: HiddenContentSigner,
8250
store?: EventStore,
8351
): Promise<T> {
8452
if (!canHaveHiddenTags(event.kind)) throw new Error("Event kind does not support hidden tags");
85-
const encryption = getListEncryptionMethods(event.kind, signer);
86-
const plaintext = await encryption.decrypt(event.pubkey, event.content);
53+
const plaintext = await unlockHiddenContent(event, signer);
8754

8855
const parsed = JSON.parse(plaintext) as string[][];
8956
if (!Array.isArray(parsed)) throw new Error("Content is not an array of tags");
@@ -100,12 +67,13 @@ export async function unlockHiddenTags<T extends { kind: number; pubkey: string;
10067

10168
/**
10269
* Override the hidden tags in an event
70+
* @deprecated use EventFactory to create draft events
10371
* @throws
10472
*/
10573
export async function overrideHiddenTags(
10674
event: NostrEvent,
10775
hidden: string[][],
108-
signer: HiddenTagsSigner,
76+
signer: HiddenContentSigner,
10977
): Promise<EventTemplate> {
11078
if (!canHaveHiddenTags(event.kind)) throw new Error("Event kind does not support hidden tags");
11179
const encryption = getListEncryptionMethods(event.kind, signer);

packages/core/src/helpers/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ export * from "./comment.js";
77
export * from "./contacts.js";
88
export * from "./content.js";
99
export * from "./delete.js";
10+
export * from "./direct-messages.js";
1011
export * from "./dns-identity.js";
1112
export * from "./emoji.js";
1213
export * from "./event.js";
1314
export * from "./external-id.js";
1415
export * from "./file-metadata.js";
1516
export * from "./filter.js";
17+
export * from "./gift-wraps.js";
1618
export * from "./groups.js";
1719
export * from "./hashtag.js";
20+
export * from "./hidden-content.js";
1821
export * from "./hidden-tags.js";
1922
export * from "./json.js";
2023
export * from "./lists.js";

packages/relay/src/relay.ts

-7
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
NEVER,
99
Observable,
1010
of,
11-
retry,
1211
shareReplay,
1312
switchMap,
1413
take,
@@ -74,8 +73,6 @@ export class Relay {
7473

7574
// create an observable for listening for AUTH
7675
this.challenge$ = this.socket$.pipe(
77-
// trying connection on error
78-
retry({ count: 20, delay: 5_000, resetOnSuccess: true }),
7976
// listen for AUTH messages
8077
filter((message) => message[0] === "AUTH"),
8178
// pick the challenge string out
@@ -115,8 +112,6 @@ export class Relay {
115112
(message) => (message[0] === "EVENT" || message[0] === "CLOSE" || message[0] === "EOSE") && message[1] === id,
116113
)
117114
.pipe(
118-
// trying connection on error
119-
retry({ count: 20, delay: 5_000, resetOnSuccess: true }),
120115
// listen for CLOSE auth-required
121116
tap((m) => {
122117
if (m[0] === "CLOSE" && m[1].startsWith("auth-required") && !this.authRequiredForReq.value) {
@@ -150,8 +145,6 @@ export class Relay {
150145
(m) => m[0] === "OK" && m[1] === event.id,
151146
)
152147
.pipe(
153-
// trying connection on error
154-
retry({ count: 20, delay: 5_000, resetOnSuccess: true }),
155148
// format OK message
156149
map((m) => ({ ok: m[2], message: m[3], from: this.url })),
157150
// complete on first value

0 commit comments

Comments
 (0)