ncc-02-js 0.4.0 → 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 +4 -0
- package/dist/index.cjs +158 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +153 -1
- package/dist/models.d.ts +4 -0
- package/dist/privacy.d.ts +44 -0
- package/dist/resolver.d.ts +4 -0
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/models.js +12 -1
- package/src/privacy.js +217 -0
- package/src/resolver.js +9 -0
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ This library provides tools for service owners to publish records and for client
|
|
|
10
10
|
- **Verification**: Built-in signature and expiry validation.
|
|
11
11
|
- **Trust Policy**: Support for third-party attestations (Kind 30060) and revocations (Kind 30061).
|
|
12
12
|
- **Security**: Cross-validation of subject and service identifiers to prevent impersonation.
|
|
13
|
+
- **Privacy Controls**: Required `private` tags and optional encrypted `privateRecipients` listings let you declare visibility and invite-only recipients.
|
|
13
14
|
- **Fail-Closed**: Explicit error reporting for policy or verification failures.
|
|
14
15
|
|
|
15
16
|
## Installation
|
|
@@ -78,6 +79,9 @@ await builder.createRevocation({
|
|
|
78
79
|
|
|
79
80
|
The builder helpers emit the expected NCC-02 event kinds: Kind 30059 for service records, 30060 for attestations, and 30061 for revocations. `createServiceRecord` always includes the `d` and `exp` tags while optionally populating `u` and `k`. Attestations include `subj`, `srv`, `e`, `std`, `lvl`, `nbf`, and `exp`. Revocations only need the `e` tag and an optional reason. You can supply either a raw private key (hex string or `Uint8Array`) or a signer implementing `getPublicKey`/`signEvent`.
|
|
80
81
|
|
|
82
|
+
#### Private Service Metadata
|
|
83
|
+
Service records now require a boolean `private` tag (set via `isPrivate` when calling `createServiceRecord`). When private services should only be used by a curated set of users, you can provide pre-encrypted `privateRecipients` values that contain authorized `npub` identifiers. The helper utilities derive NIP-44 conversation keys: `await encryptPrivateRecipients(ownerPrivateKey, recipients)` when publishing so each recipient gets a ciphertext they can decrypt, and `await isPrivateRecipientAuthorized(privateRecipients, ownerPubkey, recipientPrivateKey)` on the client-side to verify if the signed-in key is on the allowlist (or `await decryptPrivateRecipient(...)` if you need the raw `npub`). The helpers accept either raw private keys or NIP-07/NIP-46 style signer objects (they will call `nip44Encrypt/nip44Decrypt` or fall back to legacy `nip04` if present). The resolver surfaces `isPrivate` and `privateRecipients` on the returned `ServiceStatus`.
|
|
84
|
+
|
|
81
85
|
### 3. Trust Model & Security
|
|
82
86
|
|
|
83
87
|
The resolver treats the service owner’s pubkey as the root authority. Certificates and revocations are additive layers that you opt into via `requireAttestation` or `minLevel`. Validation failures surface as `NCC02Error` instances with codes such as `INVALID_SIGNATURE`, `EXPIRED`, or `POLICY_FAILURE`, helping clients react instead of defaulting to insecure fallbacks.
|
package/dist/index.cjs
CHANGED
|
@@ -34,7 +34,12 @@ __export(index_exports, {
|
|
|
34
34
|
NCC02Builder: () => NCC02Builder,
|
|
35
35
|
NCC02Error: () => NCC02Error,
|
|
36
36
|
NCC02Resolver: () => NCC02Resolver,
|
|
37
|
+
collectPrivateRecipients: () => collectPrivateRecipients,
|
|
38
|
+
decryptPrivateRecipient: () => decryptPrivateRecipient,
|
|
39
|
+
encryptPrivateRecipients: () => encryptPrivateRecipients,
|
|
37
40
|
isExpired: () => isExpired,
|
|
41
|
+
isPrivateRecipientAuthorized: () => isPrivateRecipientAuthorized,
|
|
42
|
+
parsePrivateFlag: () => parsePrivateFlag,
|
|
38
43
|
verifyNCC02Event: () => verifyNCC02Event
|
|
39
44
|
});
|
|
40
45
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -2574,17 +2579,28 @@ var NCC02Builder = class {
|
|
|
2574
2579
|
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
2575
2580
|
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
2576
2581
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
2582
|
+
* @param {boolean} [options.isPrivate=false] - Whether the service is private (adds required `private` tag).
|
|
2583
|
+
* @param {string[]} [options.privateRecipients] - Optional encrypted ciphertexts for authorized recipients.
|
|
2577
2584
|
*/
|
|
2578
2585
|
async createServiceRecord(options) {
|
|
2579
|
-
const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
|
|
2586
|
+
const { serviceId, endpoint, fingerprint, expiryDays = 14, isPrivate = false, privateRecipients } = options;
|
|
2580
2587
|
if (!serviceId) throw new Error("serviceId (d tag) is required");
|
|
2588
|
+
if (typeof isPrivate !== "boolean") throw new Error("isPrivate must be a boolean value");
|
|
2581
2589
|
const expiry = Math.floor(Date.now() / 1e3) + expiryDays * 24 * 60 * 60;
|
|
2582
2590
|
const tags = [
|
|
2583
2591
|
["d", serviceId],
|
|
2584
2592
|
["exp", expiry.toString()]
|
|
2585
2593
|
];
|
|
2594
|
+
tags.push(["private", isPrivate ? "true" : "false"]);
|
|
2586
2595
|
if (endpoint) tags.push(["u", endpoint]);
|
|
2587
2596
|
if (fingerprint) tags.push(["k", fingerprint]);
|
|
2597
|
+
if (privateRecipients) {
|
|
2598
|
+
if (!Array.isArray(privateRecipients)) throw new Error("privateRecipients must be an array");
|
|
2599
|
+
privateRecipients.forEach((cipher) => {
|
|
2600
|
+
if (typeof cipher !== "string") throw new Error("privateRecipients entries must be strings");
|
|
2601
|
+
tags.push(["privateRecipients", cipher]);
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2588
2604
|
const event = {
|
|
2589
2605
|
kind: KINDS.SERVICE_RECORD,
|
|
2590
2606
|
created_at: Math.floor(Date.now() / 1e3),
|
|
@@ -7727,6 +7743,136 @@ async function validateEvent22(event, url, method, body) {
|
|
|
7727
7743
|
return true;
|
|
7728
7744
|
}
|
|
7729
7745
|
|
|
7746
|
+
// src/privacy.js
|
|
7747
|
+
var PRIVATE_TAG = "private";
|
|
7748
|
+
var PRIVATE_RECIPIENTS_TAG = "privateRecipients";
|
|
7749
|
+
var HEX_REGEX = /^[0-9a-f]{64}$/i;
|
|
7750
|
+
function normalizeHexPubkey(value) {
|
|
7751
|
+
if (typeof value !== "string") {
|
|
7752
|
+
throw new Error("Pubkey must be a string");
|
|
7753
|
+
}
|
|
7754
|
+
const lower = value.toLowerCase();
|
|
7755
|
+
if (HEX_REGEX.test(lower)) {
|
|
7756
|
+
return lower;
|
|
7757
|
+
}
|
|
7758
|
+
try {
|
|
7759
|
+
const decoded = nip19_exports.decode(value);
|
|
7760
|
+
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
|
7761
|
+
return decoded.data.toLowerCase();
|
|
7762
|
+
}
|
|
7763
|
+
} catch {
|
|
7764
|
+
}
|
|
7765
|
+
throw new Error("Unsupported pubkey format");
|
|
7766
|
+
}
|
|
7767
|
+
function toNpub(hexPubkey) {
|
|
7768
|
+
return nip19_exports.npubEncode(hexPubkey);
|
|
7769
|
+
}
|
|
7770
|
+
function toUint8ArrayKey(key) {
|
|
7771
|
+
if (typeof key === "string") {
|
|
7772
|
+
return hexToBytes2(key);
|
|
7773
|
+
}
|
|
7774
|
+
if (key instanceof Uint8Array) {
|
|
7775
|
+
return key;
|
|
7776
|
+
}
|
|
7777
|
+
throw new Error("Private key must be a hex string or Uint8Array");
|
|
7778
|
+
}
|
|
7779
|
+
function createNip44Encryptor(owner) {
|
|
7780
|
+
if (typeof owner === "string" || owner instanceof Uint8Array) {
|
|
7781
|
+
const ownerKeyBytes = toUint8ArrayKey(owner);
|
|
7782
|
+
return async (recipientHex, plaintext) => {
|
|
7783
|
+
const conversationKey = nip44_exports.getConversationKey(ownerKeyBytes, recipientHex);
|
|
7784
|
+
return nip44_exports.encrypt(plaintext, conversationKey);
|
|
7785
|
+
};
|
|
7786
|
+
}
|
|
7787
|
+
if (owner && typeof owner === "object") {
|
|
7788
|
+
if (typeof owner.nip44Encrypt === "function") {
|
|
7789
|
+
const encryptFn = owner.nip44Encrypt.bind(owner);
|
|
7790
|
+
return (recipientHex, plaintext) => Promise.resolve(encryptFn(recipientHex, plaintext));
|
|
7791
|
+
}
|
|
7792
|
+
if (owner.nip04 && typeof owner.nip04.encrypt === "function") {
|
|
7793
|
+
const encryptFn = owner.nip04.encrypt.bind(owner.nip04);
|
|
7794
|
+
return (recipientHex, plaintext) => Promise.resolve(encryptFn(recipientHex, plaintext));
|
|
7795
|
+
}
|
|
7796
|
+
}
|
|
7797
|
+
throw new Error("Unsupported owner signer; must be private key or NIP-44/NIP-04 capable signer");
|
|
7798
|
+
}
|
|
7799
|
+
function createNip44Decryptor(recipient) {
|
|
7800
|
+
if (typeof recipient === "string" || recipient instanceof Uint8Array) {
|
|
7801
|
+
const recipientKeyBytes = toUint8ArrayKey(recipient);
|
|
7802
|
+
return async (ownerHex, ciphertext) => {
|
|
7803
|
+
const conversationKey = nip44_exports.getConversationKey(recipientKeyBytes, ownerHex);
|
|
7804
|
+
return nip44_exports.decrypt(ciphertext, conversationKey);
|
|
7805
|
+
};
|
|
7806
|
+
}
|
|
7807
|
+
if (recipient && typeof recipient === "object") {
|
|
7808
|
+
if (typeof recipient.nip44Decrypt === "function") {
|
|
7809
|
+
const decryptFn = recipient.nip44Decrypt.bind(recipient);
|
|
7810
|
+
return (ownerHex, ciphertext) => Promise.resolve(decryptFn(ownerHex, ciphertext));
|
|
7811
|
+
}
|
|
7812
|
+
if (recipient.nip04 && typeof recipient.nip04.decrypt === "function") {
|
|
7813
|
+
const decryptFn = recipient.nip04.decrypt.bind(recipient.nip04);
|
|
7814
|
+
return (ownerHex, ciphertext) => Promise.resolve(decryptFn(ownerHex, ciphertext));
|
|
7815
|
+
}
|
|
7816
|
+
}
|
|
7817
|
+
throw new Error("Unsupported recipient signer; must be private key or NIP-44/NIP-04 capable signer");
|
|
7818
|
+
}
|
|
7819
|
+
async function resolveRecipientPubkey(recipient) {
|
|
7820
|
+
if (typeof recipient === "string" || recipient instanceof Uint8Array) {
|
|
7821
|
+
return normalizeHexPubkey(getPublicKey(toUint8ArrayKey(recipient)));
|
|
7822
|
+
}
|
|
7823
|
+
if (recipient && typeof recipient.getPublicKey === "function") {
|
|
7824
|
+
const pubkey = await recipient.getPublicKey();
|
|
7825
|
+
return normalizeHexPubkey(pubkey);
|
|
7826
|
+
}
|
|
7827
|
+
throw new Error("Recipient must provide a private key or a NIP signer with getPublicKey()");
|
|
7828
|
+
}
|
|
7829
|
+
function parsePrivateFlag(tags) {
|
|
7830
|
+
if (!Array.isArray(tags)) return null;
|
|
7831
|
+
const tag = tags.find((t) => Array.isArray(t) && t[0] === PRIVATE_TAG);
|
|
7832
|
+
if (!tag || typeof tag[1] !== "string") return null;
|
|
7833
|
+
const normalized = tag[1].toLowerCase();
|
|
7834
|
+
if (normalized === "true") return true;
|
|
7835
|
+
if (normalized === "false") return false;
|
|
7836
|
+
return null;
|
|
7837
|
+
}
|
|
7838
|
+
function collectPrivateRecipients(tags) {
|
|
7839
|
+
if (!Array.isArray(tags)) return [];
|
|
7840
|
+
return tags.filter((t) => Array.isArray(t) && t[0] === PRIVATE_RECIPIENTS_TAG && typeof t[1] === "string").map((t) => t[1]);
|
|
7841
|
+
}
|
|
7842
|
+
async function encryptPrivateRecipients(ownerPrivateKey, recipients) {
|
|
7843
|
+
if (!Array.isArray(recipients)) {
|
|
7844
|
+
throw new Error("recipients must be an array of pubkeys");
|
|
7845
|
+
}
|
|
7846
|
+
const encryptor = createNip44Encryptor(ownerPrivateKey);
|
|
7847
|
+
const encrypted = [];
|
|
7848
|
+
for (const recipient of recipients) {
|
|
7849
|
+
const recipientHex = normalizeHexPubkey(recipient);
|
|
7850
|
+
const recipientNpub = toNpub(recipientHex);
|
|
7851
|
+
encrypted.push(await encryptor(recipientHex, recipientNpub));
|
|
7852
|
+
}
|
|
7853
|
+
return encrypted;
|
|
7854
|
+
}
|
|
7855
|
+
async function decryptPrivateRecipient(ciphertext, ownerPubkey, recipientPrivateKey) {
|
|
7856
|
+
const ownerHex = normalizeHexPubkey(ownerPubkey);
|
|
7857
|
+
const decryptor = createNip44Decryptor(recipientPrivateKey);
|
|
7858
|
+
return decryptor(ownerHex, ciphertext);
|
|
7859
|
+
}
|
|
7860
|
+
async function isPrivateRecipientAuthorized(privateRecipients, ownerPubkey, recipientPrivateKey) {
|
|
7861
|
+
if (!Array.isArray(privateRecipients) || privateRecipients.length === 0) return false;
|
|
7862
|
+
const ownerHex = normalizeHexPubkey(ownerPubkey);
|
|
7863
|
+
const recipientPubkey = await resolveRecipientPubkey(recipientPrivateKey);
|
|
7864
|
+
const expectedNpub = toNpub(normalizeHexPubkey(recipientPubkey));
|
|
7865
|
+
const decryptor = createNip44Decryptor(recipientPrivateKey);
|
|
7866
|
+
for (const ciphertext of privateRecipients) {
|
|
7867
|
+
try {
|
|
7868
|
+
const decrypted = await decryptor(ownerHex, ciphertext);
|
|
7869
|
+
if (decrypted === expectedNpub) return true;
|
|
7870
|
+
} catch {
|
|
7871
|
+
}
|
|
7872
|
+
}
|
|
7873
|
+
return false;
|
|
7874
|
+
}
|
|
7875
|
+
|
|
7730
7876
|
// src/resolver.js
|
|
7731
7877
|
var NCC02Error = class extends Error {
|
|
7732
7878
|
/**
|
|
@@ -7843,6 +7989,10 @@ var NCC02Resolver = class {
|
|
|
7843
7989
|
}
|
|
7844
7990
|
const serviceTags = Object.fromEntries(serviceEvent.tags);
|
|
7845
7991
|
const now2 = Math.floor(Date.now() / 1e3);
|
|
7992
|
+
const privateFlag = parsePrivateFlag(serviceEvent.tags);
|
|
7993
|
+
if (privateFlag === null) {
|
|
7994
|
+
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (private)");
|
|
7995
|
+
}
|
|
7846
7996
|
if (!serviceTags.exp) {
|
|
7847
7997
|
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (exp)");
|
|
7848
7998
|
}
|
|
@@ -7872,6 +8022,8 @@ var NCC02Resolver = class {
|
|
|
7872
8022
|
attestations: trustData.validAttestations,
|
|
7873
8023
|
attestationCount: trustData.validAttestations.length,
|
|
7874
8024
|
isRevoked: trustData.isRevoked,
|
|
8025
|
+
isPrivate: privateFlag,
|
|
8026
|
+
privateRecipients: collectPrivateRecipients(serviceEvent.tags),
|
|
7875
8027
|
eventId: serviceEvent.id,
|
|
7876
8028
|
pubkey: serviceEvent.pubkey,
|
|
7877
8029
|
serviceEvent
|
|
@@ -8042,7 +8194,12 @@ var MockRelay = class {
|
|
|
8042
8194
|
NCC02Builder,
|
|
8043
8195
|
NCC02Error,
|
|
8044
8196
|
NCC02Resolver,
|
|
8197
|
+
collectPrivateRecipients,
|
|
8198
|
+
decryptPrivateRecipient,
|
|
8199
|
+
encryptPrivateRecipients,
|
|
8045
8200
|
isExpired,
|
|
8201
|
+
isPrivateRecipientAuthorized,
|
|
8202
|
+
parsePrivateFlag,
|
|
8046
8203
|
verifyNCC02Event
|
|
8047
8204
|
});
|
|
8048
8205
|
/*! Bundled license information:
|
package/dist/index.d.ts
CHANGED
package/dist/index.mjs
CHANGED
|
@@ -2539,17 +2539,28 @@ var NCC02Builder = class {
|
|
|
2539
2539
|
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
2540
2540
|
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
2541
2541
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
2542
|
+
* @param {boolean} [options.isPrivate=false] - Whether the service is private (adds required `private` tag).
|
|
2543
|
+
* @param {string[]} [options.privateRecipients] - Optional encrypted ciphertexts for authorized recipients.
|
|
2542
2544
|
*/
|
|
2543
2545
|
async createServiceRecord(options) {
|
|
2544
|
-
const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
|
|
2546
|
+
const { serviceId, endpoint, fingerprint, expiryDays = 14, isPrivate = false, privateRecipients } = options;
|
|
2545
2547
|
if (!serviceId) throw new Error("serviceId (d tag) is required");
|
|
2548
|
+
if (typeof isPrivate !== "boolean") throw new Error("isPrivate must be a boolean value");
|
|
2546
2549
|
const expiry = Math.floor(Date.now() / 1e3) + expiryDays * 24 * 60 * 60;
|
|
2547
2550
|
const tags = [
|
|
2548
2551
|
["d", serviceId],
|
|
2549
2552
|
["exp", expiry.toString()]
|
|
2550
2553
|
];
|
|
2554
|
+
tags.push(["private", isPrivate ? "true" : "false"]);
|
|
2551
2555
|
if (endpoint) tags.push(["u", endpoint]);
|
|
2552
2556
|
if (fingerprint) tags.push(["k", fingerprint]);
|
|
2557
|
+
if (privateRecipients) {
|
|
2558
|
+
if (!Array.isArray(privateRecipients)) throw new Error("privateRecipients must be an array");
|
|
2559
|
+
privateRecipients.forEach((cipher) => {
|
|
2560
|
+
if (typeof cipher !== "string") throw new Error("privateRecipients entries must be strings");
|
|
2561
|
+
tags.push(["privateRecipients", cipher]);
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2553
2564
|
const event = {
|
|
2554
2565
|
kind: KINDS.SERVICE_RECORD,
|
|
2555
2566
|
created_at: Math.floor(Date.now() / 1e3),
|
|
@@ -7692,6 +7703,136 @@ async function validateEvent22(event, url, method, body) {
|
|
|
7692
7703
|
return true;
|
|
7693
7704
|
}
|
|
7694
7705
|
|
|
7706
|
+
// src/privacy.js
|
|
7707
|
+
var PRIVATE_TAG = "private";
|
|
7708
|
+
var PRIVATE_RECIPIENTS_TAG = "privateRecipients";
|
|
7709
|
+
var HEX_REGEX = /^[0-9a-f]{64}$/i;
|
|
7710
|
+
function normalizeHexPubkey(value) {
|
|
7711
|
+
if (typeof value !== "string") {
|
|
7712
|
+
throw new Error("Pubkey must be a string");
|
|
7713
|
+
}
|
|
7714
|
+
const lower = value.toLowerCase();
|
|
7715
|
+
if (HEX_REGEX.test(lower)) {
|
|
7716
|
+
return lower;
|
|
7717
|
+
}
|
|
7718
|
+
try {
|
|
7719
|
+
const decoded = nip19_exports.decode(value);
|
|
7720
|
+
if (decoded.type === "npub" && typeof decoded.data === "string") {
|
|
7721
|
+
return decoded.data.toLowerCase();
|
|
7722
|
+
}
|
|
7723
|
+
} catch {
|
|
7724
|
+
}
|
|
7725
|
+
throw new Error("Unsupported pubkey format");
|
|
7726
|
+
}
|
|
7727
|
+
function toNpub(hexPubkey) {
|
|
7728
|
+
return nip19_exports.npubEncode(hexPubkey);
|
|
7729
|
+
}
|
|
7730
|
+
function toUint8ArrayKey(key) {
|
|
7731
|
+
if (typeof key === "string") {
|
|
7732
|
+
return hexToBytes2(key);
|
|
7733
|
+
}
|
|
7734
|
+
if (key instanceof Uint8Array) {
|
|
7735
|
+
return key;
|
|
7736
|
+
}
|
|
7737
|
+
throw new Error("Private key must be a hex string or Uint8Array");
|
|
7738
|
+
}
|
|
7739
|
+
function createNip44Encryptor(owner) {
|
|
7740
|
+
if (typeof owner === "string" || owner instanceof Uint8Array) {
|
|
7741
|
+
const ownerKeyBytes = toUint8ArrayKey(owner);
|
|
7742
|
+
return async (recipientHex, plaintext) => {
|
|
7743
|
+
const conversationKey = nip44_exports.getConversationKey(ownerKeyBytes, recipientHex);
|
|
7744
|
+
return nip44_exports.encrypt(plaintext, conversationKey);
|
|
7745
|
+
};
|
|
7746
|
+
}
|
|
7747
|
+
if (owner && typeof owner === "object") {
|
|
7748
|
+
if (typeof owner.nip44Encrypt === "function") {
|
|
7749
|
+
const encryptFn = owner.nip44Encrypt.bind(owner);
|
|
7750
|
+
return (recipientHex, plaintext) => Promise.resolve(encryptFn(recipientHex, plaintext));
|
|
7751
|
+
}
|
|
7752
|
+
if (owner.nip04 && typeof owner.nip04.encrypt === "function") {
|
|
7753
|
+
const encryptFn = owner.nip04.encrypt.bind(owner.nip04);
|
|
7754
|
+
return (recipientHex, plaintext) => Promise.resolve(encryptFn(recipientHex, plaintext));
|
|
7755
|
+
}
|
|
7756
|
+
}
|
|
7757
|
+
throw new Error("Unsupported owner signer; must be private key or NIP-44/NIP-04 capable signer");
|
|
7758
|
+
}
|
|
7759
|
+
function createNip44Decryptor(recipient) {
|
|
7760
|
+
if (typeof recipient === "string" || recipient instanceof Uint8Array) {
|
|
7761
|
+
const recipientKeyBytes = toUint8ArrayKey(recipient);
|
|
7762
|
+
return async (ownerHex, ciphertext) => {
|
|
7763
|
+
const conversationKey = nip44_exports.getConversationKey(recipientKeyBytes, ownerHex);
|
|
7764
|
+
return nip44_exports.decrypt(ciphertext, conversationKey);
|
|
7765
|
+
};
|
|
7766
|
+
}
|
|
7767
|
+
if (recipient && typeof recipient === "object") {
|
|
7768
|
+
if (typeof recipient.nip44Decrypt === "function") {
|
|
7769
|
+
const decryptFn = recipient.nip44Decrypt.bind(recipient);
|
|
7770
|
+
return (ownerHex, ciphertext) => Promise.resolve(decryptFn(ownerHex, ciphertext));
|
|
7771
|
+
}
|
|
7772
|
+
if (recipient.nip04 && typeof recipient.nip04.decrypt === "function") {
|
|
7773
|
+
const decryptFn = recipient.nip04.decrypt.bind(recipient.nip04);
|
|
7774
|
+
return (ownerHex, ciphertext) => Promise.resolve(decryptFn(ownerHex, ciphertext));
|
|
7775
|
+
}
|
|
7776
|
+
}
|
|
7777
|
+
throw new Error("Unsupported recipient signer; must be private key or NIP-44/NIP-04 capable signer");
|
|
7778
|
+
}
|
|
7779
|
+
async function resolveRecipientPubkey(recipient) {
|
|
7780
|
+
if (typeof recipient === "string" || recipient instanceof Uint8Array) {
|
|
7781
|
+
return normalizeHexPubkey(getPublicKey(toUint8ArrayKey(recipient)));
|
|
7782
|
+
}
|
|
7783
|
+
if (recipient && typeof recipient.getPublicKey === "function") {
|
|
7784
|
+
const pubkey = await recipient.getPublicKey();
|
|
7785
|
+
return normalizeHexPubkey(pubkey);
|
|
7786
|
+
}
|
|
7787
|
+
throw new Error("Recipient must provide a private key or a NIP signer with getPublicKey()");
|
|
7788
|
+
}
|
|
7789
|
+
function parsePrivateFlag(tags) {
|
|
7790
|
+
if (!Array.isArray(tags)) return null;
|
|
7791
|
+
const tag = tags.find((t) => Array.isArray(t) && t[0] === PRIVATE_TAG);
|
|
7792
|
+
if (!tag || typeof tag[1] !== "string") return null;
|
|
7793
|
+
const normalized = tag[1].toLowerCase();
|
|
7794
|
+
if (normalized === "true") return true;
|
|
7795
|
+
if (normalized === "false") return false;
|
|
7796
|
+
return null;
|
|
7797
|
+
}
|
|
7798
|
+
function collectPrivateRecipients(tags) {
|
|
7799
|
+
if (!Array.isArray(tags)) return [];
|
|
7800
|
+
return tags.filter((t) => Array.isArray(t) && t[0] === PRIVATE_RECIPIENTS_TAG && typeof t[1] === "string").map((t) => t[1]);
|
|
7801
|
+
}
|
|
7802
|
+
async function encryptPrivateRecipients(ownerPrivateKey, recipients) {
|
|
7803
|
+
if (!Array.isArray(recipients)) {
|
|
7804
|
+
throw new Error("recipients must be an array of pubkeys");
|
|
7805
|
+
}
|
|
7806
|
+
const encryptor = createNip44Encryptor(ownerPrivateKey);
|
|
7807
|
+
const encrypted = [];
|
|
7808
|
+
for (const recipient of recipients) {
|
|
7809
|
+
const recipientHex = normalizeHexPubkey(recipient);
|
|
7810
|
+
const recipientNpub = toNpub(recipientHex);
|
|
7811
|
+
encrypted.push(await encryptor(recipientHex, recipientNpub));
|
|
7812
|
+
}
|
|
7813
|
+
return encrypted;
|
|
7814
|
+
}
|
|
7815
|
+
async function decryptPrivateRecipient(ciphertext, ownerPubkey, recipientPrivateKey) {
|
|
7816
|
+
const ownerHex = normalizeHexPubkey(ownerPubkey);
|
|
7817
|
+
const decryptor = createNip44Decryptor(recipientPrivateKey);
|
|
7818
|
+
return decryptor(ownerHex, ciphertext);
|
|
7819
|
+
}
|
|
7820
|
+
async function isPrivateRecipientAuthorized(privateRecipients, ownerPubkey, recipientPrivateKey) {
|
|
7821
|
+
if (!Array.isArray(privateRecipients) || privateRecipients.length === 0) return false;
|
|
7822
|
+
const ownerHex = normalizeHexPubkey(ownerPubkey);
|
|
7823
|
+
const recipientPubkey = await resolveRecipientPubkey(recipientPrivateKey);
|
|
7824
|
+
const expectedNpub = toNpub(normalizeHexPubkey(recipientPubkey));
|
|
7825
|
+
const decryptor = createNip44Decryptor(recipientPrivateKey);
|
|
7826
|
+
for (const ciphertext of privateRecipients) {
|
|
7827
|
+
try {
|
|
7828
|
+
const decrypted = await decryptor(ownerHex, ciphertext);
|
|
7829
|
+
if (decrypted === expectedNpub) return true;
|
|
7830
|
+
} catch {
|
|
7831
|
+
}
|
|
7832
|
+
}
|
|
7833
|
+
return false;
|
|
7834
|
+
}
|
|
7835
|
+
|
|
7695
7836
|
// src/resolver.js
|
|
7696
7837
|
var NCC02Error = class extends Error {
|
|
7697
7838
|
/**
|
|
@@ -7808,6 +7949,10 @@ var NCC02Resolver = class {
|
|
|
7808
7949
|
}
|
|
7809
7950
|
const serviceTags = Object.fromEntries(serviceEvent.tags);
|
|
7810
7951
|
const now2 = Math.floor(Date.now() / 1e3);
|
|
7952
|
+
const privateFlag = parsePrivateFlag(serviceEvent.tags);
|
|
7953
|
+
if (privateFlag === null) {
|
|
7954
|
+
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (private)");
|
|
7955
|
+
}
|
|
7811
7956
|
if (!serviceTags.exp) {
|
|
7812
7957
|
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (exp)");
|
|
7813
7958
|
}
|
|
@@ -7837,6 +7982,8 @@ var NCC02Resolver = class {
|
|
|
7837
7982
|
attestations: trustData.validAttestations,
|
|
7838
7983
|
attestationCount: trustData.validAttestations.length,
|
|
7839
7984
|
isRevoked: trustData.isRevoked,
|
|
7985
|
+
isPrivate: privateFlag,
|
|
7986
|
+
privateRecipients: collectPrivateRecipients(serviceEvent.tags),
|
|
7840
7987
|
eventId: serviceEvent.id,
|
|
7841
7988
|
pubkey: serviceEvent.pubkey,
|
|
7842
7989
|
serviceEvent
|
|
@@ -8006,7 +8153,12 @@ export {
|
|
|
8006
8153
|
NCC02Builder,
|
|
8007
8154
|
NCC02Error,
|
|
8008
8155
|
NCC02Resolver,
|
|
8156
|
+
collectPrivateRecipients,
|
|
8157
|
+
decryptPrivateRecipient,
|
|
8158
|
+
encryptPrivateRecipients,
|
|
8009
8159
|
isExpired,
|
|
8160
|
+
isPrivateRecipientAuthorized,
|
|
8161
|
+
parsePrivateFlag,
|
|
8010
8162
|
verifyNCC02Event
|
|
8011
8163
|
};
|
|
8012
8164
|
/*! Bundled license information:
|
package/dist/models.d.ts
CHANGED
|
@@ -42,12 +42,16 @@ export class NCC02Builder {
|
|
|
42
42
|
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
43
43
|
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
44
44
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
45
|
+
* @param {boolean} [options.isPrivate=false] - Whether the service is private (adds required `private` tag).
|
|
46
|
+
* @param {string[]} [options.privateRecipients] - Optional encrypted ciphertexts for authorized recipients.
|
|
45
47
|
*/
|
|
46
48
|
createServiceRecord(options: {
|
|
47
49
|
serviceId: string;
|
|
48
50
|
endpoint?: string;
|
|
49
51
|
fingerprint?: string;
|
|
50
52
|
expiryDays?: number;
|
|
53
|
+
isPrivate?: boolean;
|
|
54
|
+
privateRecipients?: string[];
|
|
51
55
|
}): Promise<any>;
|
|
52
56
|
/**
|
|
53
57
|
* Creates a signed Certificate Attestation (Kind 30060).
|
|
@@ -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
|
@@ -22,6 +22,8 @@ export class NCC02Error extends Error {
|
|
|
22
22
|
* @property {string} eventId
|
|
23
23
|
* @property {string} pubkey
|
|
24
24
|
* @property {any} serviceEvent
|
|
25
|
+
* @property {boolean} isPrivate
|
|
26
|
+
* @property {string[]} privateRecipients
|
|
25
27
|
*/
|
|
26
28
|
/**
|
|
27
29
|
* Resolver for NCC-02 Service Records.
|
|
@@ -157,5 +159,7 @@ export type ServiceStatus = {
|
|
|
157
159
|
eventId: string;
|
|
158
160
|
pubkey: string;
|
|
159
161
|
serviceEvent: any;
|
|
162
|
+
isPrivate: boolean;
|
|
163
|
+
privateRecipients: string[];
|
|
160
164
|
};
|
|
161
165
|
import { SimplePool } from 'nostr-tools';
|
package/package.json
CHANGED
package/src/index.js
CHANGED
package/src/models.js
CHANGED
|
@@ -103,18 +103,29 @@ export class NCC02Builder {
|
|
|
103
103
|
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
104
104
|
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
105
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.
|
|
106
108
|
*/
|
|
107
109
|
async createServiceRecord(options) {
|
|
108
|
-
const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
|
|
110
|
+
const { serviceId, endpoint, fingerprint, expiryDays = 14, isPrivate = false, privateRecipients } = options;
|
|
109
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');
|
|
110
113
|
|
|
111
114
|
const expiry = Math.floor(Date.now() / 1000) + (expiryDays * 24 * 60 * 60);
|
|
112
115
|
const tags = [
|
|
113
116
|
['d', serviceId],
|
|
114
117
|
['exp', expiry.toString()]
|
|
115
118
|
];
|
|
119
|
+
tags.push(['private', isPrivate ? 'true' : 'false']);
|
|
116
120
|
if (endpoint) tags.push(['u', endpoint]);
|
|
117
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
|
+
}
|
|
118
129
|
|
|
119
130
|
const event = {
|
|
120
131
|
kind: KINDS.SERVICE_RECORD,
|
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
|
+
}
|
package/src/resolver.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SimplePool, verifyEvent } from 'nostr-tools';
|
|
2
2
|
import { KINDS } from './models.js';
|
|
3
|
+
import { collectPrivateRecipients, parsePrivateFlag } from './privacy.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Custom error class for NCC-02 specific failures.
|
|
@@ -28,6 +29,8 @@ export class NCC02Error extends Error {
|
|
|
28
29
|
* @property {string} eventId
|
|
29
30
|
* @property {string} pubkey
|
|
30
31
|
* @property {any} serviceEvent
|
|
32
|
+
* @property {boolean} isPrivate
|
|
33
|
+
* @property {string[]} privateRecipients
|
|
31
34
|
*/
|
|
32
35
|
/**
|
|
33
36
|
* Resolver for NCC-02 Service Records.
|
|
@@ -143,6 +146,10 @@ export class NCC02Resolver {
|
|
|
143
146
|
|
|
144
147
|
const serviceTags = Object.fromEntries(serviceEvent.tags);
|
|
145
148
|
const now = Math.floor(Date.now() / 1000);
|
|
149
|
+
const privateFlag = parsePrivateFlag(serviceEvent.tags);
|
|
150
|
+
if (privateFlag === null) {
|
|
151
|
+
throw new NCC02Error('MALFORMED_RECORD', 'Service record is missing required tag (private)');
|
|
152
|
+
}
|
|
146
153
|
|
|
147
154
|
// Security Fix: exp is REQUIRED by NCC-02 spec
|
|
148
155
|
if (!serviceTags.exp) {
|
|
@@ -180,6 +187,8 @@ export class NCC02Resolver {
|
|
|
180
187
|
attestations: trustData.validAttestations,
|
|
181
188
|
attestationCount: trustData.validAttestations.length,
|
|
182
189
|
isRevoked: trustData.isRevoked,
|
|
190
|
+
isPrivate: privateFlag,
|
|
191
|
+
privateRecipients: collectPrivateRecipients(serviceEvent.tags),
|
|
183
192
|
eventId: serviceEvent.id,
|
|
184
193
|
pubkey: serviceEvent.pubkey,
|
|
185
194
|
serviceEvent
|