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
package/src/policies.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Community Verification Policy (kind 30078, NIP-78)
|
|
2
|
+
// Create policies and check compliance
|
|
3
|
+
|
|
4
|
+
import { APP_DATA_KIND, DEFAULT_CRYPTO_ALGORITHM } from './constants.js';
|
|
5
|
+
import { signEvent, getPublicKey } from './crypto.js';
|
|
6
|
+
import { getTagValue } from './validation.js';
|
|
7
|
+
import { SignetValidationError } from './errors.js';
|
|
8
|
+
import type {
|
|
9
|
+
NostrEvent,
|
|
10
|
+
UnsignedEvent,
|
|
11
|
+
PolicyParams,
|
|
12
|
+
PolicyCheckResult,
|
|
13
|
+
ParsedPolicy,
|
|
14
|
+
SignetTier,
|
|
15
|
+
EnforcementLevel,
|
|
16
|
+
CryptoAlgorithm,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
/** Build an unsigned policy event */
|
|
20
|
+
export function buildPolicyEvent(
|
|
21
|
+
operatorPubkey: string,
|
|
22
|
+
params: PolicyParams
|
|
23
|
+
): UnsignedEvent {
|
|
24
|
+
const tags: string[][] = [
|
|
25
|
+
['d', `signet:policy:${params.communityId}`],
|
|
26
|
+
['adult-min-tier', String(params.adultMinTier)],
|
|
27
|
+
['child-min-tier', String(params.childMinTier)],
|
|
28
|
+
['enforcement', params.enforcement],
|
|
29
|
+
['algo', DEFAULT_CRYPTO_ALGORITHM],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
if (params.minScore !== undefined) tags.push(['min-score', String(params.minScore)]);
|
|
33
|
+
if (params.modMinTier !== undefined) tags.push(['mod-min-tier', String(params.modMinTier)]);
|
|
34
|
+
if (params.verifierBond !== undefined) tags.push(['verifier-bond', String(params.verifierBond)]);
|
|
35
|
+
if (params.revocationThreshold !== undefined) tags.push(['revocation-threshold', String(params.revocationThreshold)]);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
kind: APP_DATA_KIND,
|
|
39
|
+
pubkey: operatorPubkey,
|
|
40
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
41
|
+
tags,
|
|
42
|
+
content: params.description || '',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Create and sign a community policy */
|
|
47
|
+
export async function createPolicy(
|
|
48
|
+
operatorPrivateKey: string,
|
|
49
|
+
params: PolicyParams
|
|
50
|
+
): Promise<NostrEvent> {
|
|
51
|
+
const pubkey = getPublicKey(operatorPrivateKey);
|
|
52
|
+
const event = buildPolicyEvent(pubkey, params);
|
|
53
|
+
return signEvent(event, operatorPrivateKey);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse a policy event into a structured object */
|
|
57
|
+
export function parsePolicy(event: NostrEvent): ParsedPolicy | null {
|
|
58
|
+
if (event.kind !== APP_DATA_KIND) return null;
|
|
59
|
+
// NIP-78 policy events are identified by the signet:policy: d-tag prefix
|
|
60
|
+
const dTag = getTagValue(event, 'd') || '';
|
|
61
|
+
if (!dTag.startsWith('signet:policy:')) return null;
|
|
62
|
+
|
|
63
|
+
const adultTier = getTagValue(event, 'adult-min-tier');
|
|
64
|
+
const childTier = getTagValue(event, 'child-min-tier');
|
|
65
|
+
|
|
66
|
+
const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
|
|
67
|
+
|
|
68
|
+
// Strip 'signet:policy:' prefix from d-tag to get community ID
|
|
69
|
+
const communityId = dTag.slice('signet:policy:'.length);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
communityId,
|
|
73
|
+
adultMinTier: (() => { const t = adultTier ? parseInt(adultTier, 10) : NaN; return (!isNaN(t) && t >= 1 && t <= 4 ? t : 1) as SignetTier; })(),
|
|
74
|
+
childMinTier: (() => { const t = childTier ? parseInt(childTier, 10) : NaN; return (!isNaN(t) && t >= 1 && t <= 4 ? t : 1) as SignetTier; })(),
|
|
75
|
+
enforcement: (getTagValue(event, 'enforcement') || 'client') as EnforcementLevel,
|
|
76
|
+
minScore: (() => { const s = getTagValue(event, 'min-score'); if (!s) return undefined; const v = parseInt(s, 10); return isNaN(v) ? undefined : Math.max(0, Math.min(v, 200)); })(),
|
|
77
|
+
modMinTier: (() => { const s = getTagValue(event, 'mod-min-tier'); if (!s) return undefined; const t = parseInt(s, 10); if (isNaN(t) || t < 1 || t > 4) return undefined; return t as SignetTier; })(),
|
|
78
|
+
verifierBond: (() => { const s = getTagValue(event, 'verifier-bond'); if (!s) return undefined; const v = parseInt(s, 10); return isNaN(v) || v < 0 ? undefined : v; })(),
|
|
79
|
+
revocationThreshold: (() => { const s = getTagValue(event, 'revocation-threshold'); if (!s) return undefined; const v = parseInt(s, 10); return isNaN(v) || v < 1 ? undefined : v; })(),
|
|
80
|
+
algorithm,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Check if a user meets a policy's requirements */
|
|
85
|
+
export function checkPolicyCompliance(
|
|
86
|
+
policy: ParsedPolicy,
|
|
87
|
+
userTier: SignetTier,
|
|
88
|
+
userScore: number,
|
|
89
|
+
opts: {
|
|
90
|
+
isChild?: boolean;
|
|
91
|
+
isModerator?: boolean;
|
|
92
|
+
} = {}
|
|
93
|
+
): PolicyCheckResult {
|
|
94
|
+
const requiredTier = opts.isChild
|
|
95
|
+
? policy.childMinTier
|
|
96
|
+
: opts.isModerator && policy.modMinTier
|
|
97
|
+
? policy.modMinTier
|
|
98
|
+
: policy.adultMinTier;
|
|
99
|
+
|
|
100
|
+
if (userTier < requiredTier) {
|
|
101
|
+
return {
|
|
102
|
+
allowed: false,
|
|
103
|
+
reason: `Tier ${userTier} does not meet minimum tier ${requiredTier}`,
|
|
104
|
+
requiredTier,
|
|
105
|
+
actualTier: userTier,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (policy.minScore !== undefined && userScore < policy.minScore) {
|
|
110
|
+
return {
|
|
111
|
+
allowed: false,
|
|
112
|
+
reason: `Score ${userScore} does not meet minimum score ${policy.minScore}`,
|
|
113
|
+
requiredTier,
|
|
114
|
+
actualTier: userTier,
|
|
115
|
+
requiredScore: policy.minScore,
|
|
116
|
+
actualScore: userScore,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
allowed: true,
|
|
122
|
+
requiredTier,
|
|
123
|
+
actualTier: userTier,
|
|
124
|
+
requiredScore: policy.minScore,
|
|
125
|
+
actualScore: userScore,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Policy checker that holds a policy and checks multiple users */
|
|
130
|
+
export class PolicyChecker {
|
|
131
|
+
private policy: ParsedPolicy;
|
|
132
|
+
|
|
133
|
+
constructor(policyEvent: NostrEvent) {
|
|
134
|
+
const parsed = parsePolicy(policyEvent);
|
|
135
|
+
if (!parsed) throw new SignetValidationError('Invalid policy event');
|
|
136
|
+
this.policy = parsed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getPolicy(): ParsedPolicy {
|
|
140
|
+
return this.policy;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
checkAdult(tier: SignetTier, score: number): PolicyCheckResult {
|
|
144
|
+
return checkPolicyCompliance(this.policy, tier, score, { isChild: false });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
checkChild(tier: SignetTier, score: number): PolicyCheckResult {
|
|
148
|
+
return checkPolicyCompliance(this.policy, tier, score, { isChild: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
checkModerator(tier: SignetTier, score: number): PolicyCheckResult {
|
|
152
|
+
return checkPolicyCompliance(this.policy, tier, score, { isModerator: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Pedersen Commitments + Range Proofs — compatibility wrapper over @forgesworn/range-proof
|
|
2
|
+
// Proves "value is in [min, max]" without revealing the exact value.
|
|
3
|
+
// Used for Tier 4 age range proofs: "child aged 8-12" without revealing exact age.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
commit,
|
|
7
|
+
verifyCommitment,
|
|
8
|
+
createRangeProof,
|
|
9
|
+
createAgeRangeProof,
|
|
10
|
+
serializeRangeProof,
|
|
11
|
+
deserializeRangeProof,
|
|
12
|
+
type PedersenCommitment,
|
|
13
|
+
type RangeProof,
|
|
14
|
+
} from '@forgesworn/range-proof';
|
|
15
|
+
import {
|
|
16
|
+
verifyRangeProof as verifyRangeProofUpstream,
|
|
17
|
+
verifyAgeRangeProof as verifyAgeRangeProofUpstream,
|
|
18
|
+
} from '@forgesworn/range-proof';
|
|
19
|
+
|
|
20
|
+
function normalizeBindingContext(bindingContext?: string): string | undefined {
|
|
21
|
+
return bindingContext === '' ? undefined : bindingContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseAgeRange(ageRange: string): { min: number; max: number } | null {
|
|
25
|
+
const digitsOnly = /^\d+$/;
|
|
26
|
+
|
|
27
|
+
if (ageRange.endsWith('+')) {
|
|
28
|
+
const minStr = ageRange.slice(0, -1);
|
|
29
|
+
if (!digitsOnly.test(minStr)) return null;
|
|
30
|
+
return { min: parseInt(minStr, 10), max: 150 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parts = ageRange.split('-');
|
|
34
|
+
if (parts.length !== 2) return null;
|
|
35
|
+
if (!digitsOnly.test(parts[0]) || !digitsOnly.test(parts[1])) return null;
|
|
36
|
+
return {
|
|
37
|
+
min: parseInt(parts[0], 10),
|
|
38
|
+
max: parseInt(parts[1], 10),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { commit, verifyCommitment, createRangeProof, createAgeRangeProof, serializeRangeProof, deserializeRangeProof };
|
|
43
|
+
export type { PedersenCommitment, RangeProof };
|
|
44
|
+
|
|
45
|
+
export function verifyRangeProof(
|
|
46
|
+
proof: RangeProof,
|
|
47
|
+
expectedMin: number,
|
|
48
|
+
expectedMax: number,
|
|
49
|
+
expectedBindingContext?: string
|
|
50
|
+
): boolean {
|
|
51
|
+
if (!Number.isSafeInteger(expectedMin) || !Number.isSafeInteger(expectedMax)) return false;
|
|
52
|
+
if (expectedMin < 0 || expectedMax < 0 || expectedMax < expectedMin) return false;
|
|
53
|
+
if (proof.min !== expectedMin || proof.max !== expectedMax) return false;
|
|
54
|
+
if (normalizeBindingContext(proof.context) !== normalizeBindingContext(expectedBindingContext)) return false;
|
|
55
|
+
return verifyRangeProofUpstream(proof, expectedMin, expectedMax, expectedBindingContext);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function verifyAgeRangeProof(
|
|
59
|
+
proof: RangeProof,
|
|
60
|
+
expectedAgeRange: string,
|
|
61
|
+
expectedSubjectPubkey?: string
|
|
62
|
+
): boolean {
|
|
63
|
+
const parsed = parseAgeRange(expectedAgeRange);
|
|
64
|
+
if (!parsed) return false;
|
|
65
|
+
return verifyRangeProof(proof, parsed.min, parsed.max, expectedSubjectPubkey);
|
|
66
|
+
}
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Nostr Relay Client
|
|
2
|
+
// WebSocket-based publish/subscribe with NIP-42 AUTH support
|
|
3
|
+
|
|
4
|
+
import { signEvent, getPublicKey, verifyEvent } from './crypto.js';
|
|
5
|
+
import type { NostrEvent, UnsignedEvent } from './types.js';
|
|
6
|
+
import { SignetValidationError } from './errors.js';
|
|
7
|
+
import { validateFieldSizeBounds } from './validation.js';
|
|
8
|
+
|
|
9
|
+
/** NIP-42 client authentication event kind */
|
|
10
|
+
const NIP42_AUTH_KIND = 22242;
|
|
11
|
+
|
|
12
|
+
/** Maximum WebSocket message size (1 MB) — prevents DoS via oversized relay messages */
|
|
13
|
+
const MAX_MESSAGE_SIZE = 1_048_576;
|
|
14
|
+
|
|
15
|
+
/** Nostr relay message types (relay → client) */
|
|
16
|
+
export type RelayMessage =
|
|
17
|
+
| ['EVENT', string, NostrEvent]
|
|
18
|
+
| ['OK', string, boolean, string]
|
|
19
|
+
| ['EOSE', string]
|
|
20
|
+
| ['NOTICE', string]
|
|
21
|
+
| ['AUTH', string];
|
|
22
|
+
|
|
23
|
+
/** Nostr subscription filter */
|
|
24
|
+
export interface NostrFilter {
|
|
25
|
+
ids?: string[];
|
|
26
|
+
authors?: string[];
|
|
27
|
+
kinds?: number[];
|
|
28
|
+
'#d'?: string[];
|
|
29
|
+
'#p'?: string[];
|
|
30
|
+
'#L'?: string[];
|
|
31
|
+
'#l'?: string[];
|
|
32
|
+
since?: number;
|
|
33
|
+
until?: number;
|
|
34
|
+
limit?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Subscription callback */
|
|
38
|
+
export type SubscriptionCallback = (event: NostrEvent) => void;
|
|
39
|
+
|
|
40
|
+
/** Relay connection state */
|
|
41
|
+
export type RelayState = 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
42
|
+
|
|
43
|
+
/** Options for the relay client */
|
|
44
|
+
export interface RelayOptions {
|
|
45
|
+
/** Private key for NIP-42 AUTH (hex) */
|
|
46
|
+
authPrivateKey?: string;
|
|
47
|
+
/** Connection timeout in ms (default: 5000) */
|
|
48
|
+
connectTimeout?: number;
|
|
49
|
+
/** Auto-reconnect on disconnect (default: true) */
|
|
50
|
+
autoReconnect?: boolean;
|
|
51
|
+
/** Reconnect delay in ms (default: 3000) */
|
|
52
|
+
reconnectDelay?: number;
|
|
53
|
+
/** Max reconnect attempts (default: 5) */
|
|
54
|
+
maxReconnectAttempts?: number;
|
|
55
|
+
/** Verify event signatures before delivering to callbacks (default: true).
|
|
56
|
+
* Events that fail verification are silently dropped. */
|
|
57
|
+
verifyEvents?: boolean;
|
|
58
|
+
/** Callback for rejected events (signature or ID verification failed) */
|
|
59
|
+
onEventRejected?: (event: NostrEvent, reason: string) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PendingSubscription {
|
|
63
|
+
filters: NostrFilter[];
|
|
64
|
+
callback: SubscriptionCallback;
|
|
65
|
+
eoseCallback?: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PendingPublish {
|
|
69
|
+
resolve: (result: { ok: boolean; message: string }) => void;
|
|
70
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Nostr relay client with NIP-42 AUTH support.
|
|
75
|
+
* Handles publishing events, subscribing to filters, and authentication.
|
|
76
|
+
*/
|
|
77
|
+
export class RelayClient {
|
|
78
|
+
private ws: WebSocket | null = null;
|
|
79
|
+
private state: RelayState = 'disconnected';
|
|
80
|
+
private subscriptions = new Map<string, PendingSubscription>();
|
|
81
|
+
private pendingPublishes = new Map<string, PendingPublish>();
|
|
82
|
+
private subCounter = 0;
|
|
83
|
+
private reconnectAttempts = 0;
|
|
84
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
private disconnectRequested = false;
|
|
86
|
+
private onStateChange?: (state: RelayState) => void;
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
private url: string,
|
|
90
|
+
private options: RelayOptions = {}
|
|
91
|
+
) {
|
|
92
|
+
if (!/^wss?:\/\//i.test(this.url)) {
|
|
93
|
+
throw new SignetValidationError('Relay URL must use ws:// or wss:// scheme');
|
|
94
|
+
}
|
|
95
|
+
// Enforce TLS for non-localhost connections — identity data must not travel in cleartext
|
|
96
|
+
if (/^ws:\/\//i.test(this.url) && !/^ws:\/\/(localhost|127\.0\.0\.1)([:\/]|$)/i.test(this.url)) {
|
|
97
|
+
throw new SignetValidationError('Relay URL must use wss:// for non-localhost connections');
|
|
98
|
+
}
|
|
99
|
+
this.options = {
|
|
100
|
+
connectTimeout: 5000,
|
|
101
|
+
autoReconnect: true,
|
|
102
|
+
reconnectDelay: 3000,
|
|
103
|
+
maxReconnectAttempts: 5,
|
|
104
|
+
verifyEvents: true,
|
|
105
|
+
...options,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Get current connection state */
|
|
110
|
+
getState(): RelayState {
|
|
111
|
+
return this.state;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Set a state change listener */
|
|
115
|
+
onStateChanged(callback: (state: RelayState) => void): void {
|
|
116
|
+
this.onStateChange = callback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private setState(state: RelayState): void {
|
|
120
|
+
this.state = state;
|
|
121
|
+
this.onStateChange?.(state);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Connect to the relay */
|
|
125
|
+
connect(): Promise<void> {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
if (this.state === 'connected') {
|
|
128
|
+
resolve();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.setState('connecting');
|
|
133
|
+
|
|
134
|
+
const timeout = setTimeout(() => {
|
|
135
|
+
this.ws?.close();
|
|
136
|
+
reject(new SignetValidationError(`Connection timeout after ${this.options.connectTimeout}ms`));
|
|
137
|
+
}, this.options.connectTimeout);
|
|
138
|
+
|
|
139
|
+
this.ws = new WebSocket(this.url);
|
|
140
|
+
|
|
141
|
+
this.ws.onopen = () => {
|
|
142
|
+
clearTimeout(timeout);
|
|
143
|
+
this.setState('connected');
|
|
144
|
+
this.reconnectAttempts = 0;
|
|
145
|
+
// Re-subscribe existing subscriptions
|
|
146
|
+
for (const [id, sub] of this.subscriptions) {
|
|
147
|
+
this.sendSubscription(id, sub.filters);
|
|
148
|
+
}
|
|
149
|
+
resolve();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
this.ws.onclose = () => {
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
this.setState('disconnected');
|
|
155
|
+
this.handleReconnect();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this.ws.onerror = () => {
|
|
159
|
+
clearTimeout(timeout);
|
|
160
|
+
this.setState('error');
|
|
161
|
+
reject(new SignetValidationError('WebSocket connection failed'));
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
this.ws.onmessage = (msg) => {
|
|
165
|
+
if (typeof msg.data !== 'string') return; // ignore binary frames
|
|
166
|
+
this.handleMessage(msg.data);
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Disconnect from the relay */
|
|
172
|
+
disconnect(): void {
|
|
173
|
+
if (this.reconnectTimer) {
|
|
174
|
+
clearTimeout(this.reconnectTimer);
|
|
175
|
+
this.reconnectTimer = null;
|
|
176
|
+
}
|
|
177
|
+
this.disconnectRequested = true;
|
|
178
|
+
this.ws?.close();
|
|
179
|
+
this.ws = null;
|
|
180
|
+
this.setState('disconnected');
|
|
181
|
+
|
|
182
|
+
// Clean up pending publishes
|
|
183
|
+
for (const [, pending] of this.pendingPublishes) {
|
|
184
|
+
clearTimeout(pending.timeout);
|
|
185
|
+
pending.resolve({ ok: false, message: 'Disconnected' });
|
|
186
|
+
}
|
|
187
|
+
this.pendingPublishes.clear();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Publish an event to the relay */
|
|
191
|
+
async publish(event: NostrEvent): Promise<{ ok: boolean; message: string }> {
|
|
192
|
+
if (this.state !== 'connected' || !this.ws) {
|
|
193
|
+
throw new SignetValidationError('Not connected to relay');
|
|
194
|
+
}
|
|
195
|
+
if (!/^[0-9a-f]{64}$/.test(event.id)) {
|
|
196
|
+
throw new SignetValidationError('Invalid event ID: must be a 64-character lowercase hex string');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const timeout = setTimeout(() => {
|
|
201
|
+
this.pendingPublishes.delete(event.id);
|
|
202
|
+
resolve({ ok: false, message: 'Publish timeout' });
|
|
203
|
+
}, 10000);
|
|
204
|
+
|
|
205
|
+
this.pendingPublishes.set(event.id, { resolve, timeout });
|
|
206
|
+
this.ws!.send(JSON.stringify(['EVENT', event]));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Subscribe to events matching the given filters.
|
|
212
|
+
* @returns Subscription ID (use to close the subscription)
|
|
213
|
+
*/
|
|
214
|
+
subscribe(
|
|
215
|
+
filters: NostrFilter[],
|
|
216
|
+
onEvent: SubscriptionCallback,
|
|
217
|
+
onEose?: () => void
|
|
218
|
+
): string {
|
|
219
|
+
const subId = `signet-sub-${++this.subCounter}`;
|
|
220
|
+
|
|
221
|
+
this.subscriptions.set(subId, {
|
|
222
|
+
filters,
|
|
223
|
+
callback: onEvent,
|
|
224
|
+
eoseCallback: onEose,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (this.state === 'connected') {
|
|
228
|
+
this.sendSubscription(subId, filters);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return subId;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Close a subscription */
|
|
235
|
+
closeSubscription(subId: string): void {
|
|
236
|
+
this.subscriptions.delete(subId);
|
|
237
|
+
if (this.state === 'connected' && this.ws) {
|
|
238
|
+
this.ws.send(JSON.stringify(['CLOSE', subId]));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Fetch events matching filters (returns after EOSE).
|
|
244
|
+
* Convenience method that subscribes, collects events, and closes.
|
|
245
|
+
*/
|
|
246
|
+
fetch(filters: NostrFilter[], timeoutMs: number = 30000): Promise<NostrEvent[]> {
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const events: NostrEvent[] = [];
|
|
249
|
+
let resolved = false;
|
|
250
|
+
|
|
251
|
+
const done = () => {
|
|
252
|
+
if (resolved) return;
|
|
253
|
+
resolved = true;
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
this.closeSubscription(subId);
|
|
256
|
+
resolve(events);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const maxEvents = 10_000;
|
|
260
|
+
const subId = this.subscribe(
|
|
261
|
+
filters,
|
|
262
|
+
(event) => { events.push(event); if (events.length >= maxEvents) done(); },
|
|
263
|
+
() => done(),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Timeout guard: resolve with events collected so far if EOSE never arrives
|
|
267
|
+
const timer = setTimeout(() => done(), timeoutMs);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private sendSubscription(subId: string, filters: NostrFilter[]): void {
|
|
272
|
+
if (this.ws) {
|
|
273
|
+
this.ws.send(JSON.stringify(['REQ', subId, ...filters]));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private handleMessage(data: string): void {
|
|
278
|
+
if (data.length > MAX_MESSAGE_SIZE) return;
|
|
279
|
+
try {
|
|
280
|
+
const msg: unknown = JSON.parse(data);
|
|
281
|
+
if (!Array.isArray(msg) || msg.length < 2 || typeof msg[0] !== 'string') return;
|
|
282
|
+
|
|
283
|
+
switch (msg[0]) {
|
|
284
|
+
case 'EVENT': {
|
|
285
|
+
if (msg.length < 3 || typeof msg[1] !== 'string' || typeof msg[2] !== 'object' || msg[2] === null) break;
|
|
286
|
+
const raw = msg[2] as Record<string, unknown>;
|
|
287
|
+
// Validate required NostrEvent fields before casting
|
|
288
|
+
if (typeof raw.id !== 'string' || typeof raw.pubkey !== 'string' ||
|
|
289
|
+
typeof raw.kind !== 'number' || typeof raw.created_at !== 'number' ||
|
|
290
|
+
!Array.isArray(raw.tags) || typeof raw.content !== 'string' ||
|
|
291
|
+
typeof raw.sig !== 'string') break;
|
|
292
|
+
const subId = msg[1] as string;
|
|
293
|
+
const event = raw as unknown as NostrEvent;
|
|
294
|
+
const boundsErrors: string[] = [];
|
|
295
|
+
validateFieldSizeBounds(event, boundsErrors);
|
|
296
|
+
if (boundsErrors.length > 0) {
|
|
297
|
+
break; // reject oversized events
|
|
298
|
+
}
|
|
299
|
+
const sub = this.subscriptions.get(subId);
|
|
300
|
+
if (sub) {
|
|
301
|
+
if (this.options.verifyEvents !== false) {
|
|
302
|
+
verifyEvent(event).then((valid) => {
|
|
303
|
+
if (valid) {
|
|
304
|
+
sub.callback(event);
|
|
305
|
+
} else {
|
|
306
|
+
this.options.onEventRejected?.(event, 'invalid signature or event ID');
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
} else {
|
|
310
|
+
sub.callback(event);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case 'OK': {
|
|
317
|
+
if (msg.length < 4 || typeof msg[1] !== 'string' || typeof msg[2] !== 'boolean' || typeof msg[3] !== 'string') break;
|
|
318
|
+
const eventId = msg[1] as string;
|
|
319
|
+
const pending = this.pendingPublishes.get(eventId);
|
|
320
|
+
if (pending) {
|
|
321
|
+
clearTimeout(pending.timeout);
|
|
322
|
+
this.pendingPublishes.delete(eventId);
|
|
323
|
+
pending.resolve({ ok: msg[2] as boolean, message: msg[3] as string });
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case 'EOSE': {
|
|
329
|
+
if (typeof msg[1] !== 'string') break;
|
|
330
|
+
const sub = this.subscriptions.get(msg[1]);
|
|
331
|
+
sub?.eoseCallback?.();
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case 'AUTH': {
|
|
336
|
+
if (typeof msg[1] !== 'string') break;
|
|
337
|
+
// Cap challenge length to prevent oversized AUTH events from malicious relays
|
|
338
|
+
if (msg[1].length > 256) break;
|
|
339
|
+
this.handleAuth(msg[1]);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case 'NOTICE': {
|
|
344
|
+
// Relay notices are informational — log but don't act
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
// Malformed message — ignore
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Handle NIP-42 AUTH challenge */
|
|
354
|
+
private async handleAuth(challenge: string): Promise<void> {
|
|
355
|
+
if (!this.options.authPrivateKey) return;
|
|
356
|
+
|
|
357
|
+
const pubkey = getPublicKey(this.options.authPrivateKey);
|
|
358
|
+
const authEvent: UnsignedEvent = {
|
|
359
|
+
kind: NIP42_AUTH_KIND,
|
|
360
|
+
pubkey,
|
|
361
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
362
|
+
tags: [
|
|
363
|
+
['relay', this.url],
|
|
364
|
+
['challenge', challenge],
|
|
365
|
+
],
|
|
366
|
+
content: '',
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const signed = await signEvent(authEvent, this.options.authPrivateKey);
|
|
370
|
+
this.ws?.send(JSON.stringify(['AUTH', signed]));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private handleReconnect(): void {
|
|
374
|
+
if (this.disconnectRequested || !this.options.autoReconnect) return;
|
|
375
|
+
if (this.reconnectAttempts >= (this.options.maxReconnectAttempts ?? 5)) return;
|
|
376
|
+
|
|
377
|
+
this.reconnectAttempts++;
|
|
378
|
+
this.reconnectTimer = setTimeout(() => {
|
|
379
|
+
this.connect().catch(() => {
|
|
380
|
+
// Will retry via onclose handler
|
|
381
|
+
});
|
|
382
|
+
}, this.options.reconnectDelay);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Publish a Signet event to multiple relays.
|
|
388
|
+
*
|
|
389
|
+
* WARNING: Relay URLs are accepted as-is. Callers are responsible for
|
|
390
|
+
* validating that URLs come from trusted sources and do not encode credentials.
|
|
391
|
+
* The RelayClient constructor enforces wss:// for non-localhost connections.
|
|
392
|
+
*/
|
|
393
|
+
export async function publishToRelays(
|
|
394
|
+
event: NostrEvent,
|
|
395
|
+
relayUrls: string[]
|
|
396
|
+
): Promise<Map<string, { ok: boolean; message: string }>> {
|
|
397
|
+
const results = new Map<string, { ok: boolean; message: string }>();
|
|
398
|
+
|
|
399
|
+
const promises = relayUrls.map(async (url) => {
|
|
400
|
+
const relay = new RelayClient(url);
|
|
401
|
+
try {
|
|
402
|
+
await relay.connect();
|
|
403
|
+
const result = await relay.publish(event);
|
|
404
|
+
results.set(url, result);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
results.set(url, { ok: false, message: err instanceof Error ? err.message : 'Connection failed' });
|
|
407
|
+
} finally {
|
|
408
|
+
relay.disconnect();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
await Promise.allSettled(promises);
|
|
413
|
+
return results;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Fetch Signet events from a relay by kind and optional filters.
|
|
418
|
+
*
|
|
419
|
+
* WARNING: Relay URL is accepted as-is. Callers are responsible for
|
|
420
|
+
* validating that URLs come from trusted sources and do not encode credentials.
|
|
421
|
+
*/
|
|
422
|
+
export async function fetchFromRelay(
|
|
423
|
+
relayUrl: string,
|
|
424
|
+
filters: NostrFilter[]
|
|
425
|
+
): Promise<NostrEvent[]> {
|
|
426
|
+
const relay = new RelayClient(relayUrl);
|
|
427
|
+
try {
|
|
428
|
+
await relay.connect();
|
|
429
|
+
return await relay.fetch(filters);
|
|
430
|
+
} finally {
|
|
431
|
+
relay.disconnect();
|
|
432
|
+
}
|
|
433
|
+
}
|