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,76 @@
1
+ // SAG Ring Signatures — thin re-export from @forgesworn/ring-sig with Signet domain separator
2
+ // Proves "one of N public keys signed this message" without revealing which one.
3
+ // Used for Tier 3/4 issuer privacy: "a professional verified this" without revealing which professional.
4
+
5
+ import { ringSign as _ringSign, ringVerify as _ringVerify, type RingSignature, MAX_RING_SIZE } from '@forgesworn/ring-sig';
6
+
7
+ const SIGNET_SAG_DOMAIN = 'signet-sag-v1';
8
+
9
+ export { MAX_RING_SIZE, type RingSignature };
10
+
11
+ /**
12
+ * Sign a message with a ring signature using Signet's domain separator.
13
+ *
14
+ * @param message - The message to sign (will be hashed)
15
+ * @param ring - Array of x-only public keys (hex) forming the anonymity set
16
+ * @param signerIndex - Index of the actual signer in the ring
17
+ * @param privateKey - Signer's private key (hex)
18
+ * @returns A ring signature
19
+ */
20
+ export function ringSign(
21
+ message: string,
22
+ ring: string[],
23
+ signerIndex: number,
24
+ privateKey: string
25
+ ): RingSignature {
26
+ const sig = _ringSign(message, ring, signerIndex, privateKey, SIGNET_SAG_DOMAIN);
27
+ // Ensure the domain is carried in the signature so ringVerify uses the correct domain
28
+ sig.domain = SIGNET_SAG_DOMAIN;
29
+ return sig;
30
+ }
31
+
32
+ /**
33
+ * Verify a ring signature.
34
+ *
35
+ * @param sig - The ring signature to verify
36
+ * @returns true if the signature is valid
37
+ */
38
+ export function ringVerify(sig: RingSignature): boolean {
39
+ return _ringVerify(sig);
40
+ }
41
+
42
+ /**
43
+ * Create a ring signature for a Signet credential.
44
+ * Wraps ringSign with credential-specific message construction.
45
+ *
46
+ * @param credentialEventId - The event ID of the credential being signed
47
+ * @param subjectPubkey - The pubkey of the person being verified
48
+ * @param ring - Array of verifier pubkeys forming the anonymity set
49
+ * @param signerIndex - Index of the actual signing verifier
50
+ * @param privateKey - Signing verifier's private key
51
+ */
52
+ export function signCredentialRing(
53
+ credentialEventId: string,
54
+ subjectPubkey: string,
55
+ ring: string[],
56
+ signerIndex: number,
57
+ privateKey: string
58
+ ): RingSignature {
59
+ const message = `signet:credential:${credentialEventId}:${subjectPubkey}`;
60
+ const sig = _ringSign(message, ring, signerIndex, privateKey, SIGNET_SAG_DOMAIN);
61
+ sig.domain = SIGNET_SAG_DOMAIN;
62
+ return sig;
63
+ }
64
+
65
+ /**
66
+ * Verify a ring signature on a Signet credential.
67
+ */
68
+ export function verifyCredentialRing(
69
+ sig: RingSignature,
70
+ credentialEventId: string,
71
+ subjectPubkey: string
72
+ ): boolean {
73
+ const expectedMessage = `signet:credential:${credentialEventId}:${subjectPubkey}`;
74
+ if (sig.message !== expectedMessage) return false;
75
+ return _ringVerify(sig);
76
+ }
@@ -0,0 +1,122 @@
1
+ // Signet Words — Time-based word verification ("signet me")
2
+ // Signet handles identity (who you are). Spoken-token handles token derivation.
3
+ // Uses SPOKEN-DERIVE (HMAC-SHA256) and the spoken-clarity wordlist.
4
+
5
+ import { deriveTokenBytes } from 'spoken-token';
6
+ import { encodeAsWords } from 'spoken-token/encoding';
7
+ import { WORDLIST } from 'spoken-token/wordlist';
8
+ import { constantTimeEqual } from './utils.js';
9
+ import { utf8ToBytes } from '@noble/hashes/utils.js';
10
+ import { SignetValidationError } from './errors.js';
11
+
12
+ /** The Canary spoken-clarity wordlist used for Signet word verification. */
13
+ export { WORDLIST as SIGNET_WORDLIST } from 'spoken-token/wordlist';
14
+
15
+ /** Default: words rotate every 30 seconds */
16
+ export const SIGNET_EPOCH_SECONDS = 30;
17
+
18
+ /** Default: 3 words per signet */
19
+ export const SIGNET_WORD_COUNT = 3;
20
+
21
+ /** Default: accept +/-1 epoch window for clock skew tolerance */
22
+ export const SIGNET_TOLERANCE = 1;
23
+
24
+ /** Maximum supported word count (limited by HMAC output bytes: 32 bytes / 2 bytes per word = 16) */
25
+ export const MAX_WORD_COUNT = 16;
26
+
27
+ /** Context string for Signet word derivation via CANARY-DERIVE */
28
+ const SIGNET_CONTEXT = 'signet:verify';
29
+
30
+ /** Configuration options for signet words */
31
+ export interface SignetWordsConfig {
32
+ /** Number of words to derive (1-16, default: 3) */
33
+ wordCount?: number;
34
+ /** Epoch interval in seconds (default: 30) */
35
+ epochSeconds?: number;
36
+ /** Tolerance in epochs, +/- (default: 1) */
37
+ tolerance?: number;
38
+ }
39
+
40
+ /** Return the current epoch index for a given interval. */
41
+ export function getEpoch(timestampMs?: number, epochSeconds: number = SIGNET_EPOCH_SECONDS): number {
42
+ if (epochSeconds < 1) throw new SignetValidationError('epochSeconds must be >= 1');
43
+ return Math.floor((timestampMs ?? Date.now()) / (epochSeconds * 1000));
44
+ }
45
+
46
+ /** Derive N words from a shared secret and epoch number.
47
+ * Uses SPOKEN-DERIVE (HMAC-SHA256) and encodes as spoken-clarity words.
48
+ * @param sharedSecret - Hex string or Uint8Array shared secret (minimum 16 bytes).
49
+ * @param epoch - Time-based epoch counter (uint32).
50
+ * @param wordCount - Number of words to derive (1-16, default 3).
51
+ * @param context - Domain-separation context string (default: 'signet:verify'). */
52
+ export function deriveWords(
53
+ sharedSecret: Uint8Array | string,
54
+ epoch: number,
55
+ wordCount: number = SIGNET_WORD_COUNT,
56
+ context: string = SIGNET_CONTEXT,
57
+ ): string[] {
58
+ if (wordCount < 1 || wordCount > MAX_WORD_COUNT) {
59
+ throw new SignetValidationError(`wordCount must be between 1 and ${MAX_WORD_COUNT}`);
60
+ }
61
+
62
+ const bytes = deriveTokenBytes(sharedSecret, context, epoch);
63
+ return encodeAsWords(bytes, wordCount, WORDLIST);
64
+ }
65
+
66
+ /** Get the current word sequence for a shared secret. */
67
+ export function getSignetWords(sharedSecret: string, timestampMs?: number, config?: SignetWordsConfig): string[] {
68
+ const epochSeconds = config?.epochSeconds ?? SIGNET_EPOCH_SECONDS;
69
+ const wordCount = config?.wordCount ?? SIGNET_WORD_COUNT;
70
+ return deriveWords(sharedSecret, getEpoch(timestampMs, epochSeconds), wordCount);
71
+ }
72
+
73
+ /** Verify that the given words match the current epoch (+/- tolerance).
74
+ * Returns true if the words match any epoch within the tolerance window. */
75
+ export function verifySignetWords(
76
+ sharedSecret: string,
77
+ words: string[],
78
+ timestampMs?: number,
79
+ config?: SignetWordsConfig,
80
+ ): boolean {
81
+ const epochSeconds = config?.epochSeconds ?? SIGNET_EPOCH_SECONDS;
82
+ const wordCount = config?.wordCount ?? SIGNET_WORD_COUNT;
83
+ const tolerance = config?.tolerance ?? SIGNET_TOLERANCE;
84
+ const currentEpoch = getEpoch(timestampMs, epochSeconds);
85
+
86
+ for (let offset = -tolerance; offset <= tolerance; offset++) {
87
+ const candidate = deriveWords(sharedSecret, currentEpoch + offset, wordCount);
88
+ if (candidate.length === words.length) {
89
+ const a = utf8ToBytes(candidate.join('\0'));
90
+ const b = utf8ToBytes(words.join('\0'));
91
+ if (constantTimeEqual(a, b)) {
92
+ return true;
93
+ }
94
+ }
95
+ }
96
+
97
+ return false;
98
+ }
99
+
100
+ /** Format words with middle-dot separator: "ocean · tiger · marble" */
101
+ export function formatSignetWords(words: string[]): string {
102
+ return words.join(' \u00b7 ');
103
+ }
104
+
105
+ /** Get everything needed for UI display: words, formatted string, and countdown. */
106
+ export function getSignetDisplay(
107
+ sharedSecret: string,
108
+ timestampMs?: number,
109
+ config?: SignetWordsConfig,
110
+ ): { words: string[]; formatted: string; expiresIn: number } {
111
+ const epochSeconds = config?.epochSeconds ?? SIGNET_EPOCH_SECONDS;
112
+ const now = timestampMs ?? Date.now();
113
+ const words = getSignetWords(sharedSecret, now, config);
114
+ const formatted = formatSignetWords(words);
115
+
116
+ // Seconds until the next epoch boundary
117
+ const epochMs = epochSeconds * 1000;
118
+ const msIntoEpoch = now % epochMs;
119
+ const expiresIn = (epochMs - msIntoEpoch) / 1000;
120
+
121
+ return { words, formatted, expiresIn };
122
+ }
package/src/store.ts ADDED
@@ -0,0 +1,336 @@
1
+ // Signet Event Store
2
+ // In-memory event storage with query support and JSON serialization
3
+
4
+ import { ATTESTATION_KIND, ATTESTATION_TYPES, APP_DATA_KIND } from './constants.js';
5
+ import { getTagValue, validateFieldSizeBounds } from './validation.js';
6
+ import { SignetValidationError } from './errors.js';
7
+ import type {
8
+ NostrEvent,
9
+ ParsedCredential,
10
+ } from './types.js';
11
+ import { parseCredential } from './credentials.js';
12
+
13
+ /** Query options for filtering events */
14
+ export interface StoreQuery {
15
+ kinds?: number[];
16
+ authors?: string[];
17
+ subjects?: string[]; // query by 'd' tag
18
+ since?: number;
19
+ until?: number;
20
+ limit?: number;
21
+ }
22
+
23
+ /**
24
+ * In-memory Signet event store.
25
+ * Stores events indexed by kind, author, and subject for fast queries.
26
+ * Handles replaceable event semantics (kind 30xxx: newer replaces older for same d-tag).
27
+ */
28
+ export class SignetStore {
29
+ private events = new Map<string, NostrEvent>();
30
+
31
+ // Indexes for fast queries
32
+ private byKind = new Map<number, Set<string>>();
33
+ private byAuthor = new Map<string, Set<string>>();
34
+ private bySubject = new Map<string, Set<string>>();
35
+
36
+ /** Add an event to the store. Returns true if added (not a duplicate/older). */
37
+ add(event: NostrEvent): boolean {
38
+ // Check if this is a replaceable event (kind 30000-39999)
39
+ if (event.kind >= 30000 && event.kind < 40000) {
40
+ const dTag = getTagValue(event, 'd');
41
+
42
+ // Find existing event with same replace key
43
+ for (const [id, existing] of this.events) {
44
+ if (existing.kind === event.kind && existing.pubkey === event.pubkey) {
45
+ const existingD = getTagValue(existing, 'd');
46
+ if (existingD === dTag) {
47
+ if (existing.created_at >= event.created_at) {
48
+ return false; // existing is newer or same
49
+ }
50
+ // Remove older event
51
+ this.remove(id);
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // Deduplicate by event ID
59
+ if (this.events.has(event.id)) return false;
60
+
61
+ this.events.set(event.id, event);
62
+ this.indexEvent(event);
63
+ return true;
64
+ }
65
+
66
+ /** Remove an event by ID */
67
+ remove(id: string): boolean {
68
+ const event = this.events.get(id);
69
+ if (!event) return false;
70
+
71
+ this.events.delete(id);
72
+ this.deindexEvent(event);
73
+ return true;
74
+ }
75
+
76
+ /** Get an event by ID */
77
+ get(id: string): NostrEvent | undefined {
78
+ return this.events.get(id);
79
+ }
80
+
81
+ /** Check if an event exists */
82
+ has(id: string): boolean {
83
+ return this.events.has(id);
84
+ }
85
+
86
+ /** Total number of events */
87
+ get size(): number {
88
+ return this.events.size;
89
+ }
90
+
91
+ /** Query events with filters */
92
+ query(q: StoreQuery = {}): NostrEvent[] {
93
+ let candidateIds: Set<string> | null = null;
94
+
95
+ // Narrow by kind
96
+ if (q.kinds?.length) {
97
+ const kindIds = new Set<string>();
98
+ for (const kind of q.kinds) {
99
+ const ids = this.byKind.get(kind);
100
+ if (ids) ids.forEach((id) => kindIds.add(id));
101
+ }
102
+ candidateIds = kindIds;
103
+ }
104
+
105
+ // Narrow by author
106
+ if (q.authors?.length) {
107
+ const authorIds = new Set<string>();
108
+ for (const author of q.authors) {
109
+ const ids = this.byAuthor.get(author);
110
+ if (ids) ids.forEach((id) => authorIds.add(id));
111
+ }
112
+ candidateIds = candidateIds
113
+ ? intersect(candidateIds, authorIds)
114
+ : authorIds;
115
+ }
116
+
117
+ // Narrow by subject
118
+ if (q.subjects?.length) {
119
+ const subjectIds = new Set<string>();
120
+ for (const subject of q.subjects) {
121
+ const ids = this.bySubject.get(subject);
122
+ if (ids) ids.forEach((id) => subjectIds.add(id));
123
+ }
124
+ candidateIds = candidateIds
125
+ ? intersect(candidateIds, subjectIds)
126
+ : subjectIds;
127
+ }
128
+
129
+ // Collect events
130
+ const ids = candidateIds ?? new Set(this.events.keys());
131
+ let results: NostrEvent[] = [];
132
+ for (const id of ids) {
133
+ const event = this.events.get(id);
134
+ if (!event) continue;
135
+
136
+ if (q.since && event.created_at < q.since) continue;
137
+ if (q.until && event.created_at > q.until) continue;
138
+
139
+ results.push(event);
140
+ }
141
+
142
+ // Sort by created_at descending (newest first)
143
+ results.sort((a, b) => b.created_at - a.created_at);
144
+
145
+ if (q.limit && results.length > q.limit) {
146
+ results = results.slice(0, q.limit);
147
+ }
148
+
149
+ return results;
150
+ }
151
+
152
+ // --- Convenience query methods ---
153
+
154
+ /** Get all credentials for a subject */
155
+ getCredentials(subjectPubkey: string): NostrEvent[] {
156
+ return this.query({ kinds: [ATTESTATION_KIND], subjects: [`credential:${subjectPubkey}`, subjectPubkey] })
157
+ .filter((e) => getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL);
158
+ }
159
+
160
+ /** Get the highest-tier credential for a subject */
161
+ getHighestCredential(subjectPubkey: string): ParsedCredential | null {
162
+ const creds = this.getCredentials(subjectPubkey);
163
+ let highest: ParsedCredential | null = null;
164
+
165
+ for (const cred of creds) {
166
+ const parsed = parseCredential(cred);
167
+ if (parsed && (!highest || parsed.tier > highest.tier)) {
168
+ highest = parsed;
169
+ }
170
+ }
171
+
172
+ return highest;
173
+ }
174
+
175
+ /** Get all vouches for a subject */
176
+ getVouches(subjectPubkey: string): NostrEvent[] {
177
+ return this.query({ kinds: [ATTESTATION_KIND], subjects: [`vouch:${subjectPubkey}`, subjectPubkey] })
178
+ .filter((e) => getTagValue(e, 'type') === ATTESTATION_TYPES.VOUCH);
179
+ }
180
+
181
+ /** Get the community policy for a community */
182
+ getPolicy(communityId: string): NostrEvent | undefined {
183
+ const policies = this.query({ kinds: [APP_DATA_KIND] });
184
+ return policies.find((p) => {
185
+ const dTag = getTagValue(p, 'd') || '';
186
+ return dTag === `signet:policy:${communityId}`;
187
+ });
188
+ }
189
+
190
+ /** Get a verifier credential */
191
+ getVerifierCredential(verifierPubkey: string): NostrEvent | undefined {
192
+ const creds = this.query({ kinds: [ATTESTATION_KIND], authors: [verifierPubkey] })
193
+ .filter((e) => getTagValue(e, 'type') === ATTESTATION_TYPES.VERIFIER);
194
+ return creds[0];
195
+ }
196
+
197
+ /** Get all challenges against a verifier */
198
+ getChallenges(verifierPubkey: string): NostrEvent[] {
199
+ return this.query({ kinds: [ATTESTATION_KIND], subjects: [`challenge:${verifierPubkey}`, verifierPubkey] })
200
+ .filter((e) => getTagValue(e, 'type') === ATTESTATION_TYPES.CHALLENGE);
201
+ }
202
+
203
+ /** Get all revocations for a verifier */
204
+ getRevocations(verifierPubkey: string): NostrEvent[] {
205
+ return this.query({ kinds: [ATTESTATION_KIND], subjects: [`revocation:${verifierPubkey}`, verifierPubkey] })
206
+ .filter((e) => getTagValue(e, 'type') === ATTESTATION_TYPES.REVOCATION);
207
+ }
208
+
209
+ /** Check if a verifier is revoked */
210
+ isRevoked(verifierPubkey: string): boolean {
211
+ return this.getRevocations(verifierPubkey).length > 0;
212
+ }
213
+
214
+ /** Get all credentials issued by a verifier */
215
+ getCredentialsByIssuer(issuerPubkey: string): NostrEvent[] {
216
+ return this.query({ kinds: [ATTESTATION_KIND], authors: [issuerPubkey] })
217
+ .filter((e) => getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL);
218
+ }
219
+
220
+ // --- Serialization ---
221
+
222
+ /** Export all events as JSON */
223
+ export(): string {
224
+ return JSON.stringify(Array.from(this.events.values()));
225
+ }
226
+
227
+ /** Import events from JSON.
228
+ *
229
+ * WARNING: This method validates structural shape and field-size bounds but does
230
+ * NOT verify event signatures. Callers are responsible for verifying cryptographic
231
+ * validity before importing untrusted data. The RelayClient verifies signatures
232
+ * by default — this gap only affects direct callers of store.import(). */
233
+ import(json: string): number {
234
+ let parsed: unknown;
235
+ try {
236
+ parsed = JSON.parse(json);
237
+ } catch {
238
+ throw new SignetValidationError('Import data is not valid JSON');
239
+ }
240
+ if (!Array.isArray(parsed)) {
241
+ throw new SignetValidationError('Import data must be a JSON array');
242
+ }
243
+ const MAX_IMPORT_SIZE = 10_000;
244
+ if (parsed.length > MAX_IMPORT_SIZE) {
245
+ throw new SignetValidationError(`Import array too large: ${parsed.length} items (max ${MAX_IMPORT_SIZE})`);
246
+ }
247
+ const events: NostrEvent[] = [];
248
+ for (const item of parsed) {
249
+ if (
250
+ typeof item !== 'object' || item === null ||
251
+ typeof item.id !== 'string' ||
252
+ typeof item.pubkey !== 'string' ||
253
+ typeof item.kind !== 'number' ||
254
+ typeof item.created_at !== 'number' ||
255
+ !Array.isArray(item.tags) ||
256
+ typeof item.content !== 'string' ||
257
+ typeof item.sig !== 'string' ||
258
+ !item.tags.every((t: unknown) => Array.isArray(t) && t.every((v: unknown) => typeof v === 'string'))
259
+ ) {
260
+ continue; // skip malformed entries
261
+ }
262
+ // Validate hex format of id, pubkey, sig
263
+ if (!/^[0-9a-f]{64}$/.test(item.id) || !/^[0-9a-f]{64}$/.test(item.pubkey) || !/^[0-9a-f]{128}$/.test(item.sig)) {
264
+ continue;
265
+ }
266
+ // Validate field-size bounds on imported events
267
+ const boundsErrors: string[] = [];
268
+ validateFieldSizeBounds(item as NostrEvent, boundsErrors);
269
+ if (boundsErrors.length > 0) continue;
270
+
271
+ events.push(item as NostrEvent);
272
+ }
273
+ let added = 0;
274
+ for (const event of events) {
275
+ if (this.add(event)) added++;
276
+ }
277
+ return added;
278
+ }
279
+
280
+ /** Clear all events */
281
+ clear(): void {
282
+ this.events.clear();
283
+ this.byKind.clear();
284
+ this.byAuthor.clear();
285
+ this.bySubject.clear();
286
+ }
287
+
288
+ // --- Index management ---
289
+
290
+ private indexEvent(event: NostrEvent): void {
291
+ // Index by kind
292
+ if (!this.byKind.has(event.kind)) this.byKind.set(event.kind, new Set());
293
+ this.byKind.get(event.kind)!.add(event.id);
294
+
295
+ // Index by author
296
+ if (!this.byAuthor.has(event.pubkey)) this.byAuthor.set(event.pubkey, new Set());
297
+ this.byAuthor.get(event.pubkey)!.add(event.id);
298
+
299
+ // Index by subject (d tag)
300
+ const subject = getTagValue(event, 'd');
301
+ if (subject) {
302
+ if (!this.bySubject.has(subject)) this.bySubject.set(subject, new Set());
303
+ this.bySubject.get(subject)!.add(event.id);
304
+ }
305
+
306
+ // Also index by p tag (many events reference subjects via p tag)
307
+ const pTags = event.tags.filter((t) => t[0] === 'p').map((t) => t[1]);
308
+ for (const p of pTags) {
309
+ if (!this.bySubject.has(p)) this.bySubject.set(p, new Set());
310
+ this.bySubject.get(p)!.add(event.id);
311
+ }
312
+ }
313
+
314
+ private deindexEvent(event: NostrEvent): void {
315
+ this.byKind.get(event.kind)?.delete(event.id);
316
+ this.byAuthor.get(event.pubkey)?.delete(event.id);
317
+
318
+ const subject = getTagValue(event, 'd');
319
+ if (subject) this.bySubject.get(subject)?.delete(event.id);
320
+
321
+ const pTags = event.tags.filter((t) => t[0] === 'p').map((t) => t[1]);
322
+ for (const p of pTags) {
323
+ this.bySubject.get(p)?.delete(event.id);
324
+ }
325
+ }
326
+ }
327
+
328
+ /** Set intersection */
329
+ function intersect(a: Set<string>, b: Set<string>): Set<string> {
330
+ const result = new Set<string>();
331
+ const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];
332
+ for (const item of smaller) {
333
+ if (larger.has(item)) result.add(item);
334
+ }
335
+ return result;
336
+ }