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