signet-protocol 0.1.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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/anomaly.d.ts +42 -0
  4. package/dist/anomaly.d.ts.map +1 -0
  5. package/dist/anomaly.js +209 -0
  6. package/dist/anomaly.js.map +1 -0
  7. package/dist/badge.d.ts +56 -0
  8. package/dist/badge.d.ts.map +1 -0
  9. package/dist/badge.js +171 -0
  10. package/dist/badge.js.map +1 -0
  11. package/dist/bonds.d.ts +39 -0
  12. package/dist/bonds.d.ts.map +1 -0
  13. package/dist/bonds.js +149 -0
  14. package/dist/bonds.js.map +1 -0
  15. package/dist/challenges.d.ts +18 -0
  16. package/dist/challenges.d.ts.map +1 -0
  17. package/dist/challenges.js +145 -0
  18. package/dist/challenges.js.map +1 -0
  19. package/dist/cold-call.d.ts +74 -0
  20. package/dist/cold-call.d.ts.map +1 -0
  21. package/dist/cold-call.js +176 -0
  22. package/dist/cold-call.js.map +1 -0
  23. package/dist/compliance.d.ts +82 -0
  24. package/dist/compliance.d.ts.map +1 -0
  25. package/dist/compliance.js +478 -0
  26. package/dist/compliance.js.map +1 -0
  27. package/dist/connections.d.ts +63 -0
  28. package/dist/connections.d.ts.map +1 -0
  29. package/dist/connections.js +170 -0
  30. package/dist/connections.js.map +1 -0
  31. package/dist/constants.d.ts +86 -0
  32. package/dist/constants.d.ts.map +1 -0
  33. package/dist/constants.js +124 -0
  34. package/dist/constants.js.map +1 -0
  35. package/dist/credentials.d.ts +190 -0
  36. package/dist/credentials.d.ts.map +1 -0
  37. package/dist/credentials.js +686 -0
  38. package/dist/credentials.js.map +1 -0
  39. package/dist/crypto.d.ts +27 -0
  40. package/dist/crypto.d.ts.map +1 -0
  41. package/dist/crypto.js +75 -0
  42. package/dist/crypto.js.map +1 -0
  43. package/dist/errors.d.ts +17 -0
  44. package/dist/errors.d.ts.map +1 -0
  45. package/dist/errors.js +29 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/i18n.d.ts +98 -0
  48. package/dist/i18n.d.ts.map +1 -0
  49. package/dist/i18n.js +1118 -0
  50. package/dist/i18n.js.map +1 -0
  51. package/dist/identity-bridge.d.ts +52 -0
  52. package/dist/identity-bridge.d.ts.map +1 -0
  53. package/dist/identity-bridge.js +228 -0
  54. package/dist/identity-bridge.js.map +1 -0
  55. package/dist/identity-tree.d.ts +47 -0
  56. package/dist/identity-tree.d.ts.map +1 -0
  57. package/dist/identity-tree.js +69 -0
  58. package/dist/identity-tree.js.map +1 -0
  59. package/dist/index.d.ts +55 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +86 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/key-derivation.d.ts +43 -0
  64. package/dist/key-derivation.d.ts.map +1 -0
  65. package/dist/key-derivation.js +212 -0
  66. package/dist/key-derivation.js.map +1 -0
  67. package/dist/lsag.d.ts +23 -0
  68. package/dist/lsag.d.ts.map +1 -0
  69. package/dist/lsag.js +35 -0
  70. package/dist/lsag.js.map +1 -0
  71. package/dist/merkle.d.ts +19 -0
  72. package/dist/merkle.d.ts.map +1 -0
  73. package/dist/merkle.js +155 -0
  74. package/dist/merkle.js.map +1 -0
  75. package/dist/policies.d.ts +22 -0
  76. package/dist/policies.d.ts.map +1 -0
  77. package/dist/policies.js +123 -0
  78. package/dist/policies.js.map +1 -0
  79. package/dist/range-proof.d.ts +6 -0
  80. package/dist/range-proof.d.ts.map +1 -0
  81. package/dist/range-proof.js +45 -0
  82. package/dist/range-proof.js.map +1 -0
  83. package/dist/relay.d.ts +106 -0
  84. package/dist/relay.d.ts.map +1 -0
  85. package/dist/relay.js +336 -0
  86. package/dist/relay.js.map +1 -0
  87. package/dist/ring-signature.d.ts +35 -0
  88. package/dist/ring-signature.d.ts.map +1 -0
  89. package/dist/ring-signature.js +56 -0
  90. package/dist/ring-signature.js.map +1 -0
  91. package/dist/shamir.d.ts +55 -0
  92. package/dist/shamir.d.ts.map +1 -0
  93. package/dist/shamir.js +253 -0
  94. package/dist/shamir.js.map +1 -0
  95. package/dist/signet-words.d.ts +42 -0
  96. package/dist/signet-words.d.ts.map +1 -0
  97. package/dist/signet-words.js +82 -0
  98. package/dist/signet-words.js.map +1 -0
  99. package/dist/store.d.ts +65 -0
  100. package/dist/store.d.ts.map +1 -0
  101. package/dist/store.js +290 -0
  102. package/dist/store.js.map +1 -0
  103. package/dist/trust-score.d.ts +9 -0
  104. package/dist/trust-score.d.ts.map +1 -0
  105. package/dist/trust-score.js +186 -0
  106. package/dist/trust-score.js.map +1 -0
  107. package/dist/types.d.ts +358 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +15 -0
  110. package/dist/types.js.map +1 -0
  111. package/dist/utils.d.ts +11 -0
  112. package/dist/utils.d.ts.map +1 -0
  113. package/dist/utils.js +21 -0
  114. package/dist/utils.js.map +1 -0
  115. package/dist/validation.d.ts +33 -0
  116. package/dist/validation.d.ts.map +1 -0
  117. package/dist/validation.js +312 -0
  118. package/dist/validation.js.map +1 -0
  119. package/dist/verifiers.d.ts +18 -0
  120. package/dist/verifiers.d.ts.map +1 -0
  121. package/dist/verifiers.js +118 -0
  122. package/dist/verifiers.js.map +1 -0
  123. package/dist/vouches.d.ts +14 -0
  124. package/dist/vouches.d.ts.map +1 -0
  125. package/dist/vouches.js +103 -0
  126. package/dist/vouches.js.map +1 -0
  127. package/package.json +76 -0
  128. package/src/anomaly.ts +307 -0
  129. package/src/badge.ts +208 -0
  130. package/src/bonds.ts +203 -0
  131. package/src/challenges.ts +187 -0
  132. package/src/cold-call.ts +238 -0
  133. package/src/compliance.ts +612 -0
  134. package/src/connections.ts +216 -0
  135. package/src/constants.ts +146 -0
  136. package/src/credentials.ts +908 -0
  137. package/src/crypto.ts +85 -0
  138. package/src/errors.ts +31 -0
  139. package/src/i18n.ts +1347 -0
  140. package/src/identity-bridge.ts +262 -0
  141. package/src/identity-tree.ts +90 -0
  142. package/src/index.ts +452 -0
  143. package/src/lsag.ts +53 -0
  144. package/src/merkle.ts +176 -0
  145. package/src/policies.ts +154 -0
  146. package/src/range-proof.ts +66 -0
  147. package/src/relay.ts +433 -0
  148. package/src/ring-signature.ts +76 -0
  149. package/src/signet-words.ts +122 -0
  150. package/src/store.ts +336 -0
  151. package/src/trust-score.ts +208 -0
  152. package/src/types.ts +482 -0
  153. package/src/utils.ts +20 -0
  154. package/src/validation.ts +391 -0
  155. package/src/verifiers.ts +156 -0
  156. package/src/vouches.ts +141 -0
@@ -0,0 +1,908 @@
1
+ // Verification Credential (kind 31000, type: credential)
2
+ // Create, sign, verify, and parse Signet credentials for all 4 tiers
3
+
4
+ import { createAttestation } from 'nostr-attestations';
5
+ import { ATTESTATION_KIND, ATTESTATION_TYPES, DEFAULT_CREDENTIAL_EXPIRY_SECONDS, DEFAULT_CRYPTO_ALGORITHM } from './constants.js';
6
+ import { signEvent, verifyEvent, getPublicKey, hash } from './crypto.js';
7
+ import { validateCredential, getTagValue, getTagValues } from './validation.js';
8
+ import { ringSign, ringVerify, type RingSignature } from './ring-signature.js';
9
+ import { createAgeRangeProof, verifyAgeRangeProof, type RangeProof } from './range-proof.js';
10
+ import { SignetValidationError } from './errors.js';
11
+ import type {
12
+ NostrEvent,
13
+ UnsignedEvent,
14
+ CredentialParams,
15
+ ParsedCredential,
16
+ SignetTier,
17
+ VerificationType,
18
+ VerificationScope,
19
+ VerificationMethod,
20
+ EntityType,
21
+ TwoCredentialResult,
22
+ CredentialChain,
23
+ GuardianDelegationParams,
24
+ MerkleProof,
25
+ CryptoAlgorithm,
26
+ } from './types.js';
27
+ import { MerkleTree } from './merkle.js';
28
+
29
+ /** Build an unsigned credential event */
30
+ export function buildCredentialEvent(
31
+ verifierPubkey: string,
32
+ params: CredentialParams
33
+ ): UnsignedEvent {
34
+ const signetTags: string[][] = [
35
+ ['tier', String(params.tier)],
36
+ ['verification-type', params.type],
37
+ ['scope', params.scope],
38
+ ['method', params.method],
39
+ ['algo', DEFAULT_CRYPTO_ALGORITHM],
40
+ ];
41
+
42
+ if (params.profession) signetTags.push(['profession', params.profession]);
43
+ if (params.jurisdiction) signetTags.push(['jurisdiction', params.jurisdiction]);
44
+ if (params.ageRange) signetTags.push(['age-range', params.ageRange]);
45
+ if (params.entityType) signetTags.push(['entity-type', params.entityType]);
46
+ if (params.nullifier) signetTags.push(['nullifier', params.nullifier]);
47
+ if (params.merkleRoot) signetTags.push(['merkle-root', params.merkleRoot]);
48
+ if (params.guardianPubkeys) {
49
+ for (const gp of params.guardianPubkeys) {
50
+ signetTags.push(['guardian', gp]);
51
+ }
52
+ }
53
+ if (params.supersedes) signetTags.push(['supersedes', params.supersedes]);
54
+
55
+ // Tier 2-4: assertion-first hybrid (references subject's Tier 1 self-declaration)
56
+ // Tier 1: direct claim (self-attestation, no assertion reference)
57
+ const useAssertionFirst = params.tier > 1 && params.assertionEventId;
58
+
59
+ const template = createAttestation({
60
+ type: ATTESTATION_TYPES.CREDENTIAL,
61
+ identifier: useAssertionFirst ? undefined : params.subjectPubkey,
62
+ subject: params.subjectPubkey,
63
+ assertion: useAssertionFirst ? {
64
+ id: params.assertionEventId!,
65
+ relay: params.assertionRelay,
66
+ } : undefined,
67
+ expiration: params.expiresAt,
68
+ occurredAt: params.occurredAt,
69
+ summary: `${params.type} verification (tier ${params.tier}) for ${params.subjectPubkey.slice(0, 8)}...`,
70
+ content: params.content || '',
71
+ tags: signetTags,
72
+ });
73
+
74
+ return {
75
+ ...template,
76
+ pubkey: verifierPubkey,
77
+ created_at: Math.floor(Date.now() / 1000),
78
+ };
79
+ }
80
+
81
+ /** Create and sign a Tier 1 (self-declared) credential */
82
+ export async function createSelfDeclaredCredential(
83
+ privateKey: string,
84
+ scope: VerificationScope = 'adult',
85
+ expiresAt?: number
86
+ ): Promise<NostrEvent> {
87
+ const pubkey = getPublicKey(privateKey);
88
+ const event = buildCredentialEvent(pubkey, {
89
+ subjectPubkey: pubkey,
90
+ tier: 1,
91
+ type: 'self',
92
+ scope,
93
+ method: 'self-declaration',
94
+ expiresAt: expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS,
95
+ });
96
+ return signEvent(event, privateKey);
97
+ }
98
+
99
+ /** Create and sign a Tier 2 (web-of-trust vouched) credential.
100
+ * Typically issued by an aggregator service when vouch threshold is met.
101
+ * Uses assertion-first hybrid pattern: references the subject's Tier 1 self-declaration. */
102
+ export async function createPeerVouchedCredential(
103
+ issuerPrivateKey: string,
104
+ subjectPubkey: string,
105
+ opts: {
106
+ assertionEventId: string;
107
+ assertionRelay?: string;
108
+ expiresAt?: number;
109
+ }
110
+ ): Promise<NostrEvent> {
111
+ const pubkey = getPublicKey(issuerPrivateKey);
112
+ const event = buildCredentialEvent(pubkey, {
113
+ subjectPubkey,
114
+ tier: 2,
115
+ type: 'peer',
116
+ scope: 'adult',
117
+ method: 'in-person',
118
+ assertionEventId: opts.assertionEventId,
119
+ assertionRelay: opts.assertionRelay,
120
+ expiresAt: opts.expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS,
121
+ });
122
+ return signEvent(event, issuerPrivateKey);
123
+ }
124
+
125
+ /** Create and sign a Tier 3 (professional verified adult) credential.
126
+ * Uses assertion-first hybrid pattern: references the subject's Tier 1 self-declaration. */
127
+ export async function createProfessionalCredential(
128
+ verifierPrivateKey: string,
129
+ subjectPubkey: string,
130
+ opts: {
131
+ assertionEventId: string;
132
+ profession: string;
133
+ jurisdiction: string;
134
+ assertionRelay?: string;
135
+ expiresAt?: number;
136
+ occurredAt?: number;
137
+ proofBlob?: string;
138
+ }
139
+ ): Promise<NostrEvent> {
140
+ const pubkey = getPublicKey(verifierPrivateKey);
141
+ const event = buildCredentialEvent(pubkey, {
142
+ subjectPubkey,
143
+ tier: 3,
144
+ type: 'professional',
145
+ scope: 'adult',
146
+ method: 'in-person-id',
147
+ profession: opts.profession,
148
+ jurisdiction: opts.jurisdiction,
149
+ assertionEventId: opts.assertionEventId,
150
+ assertionRelay: opts.assertionRelay,
151
+ expiresAt: opts.expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS,
152
+ occurredAt: opts.occurredAt,
153
+ content: opts.proofBlob,
154
+ });
155
+ return signEvent(event, verifierPrivateKey);
156
+ }
157
+
158
+ /** Create and sign a Tier 4 (professional verified adult+child) credential.
159
+ * Uses assertion-first hybrid pattern: references the subject's Tier 1 self-declaration. */
160
+ export async function createChildSafetyCredential(
161
+ verifierPrivateKey: string,
162
+ subjectPubkey: string,
163
+ opts: {
164
+ assertionEventId: string;
165
+ profession: string;
166
+ jurisdiction: string;
167
+ ageRange: string;
168
+ assertionRelay?: string;
169
+ expiresAt?: number;
170
+ occurredAt?: number;
171
+ proofBlob?: string;
172
+ }
173
+ ): Promise<NostrEvent> {
174
+ const pubkey = getPublicKey(verifierPrivateKey);
175
+ const event = buildCredentialEvent(pubkey, {
176
+ subjectPubkey,
177
+ tier: 4,
178
+ type: 'professional',
179
+ scope: 'adult+child',
180
+ method: 'in-person-id',
181
+ profession: opts.profession,
182
+ jurisdiction: opts.jurisdiction,
183
+ ageRange: opts.ageRange,
184
+ assertionEventId: opts.assertionEventId,
185
+ assertionRelay: opts.assertionRelay,
186
+ expiresAt: opts.expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS,
187
+ occurredAt: opts.occurredAt,
188
+ content: opts.proofBlob,
189
+ });
190
+ return signEvent(event, verifierPrivateKey);
191
+ }
192
+
193
+ /** Verify a credential event's signature and structure */
194
+ export async function verifyCredential(event: NostrEvent): Promise<{
195
+ signatureValid: boolean;
196
+ structureValid: boolean;
197
+ expired: boolean;
198
+ errors: string[];
199
+ }> {
200
+ const signatureValid = await verifyEvent(event);
201
+ const validation = validateCredential(event);
202
+ const expiresStr = getTagValue(event, 'expiration');
203
+ const expired = expiresStr ? (() => { const exp = parseInt(expiresStr, 10); return isNaN(exp) || exp < Math.floor(Date.now() / 1000); })() : false;
204
+
205
+ return {
206
+ signatureValid,
207
+ structureValid: validation.valid,
208
+ expired,
209
+ errors: validation.errors,
210
+ };
211
+ }
212
+
213
+ /** Check if a credential is expired */
214
+ export function isCredentialExpired(event: NostrEvent): boolean {
215
+ const expiresStr = getTagValue(event, 'expiration');
216
+ if (!expiresStr) return false;
217
+ const exp = parseInt(expiresStr, 10);
218
+ // NaN expiration is treated as expired (not perpetually valid)
219
+ return isNaN(exp) || exp < Math.floor(Date.now() / 1000);
220
+ }
221
+
222
+ /** Parse a credential event into a structured object */
223
+ export function parseCredential(event: NostrEvent): ParsedCredential | null {
224
+ if (event.kind !== ATTESTATION_KIND) return null;
225
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.CREDENTIAL) return null;
226
+
227
+ const tier = getTagValue(event, 'tier');
228
+ if (!tier) return null;
229
+
230
+ const guardianValues = getTagValues(event, 'guardian');
231
+ const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
232
+
233
+ // For assertion-first events (Tier 2-4), the subject pubkey is in the 'p' tag.
234
+ // For direct claims (Tier 1), strip 'credential:' prefix from d-tag.
235
+ const dTag = getTagValue(event, 'd') || '';
236
+ const pTag = getTagValue(event, 'p');
237
+ let subjectPubkey: string;
238
+ if (dTag.startsWith('assertion:') && pTag) {
239
+ subjectPubkey = pTag;
240
+ } else {
241
+ subjectPubkey = dTag.startsWith('credential:') ? dTag.slice('credential:'.length) : dTag;
242
+ }
243
+
244
+ return {
245
+ subjectPubkey,
246
+ tier: (() => { const t = parseInt(tier, 10); return (!isNaN(t) && t >= 1 && t <= 4 ? t : 1) as SignetTier; })(),
247
+ type: (getTagValue(event, 'verification-type') || 'self') as VerificationType,
248
+ scope: (getTagValue(event, 'scope') || 'adult') as VerificationScope,
249
+ method: (getTagValue(event, 'method') || 'self-declaration') as VerificationMethod,
250
+ profession: getTagValue(event, 'profession'),
251
+ jurisdiction: getTagValue(event, 'jurisdiction'),
252
+ ageRange: getTagValue(event, 'age-range'),
253
+ expiresAt: (() => { const expiresStr = getTagValue(event, 'expiration'); const expiresNum = expiresStr ? parseInt(expiresStr, 10) : undefined; return (expiresNum !== undefined && !isNaN(expiresNum)) ? expiresNum : undefined; })(),
254
+ entityType: getTagValue(event, 'entity-type') as EntityType | undefined,
255
+ nullifier: getTagValue(event, 'nullifier'),
256
+ merkleRoot: getTagValue(event, 'merkle-root'),
257
+ guardianPubkeys: guardianValues.length > 0 ? guardianValues : undefined,
258
+ supersedes: getTagValue(event, 'supersedes'),
259
+ supersededBy: getTagValue(event, 'superseded-by'),
260
+ occurredAt: (() => { const s = getTagValue(event, 'occurred_at'); const n = s ? parseInt(s, 10) : undefined; return (n !== undefined && !isNaN(n)) ? n : undefined; })(),
261
+ algorithm,
262
+ };
263
+ }
264
+
265
+ // --- Ring Signature Enhanced Credentials (Tier 3/4) ---
266
+
267
+ /** Content structure for ring-signature-protected credentials */
268
+ export interface RingProtectedContent {
269
+ ringSignature: RingSignature;
270
+ rangeProof?: RangeProof;
271
+ }
272
+
273
+ /**
274
+ * Create a Tier 3 credential with ring signature issuer privacy.
275
+ * The credential is signed by the verifier's Nostr key (for relay acceptance),
276
+ * but the content includes a ring signature proving "one of N verifiers" issued it.
277
+ */
278
+ export async function createRingProtectedCredential(
279
+ verifierPrivateKey: string,
280
+ subjectPubkey: string,
281
+ ring: string[],
282
+ signerIndex: number,
283
+ opts: {
284
+ profession: string;
285
+ jurisdiction: string;
286
+ expiresAt?: number;
287
+ }
288
+ ): Promise<NostrEvent> {
289
+ const pubkey = getPublicKey(verifierPrivateKey);
290
+ const expiresAt = opts.expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS;
291
+
292
+ // Use a single timestamp for both the ring signature binding and the event
293
+ const timestamp = Math.floor(Date.now() / 1000);
294
+
295
+ // Ring signature binds to subject + timestamp (not event ID, which changes with content)
296
+ const ringSig = ringSign(
297
+ `signet:credential:${subjectPubkey}:${timestamp}`,
298
+ ring,
299
+ signerIndex,
300
+ verifierPrivateKey
301
+ );
302
+
303
+ const content: RingProtectedContent = { ringSignature: ringSig };
304
+
305
+ const event: UnsignedEvent = {
306
+ kind: ATTESTATION_KIND,
307
+ pubkey,
308
+ created_at: timestamp,
309
+ tags: buildCredentialEvent(pubkey, {
310
+ subjectPubkey,
311
+ tier: 3,
312
+ type: 'professional',
313
+ scope: 'adult',
314
+ method: 'in-person-id',
315
+ profession: opts.profession,
316
+ jurisdiction: opts.jurisdiction,
317
+ expiresAt,
318
+ content: JSON.stringify(content),
319
+ }).tags,
320
+ content: JSON.stringify(content),
321
+ };
322
+
323
+ return signEvent(event, verifierPrivateKey);
324
+ }
325
+
326
+ /**
327
+ * Create a Tier 4 credential with ring signature AND age range proof.
328
+ */
329
+ export async function createRingProtectedChildCredential(
330
+ verifierPrivateKey: string,
331
+ subjectPubkey: string,
332
+ ring: string[],
333
+ signerIndex: number,
334
+ opts: {
335
+ profession: string;
336
+ jurisdiction: string;
337
+ ageRange: string;
338
+ actualAge: number;
339
+ expiresAt?: number;
340
+ }
341
+ ): Promise<NostrEvent> {
342
+ const pubkey = getPublicKey(verifierPrivateKey);
343
+ const expiresAt = opts.expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS;
344
+
345
+ // Use a single timestamp for both the ring signature binding and the event
346
+ const timestamp = Math.floor(Date.now() / 1000);
347
+
348
+ // Create the age range proof, bound to the subject pubkey to prevent transplanting
349
+ const rangeProof = createAgeRangeProof(opts.actualAge, opts.ageRange, subjectPubkey);
350
+
351
+ // Ring signature binds to subject + timestamp (not event ID, which changes with content)
352
+ const ringSig = ringSign(
353
+ `signet:credential:${subjectPubkey}:${timestamp}`,
354
+ ring,
355
+ signerIndex,
356
+ verifierPrivateKey
357
+ );
358
+
359
+ const content: RingProtectedContent = {
360
+ ringSignature: ringSig,
361
+ rangeProof,
362
+ };
363
+
364
+ const event: UnsignedEvent = {
365
+ kind: ATTESTATION_KIND,
366
+ pubkey,
367
+ created_at: timestamp,
368
+ tags: buildCredentialEvent(pubkey, {
369
+ subjectPubkey,
370
+ tier: 4,
371
+ type: 'professional',
372
+ scope: 'adult+child',
373
+ method: 'in-person-id',
374
+ profession: opts.profession,
375
+ jurisdiction: opts.jurisdiction,
376
+ ageRange: opts.ageRange,
377
+ expiresAt,
378
+ content: JSON.stringify(content),
379
+ }).tags,
380
+ content: JSON.stringify(content),
381
+ };
382
+
383
+ return signEvent(event, verifierPrivateKey);
384
+ }
385
+
386
+ /**
387
+ * Verify the ring signature and optional range proof inside a credential's content.
388
+ */
389
+ export function verifyRingProtectedContent(event: NostrEvent): {
390
+ hasRingSignature: boolean;
391
+ ringValid: boolean;
392
+ hasRangeProof: boolean;
393
+ rangeProofValid: boolean;
394
+ } {
395
+ const result = {
396
+ hasRingSignature: false,
397
+ ringValid: false,
398
+ hasRangeProof: false,
399
+ rangeProofValid: false,
400
+ };
401
+
402
+ if (!event.content) return result;
403
+
404
+ try {
405
+ const raw = JSON.parse(event.content);
406
+ if (!raw || typeof raw !== 'object') return result;
407
+ const content = raw as RingProtectedContent;
408
+ const dTag = getTagValue(event, 'd') || '';
409
+ const subjectPubkey = dTag.startsWith('credential:') ? dTag.slice('credential:'.length) : dTag;
410
+
411
+ if (content.ringSignature &&
412
+ typeof content.ringSignature === 'object' &&
413
+ typeof content.ringSignature.message === 'string' &&
414
+ Array.isArray(content.ringSignature.ring) &&
415
+ content.ringSignature.ring.every((r: unknown) => typeof r === 'string') &&
416
+ typeof content.ringSignature.c0 === 'string' &&
417
+ Array.isArray(content.ringSignature.responses) &&
418
+ content.ringSignature.responses.every((r: unknown) => typeof r === 'string')) {
419
+ result.hasRingSignature = true;
420
+
421
+ // Verify the ring signature is cryptographically valid
422
+ const cryptoValid = ringVerify(content.ringSignature);
423
+
424
+ // Verify the binding message references this credential's subject
425
+ const expectedPrefix = `signet:credential:${subjectPubkey}:`;
426
+ const messageBindsToSubject = content.ringSignature.message.startsWith(expectedPrefix);
427
+
428
+ // Extract timestamp from after the validated prefix
429
+ const timestampStr = content.ringSignature.message.slice(expectedPrefix.length);
430
+ const msgTimestamp = parseInt(timestampStr, 10);
431
+ const timestampMatches = !isNaN(msgTimestamp) && msgTimestamp === event.created_at;
432
+
433
+ result.ringValid = cryptoValid && messageBindsToSubject && timestampMatches;
434
+ }
435
+
436
+ if (content.rangeProof &&
437
+ typeof content.rangeProof === 'object' &&
438
+ !Array.isArray(content.rangeProof)) {
439
+ result.hasRangeProof = true;
440
+ const expectedAgeRange = getTagValue(event, 'age-range');
441
+ if (!expectedAgeRange) {
442
+ result.rangeProofValid = false;
443
+ } else {
444
+ result.rangeProofValid = verifyAgeRangeProof(content.rangeProof, expectedAgeRange, subjectPubkey);
445
+ }
446
+ }
447
+ } catch {
448
+ // Content is not JSON or not a RingProtectedContent — that's OK for Tier 1/2
449
+ }
450
+
451
+ return result;
452
+ }
453
+
454
+ // --- Credential Renewal ---
455
+
456
+ /**
457
+ * Renew an expiring credential. Creates a new credential with the same parameters
458
+ * but a fresh expiry. Must be issued by the same verifier (or a new one for re-verification).
459
+ */
460
+ export async function renewCredential(
461
+ verifierPrivateKey: string,
462
+ existingCredential: NostrEvent,
463
+ newExpiresAt?: number
464
+ ): Promise<NostrEvent> {
465
+ const parsed = parseCredential(existingCredential);
466
+ if (!parsed) throw new SignetValidationError('Invalid credential to renew');
467
+
468
+ const pubkey = getPublicKey(verifierPrivateKey);
469
+ const expiresAt = newExpiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS;
470
+
471
+ const event = buildCredentialEvent(pubkey, {
472
+ subjectPubkey: parsed.subjectPubkey,
473
+ tier: parsed.tier,
474
+ type: parsed.type,
475
+ scope: parsed.scope,
476
+ method: parsed.method,
477
+ profession: parsed.profession,
478
+ jurisdiction: parsed.jurisdiction,
479
+ ageRange: parsed.ageRange,
480
+ expiresAt,
481
+ });
482
+
483
+ return signEvent(event, verifierPrivateKey);
484
+ }
485
+
486
+ /**
487
+ * Check if a credential needs renewal (within N days of expiry).
488
+ */
489
+ export function needsRenewal(event: NostrEvent, withinDays: number = 30): boolean {
490
+ if (withinDays < 0) throw new SignetValidationError('withinDays must be non-negative');
491
+ const expiresStr = getTagValue(event, 'expiration');
492
+ if (!expiresStr) return false;
493
+
494
+ const expiresAt = parseInt(expiresStr, 10);
495
+ if (isNaN(expiresAt)) return false;
496
+ const now = Math.floor(Date.now() / 1000);
497
+ const threshold = now + withinDays * 24 * 60 * 60;
498
+
499
+ return expiresAt <= threshold;
500
+ }
501
+
502
+ // --- Two-Credential Ceremony ---
503
+
504
+ /**
505
+ * Create a two-credential ceremony issuing Natural Person + Persona credentials.
506
+ * The verifier sees all documents but only publishes privacy-preserving tags.
507
+ */
508
+ export async function createTwoCredentialCeremony(
509
+ verifierPrivateKey: string,
510
+ naturalPersonPubkey: string,
511
+ personaPubkey: string,
512
+ opts: {
513
+ name: string;
514
+ nationality: string;
515
+ documentType: string;
516
+ documentNumber: string;
517
+ documentCountry: string;
518
+ dateOfBirth: string; // ISO date
519
+ profession: string;
520
+ jurisdiction: string;
521
+ ageRange?: string;
522
+ guardianPubkeys?: string[];
523
+ expiresAt?: number;
524
+ occurredAt?: number;
525
+ }
526
+ ): Promise<TwoCredentialResult> {
527
+ const verifierPubkey = getPublicKey(verifierPrivateKey);
528
+ const expiresAt = opts.expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS;
529
+
530
+ // 1. Compute nullifier from document details (NOT published, only hash)
531
+ const nullifier = computeNullifier(opts.documentType, opts.documentCountry, opts.documentNumber);
532
+
533
+ // 2. Build Merkle tree from verified attributes
534
+ const merkleLeaves: Record<string, string> = {
535
+ name: opts.name,
536
+ nationality: opts.nationality,
537
+ documentType: opts.documentType,
538
+ dateOfBirth: opts.dateOfBirth,
539
+ nullifier: nullifier,
540
+ };
541
+ const tree = new MerkleTree(merkleLeaves);
542
+ const merkleRoot = tree.getRoot();
543
+
544
+ // 3. Compute age range from DOB if not provided
545
+ const VALID_AGE_RANGES = ['0-3', '4-7', '8-12', '13-17', '18+'];
546
+ const ageRange = opts.ageRange || computeAgeRange(opts.dateOfBirth);
547
+ if (!VALID_AGE_RANGES.includes(ageRange)) {
548
+ throw new SignetValidationError(`Invalid age range: must be one of ${VALID_AGE_RANGES.join(', ')}`);
549
+ }
550
+
551
+ // 4. Determine tier and scope
552
+ const isChild = ageRange !== '18+';
553
+ const tier: SignetTier = isChild ? 4 : 3;
554
+ const scope: VerificationScope = isChild ? 'adult+child' : 'adult';
555
+
556
+ // 5. Issue Natural Person credential (keypair A)
557
+ const npEvent = buildCredentialEvent(verifierPubkey, {
558
+ subjectPubkey: naturalPersonPubkey,
559
+ tier,
560
+ type: 'professional',
561
+ scope,
562
+ method: 'in-person-id',
563
+ profession: opts.profession,
564
+ jurisdiction: opts.jurisdiction,
565
+ ageRange,
566
+ entityType: 'natural_person',
567
+ nullifier,
568
+ merkleRoot,
569
+ guardianPubkeys: opts.guardianPubkeys,
570
+ expiresAt,
571
+ occurredAt: opts.occurredAt,
572
+ });
573
+ const naturalPerson = await signEvent(npEvent, verifierPrivateKey);
574
+
575
+ // 6. Issue Persona credential (keypair B) — NO nullifier, NO merkle-root
576
+ const personaEvent = buildCredentialEvent(verifierPubkey, {
577
+ subjectPubkey: personaPubkey,
578
+ tier,
579
+ type: 'professional',
580
+ scope,
581
+ method: 'in-person-id',
582
+ profession: opts.profession,
583
+ jurisdiction: opts.jurisdiction,
584
+ ageRange,
585
+ entityType: 'persona',
586
+ guardianPubkeys: opts.guardianPubkeys,
587
+ expiresAt,
588
+ occurredAt: opts.occurredAt,
589
+ });
590
+ const persona = await signEvent(personaEvent, verifierPrivateKey);
591
+
592
+ // 7. Generate Merkle proofs for all leaves
593
+ const merkleProofs: MerkleProof[] = Object.keys(merkleLeaves)
594
+ .sort()
595
+ .map((key) => tree.prove(key));
596
+
597
+ return {
598
+ naturalPerson,
599
+ persona,
600
+ merkleLeaves,
601
+ merkleProofs,
602
+ };
603
+ }
604
+
605
+ /** Compute age range string from ISO date of birth */
606
+ function computeAgeRange(dateOfBirth: string): string {
607
+ const dob = new Date(dateOfBirth);
608
+ if (isNaN(dob.getTime())) throw new SignetValidationError('Invalid date of birth: value is not a parseable ISO date');
609
+ const now = new Date();
610
+ let age = now.getFullYear() - dob.getFullYear();
611
+ const monthDiff = now.getMonth() - dob.getMonth();
612
+ if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < dob.getDate())) {
613
+ age--;
614
+ }
615
+ if (age < 0 || age > 150) {
616
+ throw new SignetValidationError(`Implausible age ${age} computed from date of birth`);
617
+ }
618
+
619
+ if (age >= 18) return '18+';
620
+ if (age >= 13) return '13-17';
621
+ if (age >= 8) return '8-12';
622
+ if (age >= 4) return '4-7';
623
+ return '0-3';
624
+ }
625
+
626
+ // --- Credential Chains ---
627
+
628
+ /**
629
+ * Issue a new credential that supersedes an existing one.
630
+ * The old credential gets a superseded-by tag added (returned as updated event).
631
+ */
632
+ export async function supersedeCredential(
633
+ verifierPrivateKey: string,
634
+ oldCredential: NostrEvent,
635
+ newParams: Partial<CredentialParams> & { subjectPubkey: string }
636
+ ): Promise<{ newCredential: NostrEvent; oldCredential: NostrEvent }> {
637
+ const parsed = parseCredential(oldCredential);
638
+ if (!parsed) throw new SignetValidationError('Invalid credential to supersede');
639
+
640
+ const verifierPubkey = getPublicKey(verifierPrivateKey);
641
+ const expiresAt = newParams.expiresAt || Math.floor(Date.now() / 1000) + DEFAULT_CREDENTIAL_EXPIRY_SECONDS;
642
+
643
+ // Build new credential with supersedes link
644
+ const newEvent = buildCredentialEvent(verifierPubkey, {
645
+ subjectPubkey: newParams.subjectPubkey,
646
+ tier: newParams.tier || parsed.tier,
647
+ type: newParams.type || parsed.type,
648
+ scope: newParams.scope || parsed.scope,
649
+ method: newParams.method || parsed.method,
650
+ profession: newParams.profession ?? parsed.profession,
651
+ jurisdiction: newParams.jurisdiction ?? parsed.jurisdiction,
652
+ ageRange: newParams.ageRange ?? parsed.ageRange,
653
+ entityType: newParams.entityType ?? parsed.entityType,
654
+ nullifier: newParams.nullifier ?? parsed.nullifier,
655
+ merkleRoot: newParams.merkleRoot ?? parsed.merkleRoot,
656
+ guardianPubkeys: newParams.guardianPubkeys ?? parsed.guardianPubkeys,
657
+ supersedes: oldCredential.id,
658
+ expiresAt,
659
+ });
660
+
661
+ const newCredential = await signEvent(newEvent, verifierPrivateKey);
662
+
663
+ // Note: We do NOT modify the old credential. Adding a tag would invalidate its
664
+ // id and signature (which are computed from the serialized event including tags).
665
+ // The supersession relationship is established by the 'supersedes' tag on the
666
+ // new credential pointing to the old credential's id.
667
+
668
+ return { newCredential, oldCredential };
669
+ }
670
+
671
+ const MAX_CHAIN_DEPTH = 100;
672
+
673
+ /**
674
+ * Follow supersedes/superseded-by chain to find current active credential.
675
+ */
676
+ export function resolveCredentialChain(events: NostrEvent[]): CredentialChain | null {
677
+ if (events.length === 0) return null;
678
+
679
+ // Build lookup maps
680
+ const byId = new Map<string, NostrEvent>();
681
+ for (const e of events) byId.set(e.id, e);
682
+
683
+ // Build a set of all IDs that are superseded by another event in the set
684
+ const supersededIds = new Set<string>();
685
+ for (const e of events) {
686
+ const supersedesId = getTagValue(e, 'supersedes');
687
+ if (supersedesId && byId.has(supersedesId)) {
688
+ supersededIds.add(supersedesId);
689
+ }
690
+ }
691
+
692
+ // The "current" credential is the one whose ID is NOT in the superseded set
693
+ let current: NostrEvent | undefined;
694
+ for (const e of events) {
695
+ if (!supersededIds.has(e.id)) {
696
+ current = e;
697
+ }
698
+ }
699
+ if (!current) current = events[events.length - 1];
700
+
701
+ // Walk backwards through supersedes links to build history
702
+ const history: NostrEvent[] = [];
703
+ let cursor: NostrEvent | undefined = current;
704
+ const visited = new Set<string>();
705
+
706
+ while (cursor) {
707
+ if (visited.has(cursor.id)) break;
708
+ if (history.length >= MAX_CHAIN_DEPTH) break;
709
+ visited.add(cursor.id);
710
+
711
+ const supersedesId = getTagValue(cursor, 'supersedes');
712
+ if (supersedesId && byId.has(supersedesId)) {
713
+ history.unshift(byId.get(supersedesId)!);
714
+ cursor = byId.get(supersedesId);
715
+ } else {
716
+ break;
717
+ }
718
+ }
719
+
720
+ return { current, history };
721
+ }
722
+
723
+ /**
724
+ * Check if a credential has been superseded.
725
+ */
726
+ export function isSuperseded(event: NostrEvent): boolean {
727
+ return !!getTagValue(event, 'superseded-by');
728
+ }
729
+
730
+ // --- Nullifier Utilities ---
731
+
732
+ /**
733
+ * Compute a deterministic nullifier from document details.
734
+ * Uses length-prefixed encoding to prevent field-boundary collisions:
735
+ * SHA-256( len(docType) + docType + len(country) + country + len(docNum) + docNum + domainTag )
736
+ *
737
+ * Each field is prefixed with its UTF-8 byte length as a 2-byte big-endian uint16,
738
+ * followed by a fixed domain separation tag.
739
+ */
740
+ export function computeNullifier(documentType: string, countryCode: string, documentNumber: string): string {
741
+ const domainTag = 'signet-nullifier-v2';
742
+ const fields = [documentType, countryCode, documentNumber, domainTag];
743
+
744
+ // Calculate total buffer size: 2 bytes length prefix + field bytes for each field
745
+ const encoder = new TextEncoder();
746
+ const encoded = fields.map(f => encoder.encode(f));
747
+ const totalLen = encoded.reduce((sum, buf) => sum + 2 + buf.length, 0);
748
+
749
+ const buffer = new Uint8Array(totalLen);
750
+ let offset = 0;
751
+ for (const fieldBytes of encoded) {
752
+ // 2-byte big-endian length prefix
753
+ buffer[offset] = (fieldBytes.length >> 8) & 0xff;
754
+ buffer[offset + 1] = fieldBytes.length & 0xff;
755
+ offset += 2;
756
+ buffer.set(fieldBytes, offset);
757
+ offset += fieldBytes.length;
758
+ }
759
+
760
+ return hash(buffer);
761
+ }
762
+
763
+ /**
764
+ * Check if a nullifier already exists in a set of credentials.
765
+ * Returns the conflicting credential if found.
766
+ */
767
+ export function checkNullifierDuplicate(
768
+ nullifier: string,
769
+ existingCredentials: NostrEvent[]
770
+ ): { isDuplicate: boolean; conflicting?: NostrEvent } {
771
+ for (const cred of existingCredentials) {
772
+ const credNullifier = getTagValue(cred, 'nullifier');
773
+ if (credNullifier === nullifier) {
774
+ return { isDuplicate: true, conflicting: cred };
775
+ }
776
+ }
777
+ return { isDuplicate: false };
778
+ }
779
+
780
+ /**
781
+ * Build a nullifier-chain tag linking old and new nullifiers (for document renewal).
782
+ */
783
+ export function buildNullifierChainTag(oldNullifier: string): string[][] {
784
+ return [['nullifier-chain', oldNullifier]];
785
+ }
786
+
787
+ // --- Multi-Document Nullifier Families ---
788
+
789
+ export interface DocumentDescriptor {
790
+ documentType: string;
791
+ countryCode: string;
792
+ documentNumber: string;
793
+ }
794
+
795
+ export interface NullifierFamily {
796
+ /** Primary nullifier (first document) */
797
+ primary: string;
798
+ /** All nullifiers in the family (including primary) */
799
+ nullifiers: Array<{ documentType: string; nullifier: string }>;
800
+ }
801
+
802
+ /**
803
+ * Compute nullifiers for ALL documents presented during a verification ceremony.
804
+ * Returns a nullifier family containing all nullifiers. Collision with ANY nullifier
805
+ * in ANY family triggers duplicate detection.
806
+ */
807
+ export function computeNullifierFamily(documents: DocumentDescriptor[]): NullifierFamily {
808
+ if (documents.length === 0) {
809
+ throw new SignetValidationError('At least one document is required for nullifier computation');
810
+ }
811
+
812
+ const nullifiers = documents.map(doc => ({
813
+ documentType: doc.documentType,
814
+ nullifier: computeNullifier(doc.documentType, doc.countryCode, doc.documentNumber),
815
+ }));
816
+
817
+ return {
818
+ primary: nullifiers[0].nullifier,
819
+ nullifiers,
820
+ };
821
+ }
822
+
823
+ /**
824
+ * Build nullifier-family tags for a credential event.
825
+ * The primary nullifier is stored in the 'nullifier' tag (backwards compatible).
826
+ * Additional nullifiers are stored in 'nullifier-family' tags.
827
+ */
828
+ export function buildNullifierFamilyTags(family: NullifierFamily): string[][] {
829
+ const tags: string[][] = [
830
+ ['nullifier', family.primary],
831
+ ];
832
+ for (const entry of family.nullifiers) {
833
+ tags.push(['nullifier-family', entry.nullifier, entry.documentType]);
834
+ }
835
+ return tags;
836
+ }
837
+
838
+ /**
839
+ * Check if ANY nullifier in a family collides with ANY nullifier in existing credentials.
840
+ * This catches attempts to use different documents for the same person.
841
+ */
842
+ export function checkNullifierFamilyDuplicate(
843
+ family: NullifierFamily,
844
+ existingCredentials: NostrEvent[]
845
+ ): { isDuplicate: boolean; conflicting?: NostrEvent; matchedNullifier?: string } {
846
+ for (const cred of existingCredentials) {
847
+ // Check against primary nullifier tag
848
+ const credNullifier = getTagValue(cred, 'nullifier');
849
+
850
+ // Also check against all nullifier-family tags
851
+ const credFamilyNullifiers = getTagValues(cred, 'nullifier-family');
852
+
853
+ const allCredNullifiers = new Set<string>();
854
+ if (credNullifier) allCredNullifiers.add(credNullifier);
855
+ for (const fn of credFamilyNullifiers) {
856
+ allCredNullifiers.add(fn);
857
+ }
858
+
859
+ // Check each nullifier in the new family against all existing nullifiers
860
+ for (const entry of family.nullifiers) {
861
+ if (allCredNullifiers.has(entry.nullifier)) {
862
+ return { isDuplicate: true, conflicting: cred, matchedNullifier: entry.nullifier };
863
+ }
864
+ }
865
+ }
866
+
867
+ return { isDuplicate: false };
868
+ }
869
+
870
+ // --- Guardian Delegation ---
871
+
872
+ /**
873
+ * Create a guardian delegation event (kind 31000, type: delegation).
874
+ * Allows a guardian to delegate specific permissions to another adult for a child.
875
+ */
876
+ export async function createGuardianDelegation(
877
+ guardianPrivateKey: string,
878
+ params: GuardianDelegationParams
879
+ ): Promise<NostrEvent> {
880
+ const guardianPubkey = getPublicKey(guardianPrivateKey);
881
+
882
+ const signetTags: string[][] = [
883
+ ['p', params.delegatePubkey],
884
+ ['delegation-type', 'guardian-delegate'],
885
+ ['child', params.childPubkey],
886
+ ['scope', params.scope],
887
+ ['algo', DEFAULT_CRYPTO_ALGORITHM],
888
+ ];
889
+
890
+ const template = createAttestation({
891
+ type: ATTESTATION_TYPES.DELEGATION,
892
+ identifier: `${params.childPubkey}:${params.delegatePubkey}`,
893
+ subject: params.delegatePubkey,
894
+ expiration: params.expiresAt,
895
+ occurredAt: params.occurredAt,
896
+ summary: `Guardian delegation for ${params.childPubkey.slice(0, 8)}... to ${params.delegatePubkey.slice(0, 8)}...`,
897
+ content: '',
898
+ tags: signetTags,
899
+ });
900
+
901
+ const event = {
902
+ ...template,
903
+ pubkey: guardianPubkey,
904
+ created_at: Math.floor(Date.now() / 1000),
905
+ };
906
+
907
+ return signEvent(event, guardianPrivateKey);
908
+ }