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 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
@@ -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/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
+ };
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncc-02-js",
3
- "version": "0.4.0",
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
@@ -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