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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/anomaly.d.ts +42 -0
- package/dist/anomaly.d.ts.map +1 -0
- package/dist/anomaly.js +209 -0
- package/dist/anomaly.js.map +1 -0
- package/dist/badge.d.ts +56 -0
- package/dist/badge.d.ts.map +1 -0
- package/dist/badge.js +171 -0
- package/dist/badge.js.map +1 -0
- package/dist/bonds.d.ts +39 -0
- package/dist/bonds.d.ts.map +1 -0
- package/dist/bonds.js +149 -0
- package/dist/bonds.js.map +1 -0
- package/dist/challenges.d.ts +18 -0
- package/dist/challenges.d.ts.map +1 -0
- package/dist/challenges.js +145 -0
- package/dist/challenges.js.map +1 -0
- package/dist/cold-call.d.ts +74 -0
- package/dist/cold-call.d.ts.map +1 -0
- package/dist/cold-call.js +176 -0
- package/dist/cold-call.js.map +1 -0
- package/dist/compliance.d.ts +82 -0
- package/dist/compliance.d.ts.map +1 -0
- package/dist/compliance.js +478 -0
- package/dist/compliance.js.map +1 -0
- package/dist/connections.d.ts +63 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +170 -0
- package/dist/connections.js.map +1 -0
- package/dist/constants.d.ts +86 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +124 -0
- package/dist/constants.js.map +1 -0
- package/dist/credentials.d.ts +190 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +686 -0
- package/dist/credentials.js.map +1 -0
- package/dist/crypto.d.ts +27 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +75 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -0
- package/dist/i18n.d.ts +98 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +1118 -0
- package/dist/i18n.js.map +1 -0
- package/dist/identity-bridge.d.ts +52 -0
- package/dist/identity-bridge.d.ts.map +1 -0
- package/dist/identity-bridge.js +228 -0
- package/dist/identity-bridge.js.map +1 -0
- package/dist/identity-tree.d.ts +47 -0
- package/dist/identity-tree.d.ts.map +1 -0
- package/dist/identity-tree.js +69 -0
- package/dist/identity-tree.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/dist/key-derivation.d.ts +43 -0
- package/dist/key-derivation.d.ts.map +1 -0
- package/dist/key-derivation.js +212 -0
- package/dist/key-derivation.js.map +1 -0
- package/dist/lsag.d.ts +23 -0
- package/dist/lsag.d.ts.map +1 -0
- package/dist/lsag.js +35 -0
- package/dist/lsag.js.map +1 -0
- package/dist/merkle.d.ts +19 -0
- package/dist/merkle.d.ts.map +1 -0
- package/dist/merkle.js +155 -0
- package/dist/merkle.js.map +1 -0
- package/dist/policies.d.ts +22 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +123 -0
- package/dist/policies.js.map +1 -0
- package/dist/range-proof.d.ts +6 -0
- package/dist/range-proof.d.ts.map +1 -0
- package/dist/range-proof.js +45 -0
- package/dist/range-proof.js.map +1 -0
- package/dist/relay.d.ts +106 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +336 -0
- package/dist/relay.js.map +1 -0
- package/dist/ring-signature.d.ts +35 -0
- package/dist/ring-signature.d.ts.map +1 -0
- package/dist/ring-signature.js +56 -0
- package/dist/ring-signature.js.map +1 -0
- package/dist/shamir.d.ts +55 -0
- package/dist/shamir.d.ts.map +1 -0
- package/dist/shamir.js +253 -0
- package/dist/shamir.js.map +1 -0
- package/dist/signet-words.d.ts +42 -0
- package/dist/signet-words.d.ts.map +1 -0
- package/dist/signet-words.js +82 -0
- package/dist/signet-words.js.map +1 -0
- package/dist/store.d.ts +65 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +290 -0
- package/dist/store.js.map +1 -0
- package/dist/trust-score.d.ts +9 -0
- package/dist/trust-score.d.ts.map +1 -0
- package/dist/trust-score.js +186 -0
- package/dist/trust-score.js.map +1 -0
- package/dist/types.d.ts +358 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +11 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +21 -0
- package/dist/utils.js.map +1 -0
- package/dist/validation.d.ts +33 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +312 -0
- package/dist/validation.js.map +1 -0
- package/dist/verifiers.d.ts +18 -0
- package/dist/verifiers.d.ts.map +1 -0
- package/dist/verifiers.js +118 -0
- package/dist/verifiers.js.map +1 -0
- package/dist/vouches.d.ts +14 -0
- package/dist/vouches.d.ts.map +1 -0
- package/dist/vouches.js +103 -0
- package/dist/vouches.js.map +1 -0
- package/package.json +76 -0
- package/src/anomaly.ts +307 -0
- package/src/badge.ts +208 -0
- package/src/bonds.ts +203 -0
- package/src/challenges.ts +187 -0
- package/src/cold-call.ts +238 -0
- package/src/compliance.ts +612 -0
- package/src/connections.ts +216 -0
- package/src/constants.ts +146 -0
- package/src/credentials.ts +908 -0
- package/src/crypto.ts +85 -0
- package/src/errors.ts +31 -0
- package/src/i18n.ts +1347 -0
- package/src/identity-bridge.ts +262 -0
- package/src/identity-tree.ts +90 -0
- package/src/index.ts +452 -0
- package/src/lsag.ts +53 -0
- package/src/merkle.ts +176 -0
- package/src/policies.ts +154 -0
- package/src/range-proof.ts +66 -0
- package/src/relay.ts +433 -0
- package/src/ring-signature.ts +76 -0
- package/src/signet-words.ts +122 -0
- package/src/store.ts +336 -0
- package/src/trust-score.ts +208 -0
- package/src/types.ts +482 -0
- package/src/utils.ts +20 -0
- package/src/validation.ts +391 -0
- package/src/verifiers.ts +156 -0
- 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
|
+
}
|