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.
- package/README.md +93 -21
- package/dist/index.cjs +375 -86
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +369 -86
- package/dist/models.d.ts +33 -7
- package/dist/privacy.d.ts +44 -0
- package/dist/resolver.d.ts +78 -14
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/models.js +117 -22
- package/src/privacy.js +217 -0
- package/src/resolver.js +162 -88
|
@@ -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
|
+
};
|
package/dist/resolver.d.ts
CHANGED
|
@@ -12,13 +12,18 @@ export class NCC02Error extends Error {
|
|
|
12
12
|
cause: any;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* @typedef {Object}
|
|
15
|
+
* @typedef {Object} ServiceStatus
|
|
16
16
|
* @property {string|undefined} endpoint
|
|
17
17
|
* @property {string|undefined} fingerprint
|
|
18
18
|
* @property {number} expiry
|
|
19
|
-
* @property {
|
|
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
|
-
|
|
65
|
-
|
|
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<
|
|
88
|
+
}): Promise<ServiceStatus>;
|
|
72
89
|
/**
|
|
73
|
-
* @param {
|
|
74
|
-
* @param {
|
|
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
|
-
|
|
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 {
|
|
121
|
+
* @param {Record<string, string>} tags
|
|
80
122
|
* @param {any[]} revocations
|
|
81
123
|
*/
|
|
82
|
-
|
|
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 {
|
|
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:
|
|
146
|
+
verifyEndpoint(resolved: ServiceStatus, actualFingerprint: string): boolean;
|
|
92
147
|
}
|
|
93
|
-
export type
|
|
148
|
+
export type ServiceStatus = {
|
|
94
149
|
endpoint: string | undefined;
|
|
95
150
|
fingerprint: string | undefined;
|
|
96
151
|
expiry: number;
|
|
97
|
-
|
|
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
package/src/index.js
CHANGED
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}
|
|
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
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
44
|
-
if(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
108
|
-
content: 'NCC-02 Revocation'
|
|
109
|
-
pubkey: this.pk
|
|
189
|
+
tags,
|
|
190
|
+
content: 'NCC-02 Revocation'
|
|
110
191
|
};
|
|
111
|
-
return
|
|
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
|
+
}
|