ncc-02-js 0.3.1 → 0.5.0

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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Parse the required `private` tag from an event.
3
+ * @param {any[]} tags
4
+ * @returns {boolean | null}
5
+ */
6
+ export function parsePrivateFlag(tags: any[]): boolean | null;
7
+ /**
8
+ * Extract all `privateRecipients` ciphertext values from an event.
9
+ * @param {any[]} tags
10
+ * @returns {string[]}
11
+ */
12
+ export function collectPrivateRecipients(tags: any[]): string[];
13
+ /**
14
+ * Encrypt a list of recipient pubkeys so they can be published in a service record.
15
+ * @param {string | Uint8Array | NipSigner} ownerPrivateKey
16
+ * @param {string[]} recipients
17
+ * @returns {Promise<string[]>}
18
+ */
19
+ export function encryptPrivateRecipients(ownerPrivateKey: string | Uint8Array | NipSigner, recipients: string[]): Promise<string[]>;
20
+ /**
21
+ * Decrypt a single private recipient ciphertext.
22
+ * @param {string} ciphertext
23
+ * @param {string} ownerPubkey
24
+ * @param {string | Uint8Array | NipSigner} recipientPrivateKey
25
+ * @returns {Promise<string>}
26
+ */
27
+ export function decryptPrivateRecipient(ciphertext: string, ownerPubkey: string, recipientPrivateKey: string | Uint8Array | NipSigner): Promise<string>;
28
+ /**
29
+ * Check whether the provided private key matches one of the encrypted recipients.
30
+ * @param {string[]} privateRecipients
31
+ * @param {string} ownerPubkey
32
+ * @param {string | Uint8Array | NipSigner} recipientPrivateKey
33
+ * @returns {Promise<boolean>}
34
+ */
35
+ export function isPrivateRecipientAuthorized(privateRecipients: string[], ownerPubkey: string, recipientPrivateKey: string | Uint8Array | NipSigner): Promise<boolean>;
36
+ export type NipSigner = {
37
+ nip44Encrypt?: (thirdPartyPubkey: string, plaintext: string) => Promise<string> | string;
38
+ nip44Decrypt?: (ownerPubkey: string, ciphertext: string) => Promise<string> | string;
39
+ nip04?: {
40
+ encrypt: (thirdPartyPubkey: string, plaintext: string) => Promise<string> | string;
41
+ decrypt: (ownerPubkey: string, ciphertext: string) => Promise<string> | string;
42
+ };
43
+ getPublicKey?: () => Promise<string> | string;
44
+ };
@@ -12,13 +12,18 @@ export class NCC02Error extends Error {
12
12
  cause: any;
13
13
  }
14
14
  /**
15
- * @typedef {Object} ResolvedService
15
+ * @typedef {Object} ServiceStatus
16
16
  * @property {string|undefined} endpoint
17
17
  * @property {string|undefined} fingerprint
18
18
  * @property {number} expiry
19
- * @property {any[]} attestations
19
+ * @property {boolean} isRevoked
20
+ * @property {number} attestationCount
21
+ * @property {Array<{eventId:string, level:string, pubkey:string}>} attestations
20
22
  * @property {string} eventId
21
23
  * @property {string} pubkey
24
+ * @property {any} serviceEvent
25
+ * @property {boolean} isPrivate
26
+ * @property {string[]} privateRecipients
22
27
  */
23
28
  /**
24
29
  * Resolver for NCC-02 Service Records.
@@ -52,6 +57,18 @@ export class NCC02Resolver {
52
57
  * @returns {Promise<import('nostr-tools').Event[]>}
53
58
  */
54
59
  _query(filter: import("nostr-tools").Filter): Promise<import("nostr-tools").Event[]>;
60
+ /**
61
+ * Returns the first event sorted by freshness (newest created_at, tie broken by id).
62
+ * @param {import('nostr-tools').Event[]} events
63
+ * @returns {import('nostr-tools').Event|null}
64
+ */
65
+ _freshestEvent(events: import("nostr-tools").Event[]): import("nostr-tools").Event | null;
66
+ /**
67
+ * Query helper that returns only the freshest event matching the filter.
68
+ * @param {import('nostr-tools').Filter} filter
69
+ * @returns {Promise<import('nostr-tools').Event | null>}
70
+ */
71
+ _queryFreshest(filter: import("nostr-tools").Filter): Promise<import("nostr-tools").Event | null>;
55
72
  /**
56
73
  * Resolves a service for a given pubkey and service identifier.
57
74
  *
@@ -61,41 +78,88 @@ export class NCC02Resolver {
61
78
  * @param {boolean} [options.requireAttestation=false] - If true, fails if no trusted attestation is found.
62
79
  * @param {string} [options.minLevel=null] - Minimum trust level ('self', 'verified', 'hardened').
63
80
  * @param {string} [options.standard='nostr-service-trust-v0.1'] - Expected trust standard.
64
- * @throws {NCC02Error} If verification or policy checks fail.
65
- * @returns {Promise<ResolvedService>} The verified service details.
81
+ * @throws {NCC02Error} If verification or policy checks fail.
82
+ * @returns {Promise<ServiceStatus>} The service status including trust metadata.
66
83
  */
67
84
  resolve(pubkey: string, serviceId: string, options?: {
68
85
  requireAttestation?: boolean;
69
86
  minLevel?: string;
70
87
  standard?: string;
71
- }): Promise<ResolvedService>;
88
+ }): Promise<ServiceStatus>;
72
89
  /**
73
- * @param {string | undefined} actual
74
- * @param {string} required
90
+ * @param {any} serviceEvent
91
+ * @param {Object} options
92
+ * @param {string} options.pubkey
93
+ * @param {string} options.serviceId
94
+ * @param {string|null} options.standard
95
+ * @param {string|null} options.minLevel
96
+ */
97
+ _buildTrustData(serviceEvent: any, options: {
98
+ pubkey: string;
99
+ serviceId: string;
100
+ standard: string | null;
101
+ minLevel: string | null;
102
+ }): Promise<{
103
+ validAttestations: {
104
+ pubkey: string;
105
+ level: any;
106
+ eventId: string;
107
+ }[];
108
+ isRevoked: boolean;
109
+ }>;
110
+ /**
111
+ * @param {any[]} revocations
112
+ * @returns {Record<string, any[]>}
75
113
  */
76
- _isLevelSufficient(actual: string | undefined, required: string): boolean;
114
+ /**
115
+ * @param {any[]} revocations
116
+ * @returns {Record<string, any[]>}
117
+ */
118
+ _groupValidRevocations(revocations: any[]): Record<string, any[]>;
77
119
  /**
78
120
  * @param {any} att
79
- * @param {any} tags
121
+ * @param {Record<string, string>} tags
80
122
  * @param {any[]} revocations
81
123
  */
82
- _isAttestationValid(att: any, tags: any, revocations: any[]): boolean;
124
+ /**
125
+ * @param {any} att
126
+ * @param {Record<string, string>} tags
127
+ * @param {any[]} [revocations]
128
+ */
129
+ _evaluateAttestation(att: any, tags: Record<string, string>, revocations?: any[]): {
130
+ valid: boolean;
131
+ revoked: boolean;
132
+ };
133
+ /**
134
+ * @param {string | undefined} actual
135
+ * @param {string} required
136
+ */
137
+ _isLevelSufficient(actual: string | undefined, required: string): boolean;
83
138
  /**
84
139
  * Verifies that the actual fingerprint found during transport-level connection
85
140
  * matches the one declared in the signed service record.
86
141
  *
87
- * @param {ResolvedService} resolved - The object returned by resolve().
142
+ * @param {ServiceStatus} resolved - The object returned by resolve().
88
143
  * @param {string} actualFingerprint - The fingerprint obtained from the service.
89
144
  * @returns {boolean}
90
145
  */
91
- verifyEndpoint(resolved: ResolvedService, actualFingerprint: string): boolean;
146
+ verifyEndpoint(resolved: ServiceStatus, actualFingerprint: string): boolean;
92
147
  }
93
- export type ResolvedService = {
148
+ export type ServiceStatus = {
94
149
  endpoint: string | undefined;
95
150
  fingerprint: string | undefined;
96
151
  expiry: number;
97
- attestations: any[];
152
+ isRevoked: boolean;
153
+ attestationCount: number;
154
+ attestations: Array<{
155
+ eventId: string;
156
+ level: string;
157
+ pubkey: string;
158
+ }>;
98
159
  eventId: string;
99
160
  pubkey: string;
161
+ serviceEvent: any;
162
+ isPrivate: boolean;
163
+ privateRecipients: string[];
100
164
  };
101
165
  import { SimplePool } from 'nostr-tools';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncc-02-js",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Nostr-native service discovery and trust implementation (NCC-02)",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './models.js';
2
2
  export * from './resolver.js';
3
3
  export * from './mockRelay.js';
4
+ export * from './privacy.js';
package/src/models.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { finalizeEvent, verifyEvent, getPublicKey } from 'nostr-tools/pure';
2
2
  import { hexToBytes } from 'nostr-tools/utils';
3
3
 
4
+ /**
5
+ * @typedef {Object} NostrSigner
6
+ * @property {() => Promise<string>} getPublicKey
7
+ * @property {(event: any) => Promise<any>} signEvent
8
+ * @property {(event: any) => Promise<any>} [decryptEvent]
9
+ */
10
+
4
11
  /**
5
12
  * NCC-02 Nostr Event Kinds
6
13
  */
@@ -15,12 +22,78 @@ export const KINDS = {
15
22
  */
16
23
  export class NCC02Builder {
17
24
  /**
18
- * @param {string | Uint8Array} privateKey - The private key to sign events with.
25
+ * @param {string | Uint8Array | NostrSigner} signer - Raw private key or asynchronous signer.
26
+ */
27
+ constructor(signer) {
28
+ if (!signer) throw new Error('Signer or private key is required');
29
+ this.signer = this._normalizeSigner(signer);
30
+ this._pubkeyPromise = this.signer.getPublicKey();
31
+ this._pubkey = undefined;
32
+ }
33
+
34
+ async _getPublicKey() {
35
+ if (!this._pubkey) {
36
+ this._pubkey = await this._pubkeyPromise;
37
+ }
38
+ return this._pubkey;
39
+ }
40
+
41
+ /**
42
+ * @param {any} event
43
+ */
44
+ async _finalizeEvent(event) {
45
+ const pubkey = await this._getPublicKey();
46
+ const eventWithPubkey = { ...event, pubkey };
47
+ const signed = await this.signer.signEvent(eventWithPubkey);
48
+ if (!signed || typeof signed.id !== 'string' || typeof signed.sig !== 'string') {
49
+ throw new Error('Signer must return a signed event with id and sig');
50
+ }
51
+ return signed;
52
+ }
53
+
54
+ /**
55
+ * @param {any} signer
56
+ * @returns {NostrSigner}
19
57
  */
20
- constructor(privateKey) {
21
- if (!privateKey) throw new Error('Private key is required');
22
- this.sk = typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey;
23
- this.pk = getPublicKey(this.sk);
58
+ _normalizeSigner(signer) {
59
+ if (typeof signer === 'string' || signer instanceof Uint8Array) {
60
+ const privateKey = typeof signer === 'string' ? hexToBytes(signer) : signer;
61
+ const pubkey = getPublicKey(privateKey);
62
+ return {
63
+ getPublicKey: async () => pubkey,
64
+ /** @param {any} event */
65
+ signEvent: async (event) => {
66
+ const clonedEvent = {
67
+ ...event,
68
+ tags: Array.isArray(event.tags) ? event.tags.map((/** @type {any[]} */ tag) => [...tag]) : []
69
+ };
70
+ return finalizeEvent(clonedEvent, privateKey);
71
+ }
72
+ };
73
+ }
74
+
75
+ if (typeof signer === 'object' && signer !== null) {
76
+ if (typeof signer.getPublicKey === 'function' && typeof signer.signEvent === 'function') {
77
+ return {
78
+ getPublicKey: async () => {
79
+ const pubkey = await signer.getPublicKey();
80
+ if (typeof pubkey !== 'string') throw new Error('Signer.getPublicKey must return a hex string');
81
+ return pubkey;
82
+ },
83
+ /** @param {any} event */
84
+ signEvent: async (event) => {
85
+ const signed = await signer.signEvent(event);
86
+ if (!signed || typeof signed.id !== 'string' || typeof signed.sig !== 'string') {
87
+ throw new Error('Signer.signEvent must return a signed event');
88
+ }
89
+ return signed;
90
+ },
91
+ decryptEvent: typeof signer.decryptEvent === 'function' ? signer.decryptEvent.bind(signer) : undefined
92
+ };
93
+ }
94
+ }
95
+
96
+ throw new Error('Unsupported signer provided to NCC02Builder');
24
97
  }
25
98
 
26
99
  /**
@@ -30,27 +103,37 @@ export class NCC02Builder {
30
103
  * @param {string} [options.endpoint] - The 'u' tag URI.
31
104
  * @param {string} [options.fingerprint] - The 'k' tag fingerprint.
32
105
  * @param {number} [options.expiryDays=14] - Expiry in days.
106
+ * @param {boolean} [options.isPrivate=false] - Whether the service is private (adds required `private` tag).
107
+ * @param {string[]} [options.privateRecipients] - Optional encrypted ciphertexts for authorized recipients.
33
108
  */
34
- createServiceRecord(options) {
35
- const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
109
+ async createServiceRecord(options) {
110
+ const { serviceId, endpoint, fingerprint, expiryDays = 14, isPrivate = false, privateRecipients } = options;
36
111
  if (!serviceId) throw new Error('serviceId (d tag) is required');
112
+ if (typeof isPrivate !== 'boolean') throw new Error('isPrivate must be a boolean value');
37
113
 
38
114
  const expiry = Math.floor(Date.now() / 1000) + (expiryDays * 24 * 60 * 60);
39
115
  const tags = [
40
116
  ['d', serviceId],
41
117
  ['exp', expiry.toString()]
42
118
  ];
43
- if(endpoint) tags.push(['u', endpoint]);
44
- if(fingerprint) tags.push(['k', fingerprint]);
119
+ tags.push(['private', isPrivate ? 'true' : 'false']);
120
+ if (endpoint) tags.push(['u', endpoint]);
121
+ if (fingerprint) tags.push(['k', fingerprint]);
122
+ if (privateRecipients) {
123
+ if (!Array.isArray(privateRecipients)) throw new Error('privateRecipients must be an array');
124
+ privateRecipients.forEach((cipher) => {
125
+ if (typeof cipher !== 'string') throw new Error('privateRecipients entries must be strings');
126
+ tags.push(['privateRecipients', cipher]);
127
+ });
128
+ }
45
129
 
46
130
  const event = {
47
131
  kind: KINDS.SERVICE_RECORD,
48
132
  created_at: Math.floor(Date.now() / 1000),
49
- tags: tags,
50
- content: `NCC-02 Service Record for ${serviceId}`,
51
- pubkey: this.pk
133
+ tags,
134
+ content: `NCC-02 Service Record for ${serviceId}`
52
135
  };
53
- return finalizeEvent(event, this.sk);
136
+ return this._finalizeEvent(event);
54
137
  }
55
138
 
56
139
  /**
@@ -62,7 +145,7 @@ export class NCC02Builder {
62
145
  * @param {string} [options.level='verified'] - The 'lvl' tag level.
63
146
  * @param {number} [options.validDays=30] - Validity in days.
64
147
  */
65
- createAttestation(options) {
148
+ async createAttestation(options) {
66
149
  const { subjectPubkey, serviceId, serviceEventId, level = 'verified', validDays = 30 } = options;
67
150
  if (!subjectPubkey) throw new Error('subjectPubkey is required');
68
151
  if (!serviceId) throw new Error('serviceId is required');
@@ -82,10 +165,9 @@ export class NCC02Builder {
82
165
  ['nbf', now.toString()],
83
166
  ['exp', expiry.toString()]
84
167
  ],
85
- content: 'NCC-02 Attestation',
86
- pubkey: this.pk
168
+ content: 'NCC-02 Attestation'
87
169
  };
88
- return finalizeEvent(event, this.sk);
170
+ return this._finalizeEvent(event);
89
171
  }
90
172
 
91
173
  /**
@@ -94,7 +176,7 @@ export class NCC02Builder {
94
176
  * @param {string} options.attestationId - The 'e' tag referencing the attestation.
95
177
  * @param {string} [options.reason=''] - Optional reason.
96
178
  */
97
- createRevocation(options) {
179
+ async createRevocation(options) {
98
180
  const { attestationId, reason = '' } = options;
99
181
  if (!attestationId) throw new Error('attestationId (e tag) is required');
100
182
 
@@ -104,11 +186,10 @@ export class NCC02Builder {
104
186
  const event = {
105
187
  kind: KINDS.REVOCATION,
106
188
  created_at: Math.floor(Date.now() / 1000),
107
- tags: tags,
108
- content: 'NCC-02 Revocation',
109
- pubkey: this.pk
189
+ tags,
190
+ content: 'NCC-02 Revocation'
110
191
  };
111
- return finalizeEvent(event, this.sk);
192
+ return this._finalizeEvent(event);
112
193
  }
113
194
  }
114
195
 
@@ -119,3 +200,17 @@ export class NCC02Builder {
119
200
  export function verifyNCC02Event(event) {
120
201
  return verifyEvent(event);
121
202
  }
203
+
204
+ /**
205
+ * Checks whether an NCC event has expired based on its 'exp' tag.
206
+ * @param {any} event
207
+ * @returns {boolean}
208
+ */
209
+ export function isExpired(event) {
210
+ if (!event || !Array.isArray(event.tags)) return false;
211
+ const expTag = event.tags.find((/** @type {any[]} */ tag) => tag[0] === 'exp');
212
+ if (!expTag) return false;
213
+ const expiry = parseInt(expTag[1], 10);
214
+ if (Number.isNaN(expiry)) return false;
215
+ return expiry <= Math.floor(Date.now() / 1000);
216
+ }
package/src/privacy.js ADDED
@@ -0,0 +1,217 @@
1
+ import { nip19, nip44 } from 'nostr-tools';
2
+ import { hexToBytes } from 'nostr-tools/utils';
3
+ import { getPublicKey } from 'nostr-tools/pure';
4
+
5
+ const PRIVATE_TAG = 'private';
6
+ const PRIVATE_RECIPIENTS_TAG = 'privateRecipients';
7
+ const HEX_REGEX = /^[0-9a-f]{64}$/i;
8
+
9
+ /**
10
+ * @typedef {Object} NipSigner
11
+ * @property {(thirdPartyPubkey: string, plaintext: string) => Promise<string> | string} [nip44Encrypt]
12
+ * @property {(ownerPubkey: string, ciphertext: string) => Promise<string> | string} [nip44Decrypt]
13
+ * @property {{encrypt: (thirdPartyPubkey: string, plaintext: string) => Promise<string> | string, decrypt: (ownerPubkey: string, ciphertext: string) => Promise<string> | string}} [nip04]
14
+ * @property {() => Promise<string> | string} [getPublicKey]
15
+ */
16
+
17
+ /**
18
+ * Normalize a pubkey string (hex or npub) into a lowercase hex string.
19
+ * @param {string} value
20
+ * @returns {string}
21
+ */
22
+ function normalizeHexPubkey(value) {
23
+ if (typeof value !== 'string') {
24
+ throw new Error('Pubkey must be a string');
25
+ }
26
+ const lower = value.toLowerCase();
27
+ if (HEX_REGEX.test(lower)) {
28
+ return lower;
29
+ }
30
+ try {
31
+ const decoded = nip19.decode(value);
32
+ if (decoded.type === 'npub' && typeof decoded.data === 'string') {
33
+ return decoded.data.toLowerCase();
34
+ }
35
+ } catch {
36
+ /** fall through */
37
+ }
38
+ throw new Error('Unsupported pubkey format');
39
+ }
40
+
41
+ /**
42
+ * Convert a lowercase hex pubkey into a canonical npub value.
43
+ * @param {string} hexPubkey
44
+ * @returns {string}
45
+ */
46
+ function toNpub(hexPubkey) {
47
+ return nip19.npubEncode(hexPubkey);
48
+ }
49
+
50
+ /**
51
+ * Ensure we work with Uint8Array private keys for NIP-44 helpers.
52
+ * @param {string|Uint8Array} key
53
+ * @returns {Uint8Array}
54
+ */
55
+ function toUint8ArrayKey(key) {
56
+ if (typeof key === 'string') {
57
+ return hexToBytes(key);
58
+ }
59
+ if (key instanceof Uint8Array) {
60
+ return key;
61
+ }
62
+ throw new Error('Private key must be a hex string or Uint8Array');
63
+ }
64
+
65
+ /**
66
+ * @param {string | Uint8Array | NipSigner} owner
67
+ * @returns {(recipientHex: string, plaintext: string) => Promise<string>}
68
+ */
69
+ function createNip44Encryptor(owner) {
70
+ if (typeof owner === 'string' || owner instanceof Uint8Array) {
71
+ const ownerKeyBytes = toUint8ArrayKey(owner);
72
+ return async (recipientHex, plaintext) => {
73
+ const conversationKey = nip44.getConversationKey(ownerKeyBytes, recipientHex);
74
+ return nip44.encrypt(plaintext, conversationKey);
75
+ };
76
+ }
77
+
78
+ if (owner && typeof owner === 'object') {
79
+ if (typeof owner.nip44Encrypt === 'function') {
80
+ const encryptFn = owner.nip44Encrypt.bind(owner);
81
+ return (recipientHex, plaintext) => Promise.resolve(encryptFn(recipientHex, plaintext));
82
+ }
83
+ if (owner.nip04 && typeof owner.nip04.encrypt === 'function') {
84
+ const encryptFn = owner.nip04.encrypt.bind(owner.nip04);
85
+ return (recipientHex, plaintext) => Promise.resolve(encryptFn(recipientHex, plaintext));
86
+ }
87
+ }
88
+
89
+ throw new Error('Unsupported owner signer; must be private key or NIP-44/NIP-04 capable signer');
90
+ }
91
+
92
+ /**
93
+ * @param {string | Uint8Array | NipSigner} recipient
94
+ * @returns {(ownerHex: string, ciphertext: string) => Promise<string>}
95
+ */
96
+ function createNip44Decryptor(recipient) {
97
+ if (typeof recipient === 'string' || recipient instanceof Uint8Array) {
98
+ const recipientKeyBytes = toUint8ArrayKey(recipient);
99
+ return async (ownerHex, ciphertext) => {
100
+ const conversationKey = nip44.getConversationKey(recipientKeyBytes, ownerHex);
101
+ return nip44.decrypt(ciphertext, conversationKey);
102
+ };
103
+ }
104
+
105
+ if (recipient && typeof recipient === 'object') {
106
+ if (typeof recipient.nip44Decrypt === 'function') {
107
+ const decryptFn = recipient.nip44Decrypt.bind(recipient);
108
+ return (ownerHex, ciphertext) => Promise.resolve(decryptFn(ownerHex, ciphertext));
109
+ }
110
+ if (recipient.nip04 && typeof recipient.nip04.decrypt === 'function') {
111
+ const decryptFn = recipient.nip04.decrypt.bind(recipient.nip04);
112
+ return (ownerHex, ciphertext) => Promise.resolve(decryptFn(ownerHex, ciphertext));
113
+ }
114
+ }
115
+
116
+ throw new Error('Unsupported recipient signer; must be private key or NIP-44/NIP-04 capable signer');
117
+ }
118
+
119
+ /**
120
+ * Resolve a pubkey for either a raw key or a signer object.
121
+ * @param {string | Uint8Array | NipSigner} recipient
122
+ * @returns {Promise<string>}
123
+ */
124
+ async function resolveRecipientPubkey(recipient) {
125
+ if (typeof recipient === 'string' || recipient instanceof Uint8Array) {
126
+ return normalizeHexPubkey(getPublicKey(toUint8ArrayKey(recipient)));
127
+ }
128
+ if (recipient && typeof recipient.getPublicKey === 'function') {
129
+ const pubkey = await recipient.getPublicKey();
130
+ return normalizeHexPubkey(pubkey);
131
+ }
132
+ throw new Error('Recipient must provide a private key or a NIP signer with getPublicKey()');
133
+ }
134
+
135
+ /**
136
+ * Parse the required `private` tag from an event.
137
+ * @param {any[]} tags
138
+ * @returns {boolean | null}
139
+ */
140
+ export function parsePrivateFlag(tags) {
141
+ if (!Array.isArray(tags)) return null;
142
+ const tag = tags.find((t) => Array.isArray(t) && t[0] === PRIVATE_TAG);
143
+ if (!tag || typeof tag[1] !== 'string') return null;
144
+ const normalized = tag[1].toLowerCase();
145
+ if (normalized === 'true') return true;
146
+ if (normalized === 'false') return false;
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Extract all `privateRecipients` ciphertext values from an event.
152
+ * @param {any[]} tags
153
+ * @returns {string[]}
154
+ */
155
+ export function collectPrivateRecipients(tags) {
156
+ if (!Array.isArray(tags)) return [];
157
+ return tags
158
+ .filter((t) => Array.isArray(t) && t[0] === PRIVATE_RECIPIENTS_TAG && typeof t[1] === 'string')
159
+ .map((t) => t[1]);
160
+ }
161
+
162
+ /**
163
+ * Encrypt a list of recipient pubkeys so they can be published in a service record.
164
+ * @param {string | Uint8Array | NipSigner} ownerPrivateKey
165
+ * @param {string[]} recipients
166
+ * @returns {Promise<string[]>}
167
+ */
168
+ export async function encryptPrivateRecipients(ownerPrivateKey, recipients) {
169
+ if (!Array.isArray(recipients)) {
170
+ throw new Error('recipients must be an array of pubkeys');
171
+ }
172
+ const encryptor = createNip44Encryptor(ownerPrivateKey);
173
+ const encrypted = [];
174
+ for (const recipient of recipients) {
175
+ const recipientHex = normalizeHexPubkey(recipient);
176
+ const recipientNpub = toNpub(recipientHex);
177
+ encrypted.push(await encryptor(recipientHex, recipientNpub));
178
+ }
179
+ return encrypted;
180
+ }
181
+
182
+ /**
183
+ * Decrypt a single private recipient ciphertext.
184
+ * @param {string} ciphertext
185
+ * @param {string} ownerPubkey
186
+ * @param {string | Uint8Array | NipSigner} recipientPrivateKey
187
+ * @returns {Promise<string>}
188
+ */
189
+ export async function decryptPrivateRecipient(ciphertext, ownerPubkey, recipientPrivateKey) {
190
+ const ownerHex = normalizeHexPubkey(ownerPubkey);
191
+ const decryptor = createNip44Decryptor(recipientPrivateKey);
192
+ return decryptor(ownerHex, ciphertext);
193
+ }
194
+
195
+ /**
196
+ * Check whether the provided private key matches one of the encrypted recipients.
197
+ * @param {string[]} privateRecipients
198
+ * @param {string} ownerPubkey
199
+ * @param {string | Uint8Array | NipSigner} recipientPrivateKey
200
+ * @returns {Promise<boolean>}
201
+ */
202
+ export async function isPrivateRecipientAuthorized(privateRecipients, ownerPubkey, recipientPrivateKey) {
203
+ if (!Array.isArray(privateRecipients) || privateRecipients.length === 0) return false;
204
+ const ownerHex = normalizeHexPubkey(ownerPubkey);
205
+ const recipientPubkey = await resolveRecipientPubkey(recipientPrivateKey);
206
+ const expectedNpub = toNpub(normalizeHexPubkey(recipientPubkey));
207
+ const decryptor = createNip44Decryptor(recipientPrivateKey);
208
+ for (const ciphertext of privateRecipients) {
209
+ try {
210
+ const decrypted = await decryptor(ownerHex, ciphertext);
211
+ if (decrypted === expectedNpub) return true;
212
+ } catch {
213
+ // ignore decrypt errors, ciphertext might not target this recipient
214
+ }
215
+ }
216
+ return false;
217
+ }