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/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signet-protocol",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Decentralised identity verification protocol for Nostr",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"clean": "rm -rf dist",
|
|
24
|
+
"prepare": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"nostr",
|
|
28
|
+
"identity",
|
|
29
|
+
"verification",
|
|
30
|
+
"zkp",
|
|
31
|
+
"signet",
|
|
32
|
+
"web-of-trust",
|
|
33
|
+
"age-verification",
|
|
34
|
+
"zero-knowledge-proofs",
|
|
35
|
+
"child-safety",
|
|
36
|
+
"privacy",
|
|
37
|
+
"decentralised-identity",
|
|
38
|
+
"ring-signatures",
|
|
39
|
+
"pedersen-range-proofs",
|
|
40
|
+
"secp256k1"
|
|
41
|
+
],
|
|
42
|
+
"sideEffects": false,
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
},
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/forgesworn/signet-protocol.git"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@forgesworn/range-proof": "^2.0.0",
|
|
53
|
+
"@forgesworn/ring-sig": "^3.0.0",
|
|
54
|
+
"@forgesworn/shamir-words": "^1.0.0",
|
|
55
|
+
"@noble/curves": "^2.0.1",
|
|
56
|
+
"@noble/hashes": "^2.0.1",
|
|
57
|
+
"@scure/bip39": "^2.0.1",
|
|
58
|
+
"jurisdiction-kit": "^1.0.0",
|
|
59
|
+
"nostr-attestations": "^2.3.1",
|
|
60
|
+
"nsec-tree": "^1.4.1",
|
|
61
|
+
"spoken-token": "^2.0.3"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
65
|
+
"@semantic-release/git": "^10.0.1",
|
|
66
|
+
"@types/node": "^25.3.3",
|
|
67
|
+
"semantic-release": "^25.0.3",
|
|
68
|
+
"tsx": "^4.21.0",
|
|
69
|
+
"typescript": "^5.7.0",
|
|
70
|
+
"vitest": "^3.0.0"
|
|
71
|
+
},
|
|
72
|
+
"funding": {
|
|
73
|
+
"type": "lightning",
|
|
74
|
+
"url": "lightning:thedonkey@strike.me"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/anomaly.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Verifier Anomaly Detection
|
|
2
|
+
// Statistical analysis of verifier issuance patterns
|
|
3
|
+
// Layer 3 of the anti-corruption framework
|
|
4
|
+
|
|
5
|
+
import { ATTESTATION_KIND, ATTESTATION_TYPES } from './constants.js';
|
|
6
|
+
import { getTagValue } from './validation.js';
|
|
7
|
+
import type { NostrEvent } from './types.js';
|
|
8
|
+
|
|
9
|
+
/** Types of anomaly flags */
|
|
10
|
+
export type AnomalyType = 'volume' | 'temporal' | 'geographic' | 'pattern';
|
|
11
|
+
|
|
12
|
+
/** A detected anomaly */
|
|
13
|
+
export interface AnomalyFlag {
|
|
14
|
+
type: AnomalyType;
|
|
15
|
+
verifierPubkey: string;
|
|
16
|
+
severity: 'low' | 'medium' | 'high';
|
|
17
|
+
description: string;
|
|
18
|
+
evidence: {
|
|
19
|
+
metric: string;
|
|
20
|
+
value: number;
|
|
21
|
+
threshold: number;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Anomaly detection configuration */
|
|
26
|
+
export interface AnomalyConfig {
|
|
27
|
+
/** Max credentials per week before flagging (default: 20) */
|
|
28
|
+
maxWeeklyVolume: number;
|
|
29
|
+
/** Max credentials per hour before flagging (default: 5) */
|
|
30
|
+
maxHourlyVolume: number;
|
|
31
|
+
/** Max percentage of credentials in a single foreign jurisdiction (default: 30) */
|
|
32
|
+
maxForeignJurisdictionPercent: number;
|
|
33
|
+
/** Min time between consecutive credentials in seconds (default: 300 = 5 min) */
|
|
34
|
+
minTimeBetweenCredentials: number;
|
|
35
|
+
/** Volume multiplier vs network average to flag (default: 5) */
|
|
36
|
+
volumeMultiplierThreshold: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CONFIG: AnomalyConfig = {
|
|
40
|
+
maxWeeklyVolume: 20,
|
|
41
|
+
maxHourlyVolume: 5,
|
|
42
|
+
maxForeignJurisdictionPercent: 30,
|
|
43
|
+
minTimeBetweenCredentials: 300,
|
|
44
|
+
volumeMultiplierThreshold: 5,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Analyze a verifier's issuance patterns for anomalies.
|
|
49
|
+
*
|
|
50
|
+
* @param verifierPubkey - The verifier to analyze
|
|
51
|
+
* @param allCredentials - All credential events (kind 31000, type: credential) in the store
|
|
52
|
+
* @param verifierCredential - The verifier's own credential (kind 31000, type: verifier)
|
|
53
|
+
* @param config - Detection thresholds
|
|
54
|
+
*/
|
|
55
|
+
export function detectAnomalies(
|
|
56
|
+
verifierPubkey: string,
|
|
57
|
+
allCredentials: NostrEvent[],
|
|
58
|
+
verifierCredential?: NostrEvent,
|
|
59
|
+
config: Partial<AnomalyConfig> = {}
|
|
60
|
+
): AnomalyFlag[] {
|
|
61
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
62
|
+
const flags: AnomalyFlag[] = [];
|
|
63
|
+
|
|
64
|
+
// Get this verifier's issued credentials
|
|
65
|
+
const issued = allCredentials.filter(
|
|
66
|
+
(e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL && e.pubkey === verifierPubkey
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (issued.length === 0) return flags;
|
|
70
|
+
|
|
71
|
+
// Sort by time
|
|
72
|
+
const sorted = [...issued].sort((a, b) => a.created_at - b.created_at);
|
|
73
|
+
|
|
74
|
+
// --- Volume Analysis ---
|
|
75
|
+
flags.push(...analyzeVolume(verifierPubkey, sorted, allCredentials, cfg));
|
|
76
|
+
|
|
77
|
+
// --- Temporal Analysis ---
|
|
78
|
+
flags.push(...analyzeTemporal(verifierPubkey, sorted, cfg));
|
|
79
|
+
|
|
80
|
+
// --- Geographic Analysis ---
|
|
81
|
+
if (verifierCredential) {
|
|
82
|
+
flags.push(...analyzeGeographic(verifierPubkey, sorted, verifierCredential, cfg));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- Pattern Analysis ---
|
|
86
|
+
flags.push(...analyzePatterns(verifierPubkey, sorted));
|
|
87
|
+
|
|
88
|
+
return flags;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Volume anomaly detection */
|
|
92
|
+
function analyzeVolume(
|
|
93
|
+
verifierPubkey: string,
|
|
94
|
+
issued: NostrEvent[],
|
|
95
|
+
allCredentials: NostrEvent[],
|
|
96
|
+
cfg: AnomalyConfig
|
|
97
|
+
): AnomalyFlag[] {
|
|
98
|
+
const flags: AnomalyFlag[] = [];
|
|
99
|
+
const now = Math.floor(Date.now() / 1000);
|
|
100
|
+
const oneWeek = 7 * 24 * 60 * 60;
|
|
101
|
+
const oneHour = 60 * 60;
|
|
102
|
+
|
|
103
|
+
// Weekly volume
|
|
104
|
+
const weeklyCount = issued.filter((e) => e.created_at > now - oneWeek).length;
|
|
105
|
+
if (weeklyCount > cfg.maxWeeklyVolume) {
|
|
106
|
+
flags.push({
|
|
107
|
+
type: 'volume',
|
|
108
|
+
verifierPubkey,
|
|
109
|
+
severity: weeklyCount > cfg.maxWeeklyVolume * 3 ? 'high' : 'medium',
|
|
110
|
+
description: `Issued ${weeklyCount} credentials this week (threshold: ${cfg.maxWeeklyVolume})`,
|
|
111
|
+
evidence: { metric: 'weekly_volume', value: weeklyCount, threshold: cfg.maxWeeklyVolume },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Hourly volume (burst detection)
|
|
116
|
+
const hourlyCount = issued.filter((e) => e.created_at > now - oneHour).length;
|
|
117
|
+
if (hourlyCount > cfg.maxHourlyVolume) {
|
|
118
|
+
flags.push({
|
|
119
|
+
type: 'volume',
|
|
120
|
+
verifierPubkey,
|
|
121
|
+
severity: 'high',
|
|
122
|
+
description: `Issued ${hourlyCount} credentials in the last hour (threshold: ${cfg.maxHourlyVolume})`,
|
|
123
|
+
evidence: { metric: 'hourly_volume', value: hourlyCount, threshold: cfg.maxHourlyVolume },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Compare to network average
|
|
128
|
+
const allVerifiers = new Set(
|
|
129
|
+
allCredentials.filter((e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL).map((e) => e.pubkey)
|
|
130
|
+
);
|
|
131
|
+
if (allVerifiers.size > 1) {
|
|
132
|
+
const totalCredentials = allCredentials.filter(
|
|
133
|
+
(e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL && e.created_at > now - oneWeek
|
|
134
|
+
).length;
|
|
135
|
+
const networkAvg = totalCredentials / allVerifiers.size;
|
|
136
|
+
|
|
137
|
+
if (networkAvg > 0 && weeklyCount > networkAvg * cfg.volumeMultiplierThreshold) {
|
|
138
|
+
flags.push({
|
|
139
|
+
type: 'volume',
|
|
140
|
+
verifierPubkey,
|
|
141
|
+
severity: 'high',
|
|
142
|
+
description: `Weekly volume ${weeklyCount} is ${(weeklyCount / networkAvg).toFixed(1)}x the network average of ${networkAvg.toFixed(1)}`,
|
|
143
|
+
evidence: {
|
|
144
|
+
metric: 'volume_vs_average',
|
|
145
|
+
value: weeklyCount / networkAvg,
|
|
146
|
+
threshold: cfg.volumeMultiplierThreshold,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return flags;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Temporal anomaly detection */
|
|
156
|
+
function analyzeTemporal(
|
|
157
|
+
verifierPubkey: string,
|
|
158
|
+
sorted: NostrEvent[],
|
|
159
|
+
cfg: AnomalyConfig
|
|
160
|
+
): AnomalyFlag[] {
|
|
161
|
+
const flags: AnomalyFlag[] = [];
|
|
162
|
+
|
|
163
|
+
if (sorted.length < 2) return flags;
|
|
164
|
+
|
|
165
|
+
// Check minimum time between consecutive credentials
|
|
166
|
+
let rapidCount = 0;
|
|
167
|
+
let minGap = Infinity;
|
|
168
|
+
|
|
169
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
170
|
+
const gap = sorted[i].created_at - sorted[i - 1].created_at;
|
|
171
|
+
if (gap < cfg.minTimeBetweenCredentials) {
|
|
172
|
+
rapidCount++;
|
|
173
|
+
}
|
|
174
|
+
if (gap < minGap) minGap = gap;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (rapidCount > 0) {
|
|
178
|
+
flags.push({
|
|
179
|
+
type: 'temporal',
|
|
180
|
+
verifierPubkey,
|
|
181
|
+
severity: rapidCount > 5 ? 'high' : 'medium',
|
|
182
|
+
description: `${rapidCount} credential pairs issued less than ${cfg.minTimeBetweenCredentials}s apart (min gap: ${minGap}s). Suggests rubber-stamping, not in-person verification.`,
|
|
183
|
+
evidence: { metric: 'rapid_issuance_count', value: rapidCount, threshold: 0 },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return flags;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Geographic anomaly detection */
|
|
191
|
+
function analyzeGeographic(
|
|
192
|
+
verifierPubkey: string,
|
|
193
|
+
issued: NostrEvent[],
|
|
194
|
+
verifierCredential: NostrEvent,
|
|
195
|
+
cfg: AnomalyConfig
|
|
196
|
+
): AnomalyFlag[] {
|
|
197
|
+
const flags: AnomalyFlag[] = [];
|
|
198
|
+
|
|
199
|
+
const verifierJurisdiction = getTagValue(verifierCredential, 'jurisdiction');
|
|
200
|
+
if (!verifierJurisdiction) return flags;
|
|
201
|
+
|
|
202
|
+
// Count jurisdictions in issued credentials
|
|
203
|
+
const jurisdictionCounts = new Map<string, number>();
|
|
204
|
+
for (const cred of issued) {
|
|
205
|
+
const j = getTagValue(cred, 'jurisdiction') || 'unknown';
|
|
206
|
+
jurisdictionCounts.set(j, (jurisdictionCounts.get(j) || 0) + 1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check for high foreign jurisdiction percentage
|
|
210
|
+
for (const [jurisdiction, count] of jurisdictionCounts) {
|
|
211
|
+
if (jurisdiction === verifierJurisdiction) continue;
|
|
212
|
+
|
|
213
|
+
const percent = (count / issued.length) * 100;
|
|
214
|
+
if (percent > cfg.maxForeignJurisdictionPercent) {
|
|
215
|
+
flags.push({
|
|
216
|
+
type: 'geographic',
|
|
217
|
+
verifierPubkey,
|
|
218
|
+
severity: percent > 60 ? 'high' : 'medium',
|
|
219
|
+
description: `${percent.toFixed(0)}% of credentials (${count}/${issued.length}) issued in ${jurisdiction}, but verifier is registered in ${verifierJurisdiction}`,
|
|
220
|
+
evidence: {
|
|
221
|
+
metric: 'foreign_jurisdiction_percent',
|
|
222
|
+
value: percent,
|
|
223
|
+
threshold: cfg.maxForeignJurisdictionPercent,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return flags;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Pattern anomaly detection */
|
|
233
|
+
function analyzePatterns(
|
|
234
|
+
verifierPubkey: string,
|
|
235
|
+
sorted: NostrEvent[]
|
|
236
|
+
): AnomalyFlag[] {
|
|
237
|
+
const flags: AnomalyFlag[] = [];
|
|
238
|
+
|
|
239
|
+
if (sorted.length < 5) return flags;
|
|
240
|
+
|
|
241
|
+
// Check for repeated subjects (same person verified multiple times)
|
|
242
|
+
const subjectCounts = new Map<string, number>();
|
|
243
|
+
for (const cred of sorted) {
|
|
244
|
+
const subject = getTagValue(cred, 'd') || '';
|
|
245
|
+
subjectCounts.set(subject, (subjectCounts.get(subject) || 0) + 1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const duplicates = Array.from(subjectCounts.entries()).filter(([, count]) => count > 1);
|
|
249
|
+
if (duplicates.length > 0) {
|
|
250
|
+
const totalDupes = duplicates.reduce((sum, [, count]) => sum + count - 1, 0);
|
|
251
|
+
flags.push({
|
|
252
|
+
type: 'pattern',
|
|
253
|
+
verifierPubkey,
|
|
254
|
+
severity: 'low',
|
|
255
|
+
description: `${duplicates.length} subjects verified multiple times (${totalDupes} extra verifications)`,
|
|
256
|
+
evidence: { metric: 'duplicate_subjects', value: duplicates.length, threshold: 0 },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Check for disproportionate tier 4 issuance
|
|
261
|
+
let tier4Count = 0;
|
|
262
|
+
for (const cred of sorted) {
|
|
263
|
+
if (getTagValue(cred, 'tier') === '4') tier4Count++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const tier4Percent = (tier4Count / sorted.length) * 100;
|
|
267
|
+
if (tier4Count > 3 && tier4Percent > 80) {
|
|
268
|
+
flags.push({
|
|
269
|
+
type: 'pattern',
|
|
270
|
+
verifierPubkey,
|
|
271
|
+
severity: 'medium',
|
|
272
|
+
description: `${tier4Percent.toFixed(0)}% of credentials are Tier 4 (${tier4Count}/${sorted.length}). Unusually high child verification rate.`,
|
|
273
|
+
evidence: { metric: 'tier4_percent', value: tier4Percent, threshold: 80 },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return flags;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get a summary of all flagged verifiers from a set of credentials.
|
|
282
|
+
*/
|
|
283
|
+
export function scanForAnomalies(
|
|
284
|
+
allCredentials: NostrEvent[],
|
|
285
|
+
verifierCredentials: NostrEvent[],
|
|
286
|
+
config?: Partial<AnomalyConfig>
|
|
287
|
+
): Map<string, AnomalyFlag[]> {
|
|
288
|
+
const results = new Map<string, AnomalyFlag[]>();
|
|
289
|
+
|
|
290
|
+
// Get unique verifier pubkeys from credentials
|
|
291
|
+
const verifiers = new Set(
|
|
292
|
+
allCredentials
|
|
293
|
+
.filter((e) => e.kind === ATTESTATION_KIND && getTagValue(e, 'type') === ATTESTATION_TYPES.CREDENTIAL)
|
|
294
|
+
.map((e) => e.pubkey)
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
for (const pubkey of verifiers) {
|
|
298
|
+
const verifierCred = verifierCredentials.find((c) => c.pubkey === pubkey);
|
|
299
|
+
const flags = detectAnomalies(pubkey, allCredentials, verifierCred, config);
|
|
300
|
+
|
|
301
|
+
if (flags.length > 0) {
|
|
302
|
+
results.set(pubkey, flags);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return results;
|
|
307
|
+
}
|
package/src/badge.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Level 1 Badge Display Library
|
|
2
|
+
// Minimal drop-in module for Nostr clients to display Signet trust badges.
|
|
3
|
+
// Requires only kind 31000 (type: credential and type: vouch) and Schnorr verification.
|
|
4
|
+
|
|
5
|
+
import { ATTESTATION_KIND, ATTESTATION_TYPES, TRUST_WEIGHTS, MAX_TRUST_SCORE } from './constants.js';
|
|
6
|
+
import { verifyEvent } from './crypto.js';
|
|
7
|
+
import { getTagValue } from './validation.js';
|
|
8
|
+
import type { NostrEvent, SignetTier } from './types.js';
|
|
9
|
+
|
|
10
|
+
// --- Types ---
|
|
11
|
+
|
|
12
|
+
export interface BadgeInfo {
|
|
13
|
+
/** The pubkey this badge describes */
|
|
14
|
+
pubkey: string;
|
|
15
|
+
/** Highest verification tier (1-4) */
|
|
16
|
+
tier: SignetTier;
|
|
17
|
+
/** Human-readable tier label */
|
|
18
|
+
tierLabel: string;
|
|
19
|
+
/** Signet Score (0-200) */
|
|
20
|
+
score: number;
|
|
21
|
+
/** Whether the user has any valid credentials */
|
|
22
|
+
isVerified: boolean;
|
|
23
|
+
/** Short display string, e.g. "Verified (Tier 3)" */
|
|
24
|
+
displayLabel: string;
|
|
25
|
+
/** Number of valid credentials */
|
|
26
|
+
credentialCount: number;
|
|
27
|
+
/** Number of valid vouches */
|
|
28
|
+
vouchCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TrustLevel = 'unverified' | 'self-declared' | 'vouched' | 'professional' | 'professional-child';
|
|
32
|
+
|
|
33
|
+
const TIER_LABELS: Record<SignetTier, string> = {
|
|
34
|
+
1: 'Self-declared',
|
|
35
|
+
2: 'Web-of-trust',
|
|
36
|
+
3: 'Verified',
|
|
37
|
+
4: 'Verified (Child Safety)',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const TIER_TO_TRUST_LEVEL: Record<SignetTier, TrustLevel> = {
|
|
41
|
+
1: 'self-declared',
|
|
42
|
+
2: 'vouched',
|
|
43
|
+
3: 'professional',
|
|
44
|
+
4: 'professional-child',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// --- Core Functions ---
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compute badge info for a pubkey from their credentials and vouches.
|
|
51
|
+
* This is the main entry point for Level 1 integration.
|
|
52
|
+
*
|
|
53
|
+
* @param pubkey - The Nostr pubkey to compute a badge for
|
|
54
|
+
* @param events - All relevant kind 31000 (`type: credential` and `type: vouch`) events
|
|
55
|
+
* @param options - Optional configuration
|
|
56
|
+
* @returns Badge info for display
|
|
57
|
+
*/
|
|
58
|
+
export async function computeBadge(
|
|
59
|
+
pubkey: string,
|
|
60
|
+
events: NostrEvent[],
|
|
61
|
+
options?: { verifySignatures?: boolean; now?: number }
|
|
62
|
+
): Promise<BadgeInfo> {
|
|
63
|
+
const now = options?.now ?? Math.floor(Date.now() / 1000);
|
|
64
|
+
const verify = options?.verifySignatures ?? false;
|
|
65
|
+
|
|
66
|
+
let highestTier: SignetTier = 1;
|
|
67
|
+
let rawScore = 0;
|
|
68
|
+
let credentialCount = 0;
|
|
69
|
+
let hasAnyCredential = false;
|
|
70
|
+
|
|
71
|
+
// Process credentials
|
|
72
|
+
for (const event of events) {
|
|
73
|
+
if (event.kind !== ATTESTATION_KIND) continue;
|
|
74
|
+
if (getTagValue(event, 'type') !== ATTESTATION_TYPES.CREDENTIAL) continue;
|
|
75
|
+
const dTag = getTagValue(event, 'd') || '';
|
|
76
|
+
const pTag = getTagValue(event, 'p');
|
|
77
|
+
let subject: string;
|
|
78
|
+
if (dTag.startsWith('assertion:') && pTag) {
|
|
79
|
+
subject = pTag;
|
|
80
|
+
} else {
|
|
81
|
+
subject = dTag.startsWith('credential:') ? dTag.slice('credential:'.length) : dTag;
|
|
82
|
+
}
|
|
83
|
+
if (subject !== pubkey) continue;
|
|
84
|
+
|
|
85
|
+
// Check expiry — NaN must be treated as expired (not perpetually valid)
|
|
86
|
+
const expires = getTagValue(event, 'expiration');
|
|
87
|
+
if (expires) {
|
|
88
|
+
const exp = parseInt(expires, 10);
|
|
89
|
+
if (isNaN(exp) || exp < now) continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Optional signature verification
|
|
93
|
+
if (verify && !await verifyEvent(event)) continue;
|
|
94
|
+
|
|
95
|
+
hasAnyCredential = true;
|
|
96
|
+
credentialCount++;
|
|
97
|
+
|
|
98
|
+
const rawTier = parseInt(getTagValue(event, 'tier') || '1', 10);
|
|
99
|
+
const tier = (rawTier >= 1 && rawTier <= 4 ? rawTier : 1) as SignetTier;
|
|
100
|
+
if (tier > highestTier) highestTier = tier;
|
|
101
|
+
|
|
102
|
+
const verificationType = getTagValue(event, 'verification-type');
|
|
103
|
+
if (verificationType === 'professional') {
|
|
104
|
+
rawScore += TRUST_WEIGHTS.PROFESSIONAL_VERIFICATION;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Process vouches
|
|
109
|
+
const vouchersSeen = new Set<string>();
|
|
110
|
+
let vouchCount = 0;
|
|
111
|
+
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
if (event.kind !== ATTESTATION_KIND) continue;
|
|
114
|
+
if (getTagValue(event, 'type') !== ATTESTATION_TYPES.VOUCH) continue;
|
|
115
|
+
const dTag = getTagValue(event, 'd') || '';
|
|
116
|
+
const subject = dTag.startsWith('vouch:') ? dTag.slice('vouch:'.length) : dTag;
|
|
117
|
+
if (subject !== pubkey) continue;
|
|
118
|
+
|
|
119
|
+
// One vouch per voucher
|
|
120
|
+
if (vouchersSeen.has(event.pubkey)) continue;
|
|
121
|
+
vouchersSeen.add(event.pubkey);
|
|
122
|
+
|
|
123
|
+
if (verify && !await verifyEvent(event)) continue;
|
|
124
|
+
|
|
125
|
+
vouchCount++;
|
|
126
|
+
|
|
127
|
+
const method = getTagValue(event, 'method');
|
|
128
|
+
const rawVoucherScore = parseInt(getTagValue(event, 'voucher-score') || '50', 10);
|
|
129
|
+
const voucherScore = isNaN(rawVoucherScore) ? 50 : Math.max(0, Math.min(rawVoucherScore, MAX_TRUST_SCORE));
|
|
130
|
+
const multiplier = voucherScore / MAX_TRUST_SCORE;
|
|
131
|
+
|
|
132
|
+
if (method === 'in-person') {
|
|
133
|
+
rawScore += TRUST_WEIGHTS.IN_PERSON_VOUCH * multiplier;
|
|
134
|
+
} else {
|
|
135
|
+
rawScore += TRUST_WEIGHTS.ONLINE_VOUCH * multiplier;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const score = Math.min(Math.round(rawScore), MAX_TRUST_SCORE);
|
|
140
|
+
const tierLabel = TIER_LABELS[highestTier];
|
|
141
|
+
|
|
142
|
+
let displayLabel: string;
|
|
143
|
+
if (!hasAnyCredential && vouchCount === 0) {
|
|
144
|
+
displayLabel = 'Unverified';
|
|
145
|
+
} else {
|
|
146
|
+
displayLabel = `${tierLabel} (Tier ${highestTier})`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
pubkey,
|
|
151
|
+
tier: highestTier,
|
|
152
|
+
tierLabel,
|
|
153
|
+
score,
|
|
154
|
+
isVerified: hasAnyCredential,
|
|
155
|
+
displayLabel,
|
|
156
|
+
credentialCount,
|
|
157
|
+
vouchCount,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get the trust level classification for a badge.
|
|
163
|
+
*/
|
|
164
|
+
export function getTrustLevel(badge: BadgeInfo): TrustLevel {
|
|
165
|
+
if (!badge.isVerified && badge.vouchCount === 0) return 'unverified';
|
|
166
|
+
return TIER_TO_TRUST_LEVEL[badge.tier];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if a pubkey has at least the required tier.
|
|
171
|
+
* Useful for gating access to features or communities.
|
|
172
|
+
*/
|
|
173
|
+
export function meetsMinimumTier(badge: BadgeInfo, minTier: SignetTier): boolean {
|
|
174
|
+
return badge.isVerified && badge.tier >= minTier;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Filter a set of events to only those relevant for a given pubkey.
|
|
179
|
+
* Useful when you have a mixed bag of events from a relay query.
|
|
180
|
+
*/
|
|
181
|
+
export function filterEventsForPubkey(pubkey: string, events: NostrEvent[]): NostrEvent[] {
|
|
182
|
+
return events.filter(event => {
|
|
183
|
+
if (event.kind !== ATTESTATION_KIND) return false;
|
|
184
|
+
const eventType = getTagValue(event, 'type');
|
|
185
|
+
if (eventType !== ATTESTATION_TYPES.CREDENTIAL && eventType !== ATTESTATION_TYPES.VOUCH) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const dTag = getTagValue(event, 'd') || '';
|
|
189
|
+
// Strip type prefix from d-tag to get subject
|
|
190
|
+
const subject = dTag.includes(':') ? dTag.slice(dTag.indexOf(':') + 1) : dTag;
|
|
191
|
+
return subject === pubkey;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build a Nostr filter for fetching badge-relevant events for one or more pubkeys.
|
|
197
|
+
* Returns filters suitable for use with REQ messages.
|
|
198
|
+
*/
|
|
199
|
+
export function buildBadgeFilters(pubkeys: string[]): Array<{ kinds: number[]; '#d': string[] }> {
|
|
200
|
+
// With the generic attestation kind, d-tags are now prefixed with the type
|
|
201
|
+
const dTags = pubkeys.flatMap(pk => [`credential:${pk}`, `vouch:${pk}`]);
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
kinds: [ATTESTATION_KIND],
|
|
205
|
+
'#d': dTags,
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
}
|