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,391 @@
1
+ // Signet Event Validation
2
+ // Validates structure and required fields for all 6 event kinds
3
+
4
+ import { validateAttestation } from 'nostr-attestations';
5
+ import { ATTESTATION_KIND, ATTESTATION_TYPES, APP_DATA_KIND, SIGNET_LABEL, VALID_BOND_ADDRESS_TYPES } from './constants.js';
6
+ import type { NostrEvent } from './types.js';
7
+
8
+ export const MAX_CONTENT_LENGTH = 65536;
9
+ export const MAX_TAG_VALUE_LENGTH = 1024;
10
+ export const MAX_TAGS_COUNT = 100;
11
+
12
+ export interface ValidationResult {
13
+ valid: boolean;
14
+ errors: string[];
15
+ }
16
+
17
+ /** Get a tag value by name from a Nostr event */
18
+ export function getTagValue(event: NostrEvent | { tags: string[][] }, name: string): string | undefined {
19
+ const tag = event.tags.find((t) => t[0] === name);
20
+ return tag?.[1];
21
+ }
22
+
23
+ /** Get all tag values by name */
24
+ export function getTagValues(event: NostrEvent | { tags: string[][] }, name: string): string[] {
25
+ return event.tags.filter((t) => t[0] === name && t[1] !== undefined).map((t) => t[1]);
26
+ }
27
+
28
+ /** Validate field-size bounds on untrusted event data */
29
+ export function validateFieldSizeBounds(event: NostrEvent, errors: string[]): void {
30
+ if (event.content.length > MAX_CONTENT_LENGTH) {
31
+ errors.push(`Event content exceeds maximum length of ${MAX_CONTENT_LENGTH} characters`);
32
+ }
33
+ if (event.tags.length > MAX_TAGS_COUNT) {
34
+ errors.push(`Event has too many tags (max ${MAX_TAGS_COUNT})`);
35
+ }
36
+ for (const t of event.tags) {
37
+ for (let i = 1; i < t.length; i++) {
38
+ if (t[i] !== undefined && t[i].length > MAX_TAG_VALUE_LENGTH) {
39
+ errors.push(`Tag value at index ${i} for "${t[0]}" exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`);
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ /** Check that the event has a NIP-VA discoverability label.
46
+ * Accepts both the current 'nip-va' label and the legacy 'signet' label
47
+ * for backwards compatibility with events created before v2.3.0. */
48
+ function hasSignetLabel(event: NostrEvent): boolean {
49
+ return event.tags.some(
50
+ (t) => t[0] === 'L' && (t[1] === 'nip-va' || t[1] === SIGNET_LABEL)
51
+ );
52
+ }
53
+
54
+ /** Validate a Verification Credential attestation (kind 31000, type: credential) */
55
+ export function validateCredential(event: NostrEvent): ValidationResult {
56
+ const errors: string[] = [];
57
+
58
+ validateFieldSizeBounds(event, errors);
59
+
60
+ const base = validateAttestation(event);
61
+ if (!base.valid) {
62
+ return { valid: false, errors: [...base.errors] };
63
+ }
64
+
65
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.CREDENTIAL) {
66
+ errors.push(`Expected type tag '${ATTESTATION_TYPES.CREDENTIAL}'`);
67
+ }
68
+
69
+ if (!hasSignetLabel(event)) {
70
+ errors.push('Missing signet protocol label (["L", "signet"])');
71
+ }
72
+
73
+ const subject = getTagValue(event, 'd');
74
+ if (!subject) errors.push('Missing "d" tag (subject pubkey)');
75
+
76
+ const tier = getTagValue(event, 'tier');
77
+ if (!tier || !['1', '2', '3', '4'].includes(tier)) {
78
+ errors.push('Missing or invalid "tier" tag (must be 1-4)');
79
+ }
80
+
81
+ const verificationType = getTagValue(event, 'verification-type');
82
+ if (!verificationType || !['self', 'peer', 'professional'].includes(verificationType)) {
83
+ errors.push('Missing or invalid "verification-type" tag');
84
+ }
85
+
86
+ const scope = getTagValue(event, 'scope');
87
+ if (!scope || !['adult', 'adult+child'].includes(scope)) {
88
+ errors.push('Missing or invalid "scope" tag');
89
+ }
90
+
91
+ const method = getTagValue(event, 'method');
92
+ if (!method) errors.push('Missing "method" tag');
93
+
94
+ // V-SIG-07: nullifier, if present, must be a 64-character lowercase hex string (SHA-256)
95
+ const nullifier = getTagValue(event, 'nullifier');
96
+ if (nullifier !== undefined && !/^[0-9a-f]{64}$/.test(nullifier)) {
97
+ errors.push('Invalid "nullifier" tag (must be 64-character lowercase hex SHA-256)');
98
+ }
99
+
100
+ // Tier-specific validations — use string comparison (tier is already whitelisted above)
101
+ if (tier === '1' && verificationType !== 'self') {
102
+ errors.push('Tier 1 must have verification-type "self"');
103
+ }
104
+
105
+ // V-SIG-03: Tier 1 self-declaration must be self-signed (pubkey === p tag)
106
+ if (tier === '1') {
107
+ const pTag = getTagValue(event, 'p');
108
+ if (pTag && event.pubkey !== pTag) {
109
+ errors.push('Tier 1 credential must be self-signed (pubkey must equal p tag)');
110
+ }
111
+ }
112
+
113
+ if (tier === '2' && verificationType !== 'peer') {
114
+ errors.push('Tier 2 must have verification-type "peer"');
115
+ }
116
+
117
+ if ((tier === '3' || tier === '4') && verificationType !== 'professional') {
118
+ errors.push(`Tier ${tier} must have verification-type "professional"`);
119
+ }
120
+
121
+ if (tier === '4') {
122
+ if (scope !== 'adult+child') {
123
+ errors.push('Tier 4 must have scope "adult+child"');
124
+ }
125
+ if (!getTagValue(event, 'age-range')) {
126
+ errors.push('Tier 4 must include "age-range" tag');
127
+ }
128
+ }
129
+
130
+ if ((tier === '3' || tier === '4') && !getTagValue(event, 'profession')) {
131
+ errors.push(`Tier ${tier} should include "profession" tag`);
132
+ }
133
+
134
+ return { valid: errors.length === 0, errors };
135
+ }
136
+
137
+ /** Validate a Vouch Attestation (kind 31000, type: vouch) */
138
+ export function validateVouch(event: NostrEvent): ValidationResult {
139
+ const errors: string[] = [];
140
+
141
+ validateFieldSizeBounds(event, errors);
142
+
143
+ const base = validateAttestation(event);
144
+ if (!base.valid) {
145
+ return { valid: false, errors: [...base.errors] };
146
+ }
147
+
148
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.VOUCH) {
149
+ errors.push(`Expected type tag '${ATTESTATION_TYPES.VOUCH}'`);
150
+ }
151
+
152
+ if (!hasSignetLabel(event)) {
153
+ errors.push('Missing signet protocol label');
154
+ }
155
+
156
+ const dTag = getTagValue(event, 'd');
157
+ if (!dTag) errors.push('Missing "d" tag (subject pubkey)');
158
+
159
+ // Strip 'vouch:' prefix from d-tag to get subject pubkey
160
+ const subject = dTag && dTag.startsWith('vouch:') ? dTag.slice('vouch:'.length) : dTag;
161
+
162
+ const method = getTagValue(event, 'method');
163
+ if (!method || !['in-person', 'online'].includes(method)) {
164
+ errors.push('Missing or invalid "method" tag');
165
+ }
166
+
167
+ const voucherTier = getTagValue(event, 'voucher-tier');
168
+ if (!voucherTier || !['1', '2', '3', '4'].includes(voucherTier)) {
169
+ errors.push('Missing or invalid "voucher-tier" tag');
170
+ }
171
+
172
+ // Voucher must not vouch for themselves
173
+ if (subject && event.pubkey === subject) {
174
+ errors.push('Cannot vouch for yourself');
175
+ }
176
+
177
+ return { valid: errors.length === 0, errors };
178
+ }
179
+
180
+ /** Validate a Community Policy (kind 30078, NIP-78) */
181
+ export function validatePolicy(event: NostrEvent): ValidationResult {
182
+ const errors: string[] = [];
183
+
184
+ validateFieldSizeBounds(event, errors);
185
+
186
+ if (event.kind !== APP_DATA_KIND) {
187
+ errors.push(`Expected kind ${APP_DATA_KIND}, got ${event.kind}`);
188
+ }
189
+
190
+ const dTag = getTagValue(event, 'd');
191
+ if (!dTag || !dTag.startsWith('signet:policy:')) {
192
+ errors.push('Missing or invalid "d" tag (must start with "signet:policy:")');
193
+ }
194
+
195
+ const adultMinTier = getTagValue(event, 'adult-min-tier');
196
+ if (!adultMinTier || !['1', '2', '3', '4'].includes(adultMinTier)) {
197
+ errors.push('Missing or invalid "adult-min-tier" tag');
198
+ }
199
+
200
+ const childMinTier = getTagValue(event, 'child-min-tier');
201
+ if (!childMinTier || !['1', '2', '3', '4'].includes(childMinTier)) {
202
+ errors.push('Missing or invalid "child-min-tier" tag');
203
+ }
204
+
205
+ const enforcement = getTagValue(event, 'enforcement');
206
+ if (!enforcement || !['client', 'relay', 'both'].includes(enforcement)) {
207
+ errors.push('Missing or invalid "enforcement" tag');
208
+ }
209
+
210
+ return { valid: errors.length === 0, errors };
211
+ }
212
+
213
+ /** Validate a Verifier Credential attestation (kind 31000, type: verifier) */
214
+ export function validateVerifier(event: NostrEvent): ValidationResult {
215
+ const errors: string[] = [];
216
+
217
+ validateFieldSizeBounds(event, errors);
218
+
219
+ const base = validateAttestation(event);
220
+ if (!base.valid) {
221
+ return { valid: false, errors: [...base.errors] };
222
+ }
223
+
224
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.VERIFIER) {
225
+ errors.push(`Expected type tag '${ATTESTATION_TYPES.VERIFIER}'`);
226
+ }
227
+
228
+ if (!hasSignetLabel(event)) {
229
+ errors.push('Missing signet protocol label');
230
+ }
231
+
232
+ if (!getTagValue(event, 'profession')) {
233
+ errors.push('Missing "profession" tag');
234
+ }
235
+
236
+ if (!getTagValue(event, 'jurisdiction')) {
237
+ errors.push('Missing "jurisdiction" tag');
238
+ }
239
+
240
+ if (!getTagValue(event, 'licence')) {
241
+ errors.push('Missing "licence" tag');
242
+ }
243
+
244
+ if (!getTagValue(event, 'body')) {
245
+ errors.push('Missing "body" tag (professional body)');
246
+ }
247
+
248
+ // Bond tag validation (only when bond-address is present — all 6 tags must be present together)
249
+ const bondAddress = getTagValue(event, 'bond-address');
250
+ if (bondAddress !== undefined) {
251
+ const bondAddressType = getTagValue(event, 'bond-address-type');
252
+ const bondAmount = getTagValue(event, 'bond-amount');
253
+ const bondTimestamp = getTagValue(event, 'bond-timestamp');
254
+ const bondMessage = getTagValue(event, 'bond-message');
255
+ const bondSignature = getTagValue(event, 'bond-signature');
256
+
257
+ if (!bondAddressType) errors.push('Missing "bond-address-type" tag (required when bond-address is present)');
258
+ if (!bondAmount) errors.push('Missing "bond-amount" tag (required when bond-address is present)');
259
+ if (!bondTimestamp) errors.push('Missing "bond-timestamp" tag (required when bond-address is present)');
260
+ if (!bondMessage) errors.push('Missing "bond-message" tag (required when bond-address is present)');
261
+ if (!bondSignature) errors.push('Missing "bond-signature" tag (required when bond-address is present)');
262
+
263
+ if (bondAddressType && !(VALID_BOND_ADDRESS_TYPES as readonly string[]).includes(bondAddressType)) {
264
+ errors.push(`Invalid "bond-address-type" tag (must be one of: ${VALID_BOND_ADDRESS_TYPES.join(', ')})`);
265
+ }
266
+
267
+ if (bondAmount) {
268
+ const amountNum = parseInt(bondAmount, 10);
269
+ if (isNaN(amountNum) || amountNum <= 0) {
270
+ errors.push('Invalid "bond-amount" tag (must be a positive integer)');
271
+ }
272
+ }
273
+
274
+ if (bondMessage && !bondMessage.startsWith('signet:bond:')) {
275
+ errors.push('Invalid "bond-message" tag (must start with "signet:bond:")');
276
+ }
277
+ }
278
+
279
+ return { valid: errors.length === 0, errors };
280
+ }
281
+
282
+ /** Validate a Verifier Challenge attestation (kind 31000, type: challenge) */
283
+ export function validateChallenge(event: NostrEvent): ValidationResult {
284
+ const errors: string[] = [];
285
+
286
+ validateFieldSizeBounds(event, errors);
287
+
288
+ const base = validateAttestation(event);
289
+ if (!base.valid) {
290
+ return { valid: false, errors: [...base.errors] };
291
+ }
292
+
293
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.CHALLENGE) {
294
+ errors.push(`Expected type tag '${ATTESTATION_TYPES.CHALLENGE}'`);
295
+ }
296
+
297
+ if (!hasSignetLabel(event)) {
298
+ errors.push('Missing signet protocol label');
299
+ }
300
+
301
+ const verifierPubkey = getTagValue(event, 'd');
302
+ if (!verifierPubkey) errors.push('Missing "d" tag (verifier pubkey)');
303
+
304
+ const reason = getTagValue(event, 'reason');
305
+ const validReasons = ['anomalous-volume', 'registry-mismatch', 'fraudulent-attestation', 'licence-revoked', 'other'];
306
+ if (!reason || !validReasons.includes(reason)) {
307
+ errors.push('Missing or invalid "reason" tag');
308
+ }
309
+
310
+ if (!getTagValue(event, 'evidence-type')) {
311
+ errors.push('Missing "evidence-type" tag');
312
+ }
313
+
314
+ if (!event.content || event.content.length === 0) {
315
+ errors.push('Challenge must include evidence in content');
316
+ }
317
+
318
+ return { valid: errors.length === 0, errors };
319
+ }
320
+
321
+ /** Validate a Verifier Revocation attestation (kind 31000, type: revocation) */
322
+ export function validateRevocation(event: NostrEvent): ValidationResult {
323
+ const errors: string[] = [];
324
+
325
+ validateFieldSizeBounds(event, errors);
326
+
327
+ const base = validateAttestation(event);
328
+ if (!base.valid) {
329
+ return { valid: false, errors: [...base.errors] };
330
+ }
331
+
332
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.REVOCATION) {
333
+ errors.push(`Expected type tag '${ATTESTATION_TYPES.REVOCATION}'`);
334
+ }
335
+
336
+ if (!hasSignetLabel(event)) {
337
+ errors.push('Missing signet protocol label');
338
+ }
339
+
340
+ const verifierPubkey = getTagValue(event, 'd');
341
+ if (!verifierPubkey) errors.push('Missing "d" tag (verifier pubkey)');
342
+
343
+ if (!getTagValue(event, 'challenge')) {
344
+ errors.push('Missing "challenge" tag (challenge event ID)');
345
+ }
346
+
347
+ const confirmations = getTagValue(event, 'confirmations');
348
+ const confirmationsNum = confirmations ? parseInt(confirmations, 10) : NaN;
349
+ if (!confirmations || isNaN(confirmationsNum) || confirmationsNum < 1) {
350
+ errors.push('Missing or invalid "confirmations" tag');
351
+ }
352
+
353
+ const bondAction = getTagValue(event, 'bond-action');
354
+ if (!bondAction || !['slashed', 'returned', 'held'].includes(bondAction)) {
355
+ errors.push('Missing or invalid "bond-action" tag');
356
+ }
357
+
358
+ const scope = getTagValue(event, 'scope');
359
+ if (!scope || !['full', 'partial'].includes(scope)) {
360
+ errors.push('Missing or invalid "scope" tag');
361
+ }
362
+
363
+ return { valid: errors.length === 0, errors };
364
+ }
365
+
366
+ /** Validate any Signet event by kind + type tag */
367
+ export function validateEvent(event: NostrEvent): ValidationResult {
368
+ if (event.kind === APP_DATA_KIND) {
369
+ return validatePolicy(event);
370
+ }
371
+
372
+ if (event.kind === ATTESTATION_KIND) {
373
+ const eventType = getTagValue(event, 'type');
374
+ switch (eventType) {
375
+ case ATTESTATION_TYPES.CREDENTIAL:
376
+ return validateCredential(event);
377
+ case ATTESTATION_TYPES.VOUCH:
378
+ return validateVouch(event);
379
+ case ATTESTATION_TYPES.VERIFIER:
380
+ return validateVerifier(event);
381
+ case ATTESTATION_TYPES.CHALLENGE:
382
+ return validateChallenge(event);
383
+ case ATTESTATION_TYPES.REVOCATION:
384
+ return validateRevocation(event);
385
+ default:
386
+ return { valid: false, errors: [`Unknown attestation type: ${eventType}`] };
387
+ }
388
+ }
389
+
390
+ return { valid: false, errors: [`Unknown Signet event kind: ${event.kind}`] };
391
+ }
@@ -0,0 +1,156 @@
1
+ // Verifier Credential (kind 31000, type: verifier)
2
+ // Professional verifier registration and cross-verification
3
+
4
+ import { createAttestation, parseAttestation } from 'nostr-attestations';
5
+ import { ATTESTATION_KIND, ATTESTATION_TYPES, VERIFIER_ACTIVATION, DEFAULT_CRYPTO_ALGORITHM } from './constants.js';
6
+ import { signEvent, getPublicKey } from './crypto.js';
7
+ import { getTagValue } from './validation.js';
8
+ import { bondProofToTags, parseBondProof } from './bonds.js';
9
+ import type {
10
+ NostrEvent,
11
+ UnsignedEvent,
12
+ VerifierParams,
13
+ ParsedVerifier,
14
+ CryptoAlgorithm,
15
+ } from './types.js';
16
+
17
+ /** Build an unsigned verifier credential event */
18
+ export function buildVerifierEvent(
19
+ verifierPubkey: string,
20
+ params: VerifierParams
21
+ ): UnsignedEvent {
22
+ const signetTags: string[][] = [
23
+ ['profession', params.profession],
24
+ ['jurisdiction', params.jurisdiction],
25
+ ['licence', params.licenceHash],
26
+ ['body', params.professionalBody],
27
+ ['algo', DEFAULT_CRYPTO_ALGORITHM],
28
+ ];
29
+
30
+ if (params.bondProof) {
31
+ signetTags.push(...bondProofToTags(params.bondProof));
32
+ }
33
+
34
+ const template = createAttestation({
35
+ type: ATTESTATION_TYPES.VERIFIER,
36
+ summary: `${params.profession} verifier in ${params.jurisdiction}`,
37
+ tags: signetTags,
38
+ content: params.statement || '',
39
+ });
40
+
41
+ return {
42
+ ...template,
43
+ pubkey: verifierPubkey,
44
+ created_at: Math.floor(Date.now() / 1000),
45
+ };
46
+ }
47
+
48
+ /** Create and sign a verifier credential */
49
+ export async function createVerifierCredential(
50
+ privateKey: string,
51
+ params: VerifierParams
52
+ ): Promise<NostrEvent> {
53
+ const pubkey = getPublicKey(privateKey);
54
+ const event = buildVerifierEvent(pubkey, params);
55
+ return signEvent(event, privateKey);
56
+ }
57
+
58
+ /** Parse a verifier credential event */
59
+ export function parseVerifier(event: NostrEvent): ParsedVerifier | null {
60
+ const base = parseAttestation(event);
61
+ if (!base) return null;
62
+ if (base.type !== ATTESTATION_TYPES.VERIFIER) return null;
63
+
64
+ const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
65
+
66
+ return {
67
+ profession: getTagValue(event, 'profession') || '',
68
+ jurisdiction: getTagValue(event, 'jurisdiction') || '',
69
+ licenceHash: getTagValue(event, 'licence') || '',
70
+ professionalBody: getTagValue(event, 'body') || '',
71
+ algorithm,
72
+ bondProof: parseBondProof(event) ?? undefined,
73
+ };
74
+ }
75
+
76
+ /** Check if a verifier has sufficient cross-verification.
77
+ * Requires vouches from verified professionals in different fields. */
78
+ export function checkCrossVerification(
79
+ verifierPubkey: string,
80
+ vouches: NostrEvent[],
81
+ verifierCredentials: NostrEvent[]
82
+ ): {
83
+ activated: boolean;
84
+ vouchCount: number;
85
+ professions: string[];
86
+ errors: string[];
87
+ } {
88
+ const errors: string[] = [];
89
+ const professionsSet = new Set<string>();
90
+ const vouchersSeen = new Set<string>();
91
+ let vouchCount = 0;
92
+
93
+ // Build a map of verifier pubkey -> profession
94
+ const verifierProfessions = new Map<string, string>();
95
+ for (const cred of verifierCredentials) {
96
+ if (cred.kind !== ATTESTATION_KIND) continue;
97
+ if (getTagValue(cred, 'type') !== ATTESTATION_TYPES.VERIFIER) continue;
98
+ const profession = getTagValue(cred, 'profession');
99
+ if (profession) {
100
+ verifierProfessions.set(cred.pubkey, profession);
101
+ }
102
+ }
103
+
104
+ // Count qualifying vouches (from other verified professionals)
105
+ for (const vouch of vouches) {
106
+ if (vouch.kind !== ATTESTATION_KIND) continue;
107
+ if (getTagValue(vouch, 'type') !== ATTESTATION_TYPES.VOUCH) continue;
108
+
109
+ const dTag = getTagValue(vouch, 'd') || '';
110
+ const subject = dTag.startsWith('vouch:') ? dTag.slice('vouch:'.length) : dTag;
111
+ if (subject !== verifierPubkey) continue;
112
+
113
+ // Voucher must themselves be a verified professional
114
+ const voucherProfession = verifierProfessions.get(vouch.pubkey);
115
+ if (!voucherProfession) continue;
116
+
117
+ // One vouch per voucher
118
+ if (vouchersSeen.has(vouch.pubkey)) continue;
119
+ vouchersSeen.add(vouch.pubkey);
120
+
121
+ professionsSet.add(voucherProfession);
122
+ vouchCount++;
123
+ }
124
+
125
+ const professions = Array.from(professionsSet);
126
+ const activated =
127
+ vouchCount >= VERIFIER_ACTIVATION.MIN_VOUCHES &&
128
+ professions.length >= VERIFIER_ACTIVATION.MIN_PROFESSIONS;
129
+
130
+ if (vouchCount < VERIFIER_ACTIVATION.MIN_VOUCHES) {
131
+ errors.push(
132
+ `Need ${VERIFIER_ACTIVATION.MIN_VOUCHES} vouches from verified professionals, have ${vouchCount}`
133
+ );
134
+ }
135
+ if (professions.length < VERIFIER_ACTIVATION.MIN_PROFESSIONS) {
136
+ errors.push(
137
+ `Need vouches from ${VERIFIER_ACTIVATION.MIN_PROFESSIONS} different professions, have ${professions.length}`
138
+ );
139
+ }
140
+
141
+ return { activated, vouchCount, professions, errors };
142
+ }
143
+
144
+ /** Check if a verifier credential has been revoked */
145
+ export function isVerifierRevoked(
146
+ verifierPubkey: string,
147
+ revocations: NostrEvent[]
148
+ ): boolean {
149
+ return revocations.some((rev) => {
150
+ if (rev.kind !== ATTESTATION_KIND) return false;
151
+ if (getTagValue(rev, 'type') !== ATTESTATION_TYPES.REVOCATION) return false;
152
+ const dTag = getTagValue(rev, 'd') || '';
153
+ const target = dTag.startsWith('revocation:') ? dTag.slice('revocation:'.length) : dTag;
154
+ return target === verifierPubkey;
155
+ });
156
+ }
package/src/vouches.ts ADDED
@@ -0,0 +1,141 @@
1
+ // Vouch Attestation (kind 31000, type: vouch)
2
+ // Create and manage peer vouches
3
+
4
+ import { createAttestation } from 'nostr-attestations';
5
+ import { parseAttestation } from 'nostr-attestations';
6
+ import { ATTESTATION_KIND, ATTESTATION_TYPES, DEFAULT_VOUCH_THRESHOLD, DEFAULT_VOUCHER_MIN_TIER, DEFAULT_CRYPTO_ALGORITHM } from './constants.js';
7
+ import { signEvent, getPublicKey } from './crypto.js';
8
+ import { getTagValue } from './validation.js';
9
+ import type {
10
+ NostrEvent,
11
+ UnsignedEvent,
12
+ VouchParams,
13
+ ParsedVouch,
14
+ VouchMethod,
15
+ SignetTier,
16
+ CryptoAlgorithm,
17
+ } from './types.js';
18
+
19
+ /** Build an unsigned vouch event */
20
+ export function buildVouchEvent(
21
+ voucherPubkey: string,
22
+ params: VouchParams
23
+ ): UnsignedEvent {
24
+ const signetTags: string[][] = [
25
+ ['method', params.method],
26
+ ['voucher-tier', String(params.voucherTier)],
27
+ ['voucher-score', String(params.voucherScore)],
28
+ ['algo', DEFAULT_CRYPTO_ALGORITHM],
29
+ ];
30
+
31
+ if (params.context) signetTags.push(['context', params.context]);
32
+
33
+ const template = createAttestation({
34
+ type: ATTESTATION_TYPES.VOUCH,
35
+ identifier: params.subjectPubkey,
36
+ subject: params.subjectPubkey,
37
+ occurredAt: params.occurredAt,
38
+ summary: `${params.method} vouch for ${params.subjectPubkey.slice(0, 8)}...`,
39
+ tags: signetTags,
40
+ });
41
+
42
+ return {
43
+ ...template,
44
+ pubkey: voucherPubkey,
45
+ created_at: Math.floor(Date.now() / 1000),
46
+ };
47
+ }
48
+
49
+ /** Create and sign a vouch for another user */
50
+ export async function createVouch(
51
+ voucherPrivateKey: string,
52
+ params: VouchParams
53
+ ): Promise<NostrEvent> {
54
+ const pubkey = getPublicKey(voucherPrivateKey);
55
+ const event = buildVouchEvent(pubkey, params);
56
+ return signEvent(event, voucherPrivateKey);
57
+ }
58
+
59
+ /** Parse a vouch event into a structured object */
60
+ export function parseVouch(event: NostrEvent): ParsedVouch | null {
61
+ const base = parseAttestation(event);
62
+ if (!base) return null;
63
+ if (base.type !== ATTESTATION_TYPES.VOUCH) return null;
64
+
65
+ const tier = getTagValue(event, 'voucher-tier');
66
+ const score = getTagValue(event, 'voucher-score');
67
+
68
+ const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
69
+
70
+ const subjectPubkey = base.identifier ?? '';
71
+
72
+ return {
73
+ subjectPubkey,
74
+ method: (getTagValue(event, 'method') || 'online') as VouchMethod,
75
+ context: getTagValue(event, 'context'),
76
+ voucherTier: (() => { const t = tier ? parseInt(tier, 10) : NaN; return (!isNaN(t) && t >= 1 && t <= 4 ? t : 1) as SignetTier; })(),
77
+ voucherScore: (() => { const v = score ? parseInt(score, 10) : 50; return isNaN(v) ? 50 : Math.max(0, Math.min(v, 200)); })(),
78
+ algorithm,
79
+ };
80
+ }
81
+
82
+ /** Count qualifying vouches for a subject from a set of vouch events */
83
+ export function countQualifyingVouches(
84
+ vouches: NostrEvent[],
85
+ subjectPubkey: string,
86
+ minVoucherTier: SignetTier = DEFAULT_VOUCHER_MIN_TIER as SignetTier
87
+ ): number {
88
+ const seen = new Set<string>();
89
+ let count = 0;
90
+
91
+ for (const vouch of vouches) {
92
+ if (vouch.kind !== ATTESTATION_KIND) continue;
93
+ if (getTagValue(vouch, 'type') !== ATTESTATION_TYPES.VOUCH) continue;
94
+
95
+ const dTag = getTagValue(vouch, 'd') || '';
96
+ const subject = dTag.startsWith('vouch:') ? dTag.slice('vouch:'.length) : dTag;
97
+ if (subject !== subjectPubkey) continue;
98
+
99
+ const tier = getTagValue(vouch, 'voucher-tier');
100
+ const tierNum = tier ? parseInt(tier, 10) : 0;
101
+ if (isNaN(tierNum) || tierNum < minVoucherTier) continue;
102
+
103
+ // One vouch per voucher
104
+ if (seen.has(vouch.pubkey)) continue;
105
+ seen.add(vouch.pubkey);
106
+
107
+ count++;
108
+ }
109
+
110
+ return count;
111
+ }
112
+
113
+ /** Check if a subject has enough qualifying vouches for Tier 2 */
114
+ export function hasEnoughVouches(
115
+ vouches: NostrEvent[],
116
+ subjectPubkey: string,
117
+ threshold: number = DEFAULT_VOUCH_THRESHOLD,
118
+ minVoucherTier: SignetTier = DEFAULT_VOUCHER_MIN_TIER as SignetTier
119
+ ): boolean {
120
+ return countQualifyingVouches(vouches, subjectPubkey, minVoucherTier) >= threshold;
121
+ }
122
+
123
+ /** Get unique voucher pubkeys for a subject */
124
+ export function getVouchers(
125
+ vouches: NostrEvent[],
126
+ subjectPubkey: string
127
+ ): string[] {
128
+ const vouchers = new Set<string>();
129
+
130
+ for (const vouch of vouches) {
131
+ if (vouch.kind !== ATTESTATION_KIND) continue;
132
+ if (getTagValue(vouch, 'type') !== ATTESTATION_TYPES.VOUCH) continue;
133
+ const dTag = getTagValue(vouch, 'd') || '';
134
+ const subject = dTag.startsWith('vouch:') ? dTag.slice('vouch:'.length) : dTag;
135
+ if (subject === subjectPubkey) {
136
+ vouchers.add(vouch.pubkey);
137
+ }
138
+ }
139
+
140
+ return Array.from(vouchers);
141
+ }