signet-protocol 1.11.1 → 1.11.2
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/README.md +8 -1
- package/docs/bot-ownership-v1.md +43 -0
- package/docs/implementation-levels.md +235 -0
- package/docs/private-vault-v1.md +104 -0
- package/docs/signet-in-5-minutes.md +181 -0
- package/examples/full-flow.ts +260 -0
- package/examples/jurisdictions/brazil-lawyer.ts +186 -0
- package/examples/jurisdictions/eu-doctor.ts +160 -0
- package/examples/jurisdictions/india-doctor.ts +207 -0
- package/examples/jurisdictions/japan-notary.ts +157 -0
- package/examples/jurisdictions/multi-jurisdiction.ts +174 -0
- package/examples/jurisdictions/uae-professional.ts +190 -0
- package/examples/jurisdictions/uk-solicitor.ts +170 -0
- package/examples/jurisdictions/us-attorney.ts +161 -0
- package/examples/signet-me.ts +253 -0
- package/llms-full.txt +51 -20
- package/llms.txt +29 -11
- package/package.json +7 -1
- package/spec/protocol.md +2628 -0
- package/spec/voting.md +563 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Example: Full "Signet Me" Flow
|
|
2
|
+
// Profile creation → backup → connection → verification words
|
|
3
|
+
//
|
|
4
|
+
// Run: npx tsx examples/signet-me.ts
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
generateMnemonic,
|
|
8
|
+
validateMnemonic,
|
|
9
|
+
mnemonicToEntropy,
|
|
10
|
+
entropyToMnemonic,
|
|
11
|
+
createSignetIdentity,
|
|
12
|
+
deriveAdditionalPersona,
|
|
13
|
+
splitSecret,
|
|
14
|
+
reconstructSecret,
|
|
15
|
+
shareToWords,
|
|
16
|
+
wordsToShare,
|
|
17
|
+
createQRPayload,
|
|
18
|
+
serializeQRPayload,
|
|
19
|
+
parseQRPayload,
|
|
20
|
+
computeSharedSecret,
|
|
21
|
+
createConnection,
|
|
22
|
+
getSignetWords,
|
|
23
|
+
verifySignetWords,
|
|
24
|
+
formatSignetWords,
|
|
25
|
+
getSignetDisplay,
|
|
26
|
+
} from '../src/index.js';
|
|
27
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// Step 1: Alice creates her identity
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
console.log('═══════════════════════════════════════════════════');
|
|
34
|
+
console.log(' STEP 1: Profile Creation');
|
|
35
|
+
console.log('═══════════════════════════════════════════════════\n');
|
|
36
|
+
|
|
37
|
+
const aliceMnemonic = generateMnemonic();
|
|
38
|
+
const aliceIdentity = createSignetIdentity(aliceMnemonic);
|
|
39
|
+
const alice = {
|
|
40
|
+
privateKey: bytesToHex(aliceIdentity.naturalPerson.identity.privateKey),
|
|
41
|
+
publicKey: bytesToHex(aliceIdentity.naturalPerson.identity.publicKey),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
console.log('Alice creates a new Signet identity:\n');
|
|
45
|
+
console.log(` Mnemonic: ${aliceMnemonic}`);
|
|
46
|
+
console.log(` Public Key: ${alice.publicKey.slice(0, 16)}...`);
|
|
47
|
+
console.log(` Valid: ${validateMnemonic(aliceMnemonic)}`);
|
|
48
|
+
|
|
49
|
+
// Alice also derives a child persona
|
|
50
|
+
const aliceChildPersona = deriveAdditionalPersona(aliceIdentity.root, 'child', 0);
|
|
51
|
+
const aliceChild = {
|
|
52
|
+
publicKey: bytesToHex(aliceChildPersona.identity.publicKey),
|
|
53
|
+
};
|
|
54
|
+
console.log(`\n Child's Key: ${aliceChild.publicKey.slice(0, 16)}... (persona: child)`);
|
|
55
|
+
aliceIdentity.root.destroy();
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
// Step 2: Alice backs up her seed using Shamir's Secret Sharing
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
console.log('\n═══════════════════════════════════════════════════');
|
|
62
|
+
console.log(' STEP 2: Shared Backup (Shamir 2-of-3)');
|
|
63
|
+
console.log('═══════════════════════════════════════════════════\n');
|
|
64
|
+
|
|
65
|
+
const entropy = mnemonicToEntropy(aliceMnemonic);
|
|
66
|
+
const shares = splitSecret(entropy, 2, 3);
|
|
67
|
+
|
|
68
|
+
console.log('Alice splits her seed into 3 shares (any 2 reconstruct):\n');
|
|
69
|
+
for (const share of shares) {
|
|
70
|
+
const words = shareToWords(share);
|
|
71
|
+
console.log(` Share ${share.id}: ${words.slice(0, 4).join(' ')}... (${words.length} words)`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Verify reconstruction with shares 1 and 3 (skipping share 2)
|
|
75
|
+
const reconstructed = reconstructSecret(
|
|
76
|
+
[wordsToShare(shareToWords(shares[0])), wordsToShare(shareToWords(shares[2]))],
|
|
77
|
+
2
|
|
78
|
+
);
|
|
79
|
+
const reconstructedMnemonic = entropyToMnemonic(reconstructed);
|
|
80
|
+
console.log(`\n Reconstructed from shares 1 + 3: ${reconstructedMnemonic === aliceMnemonic ? 'SUCCESS ✓' : 'FAILED ✗'}`);
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
// Step 3: Bob creates his identity
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
console.log('\n═══════════════════════════════════════════════════');
|
|
87
|
+
console.log(' STEP 3: Bob Creates His Identity');
|
|
88
|
+
console.log('═══════════════════════════════════════════════════\n');
|
|
89
|
+
|
|
90
|
+
const bobMnemonic = generateMnemonic();
|
|
91
|
+
const bobIdentity = createSignetIdentity(bobMnemonic);
|
|
92
|
+
const bob = {
|
|
93
|
+
privateKey: bytesToHex(bobIdentity.naturalPerson.identity.privateKey),
|
|
94
|
+
publicKey: bytesToHex(bobIdentity.naturalPerson.identity.publicKey),
|
|
95
|
+
};
|
|
96
|
+
const bobChildPersona = deriveAdditionalPersona(bobIdentity.root, 'child', 0);
|
|
97
|
+
const bobChild = {
|
|
98
|
+
publicKey: bytesToHex(bobChildPersona.identity.publicKey),
|
|
99
|
+
};
|
|
100
|
+
bobIdentity.root.destroy();
|
|
101
|
+
|
|
102
|
+
console.log(`Bob's identity:`);
|
|
103
|
+
console.log(` Public Key: ${bob.publicKey.slice(0, 16)}...`);
|
|
104
|
+
console.log(` Child's Key: ${bobChild.publicKey.slice(0, 16)}...`);
|
|
105
|
+
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// Step 4: Alice and Bob meet in person — QR code exchange
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
console.log('\n═══════════════════════════════════════════════════');
|
|
111
|
+
console.log(' STEP 4: In-Person Connection (QR Exchange)');
|
|
112
|
+
console.log('═══════════════════════════════════════════════════\n');
|
|
113
|
+
|
|
114
|
+
// Alice shows her QR code with selected contact info
|
|
115
|
+
const aliceQR = createQRPayload(alice.publicKey, {
|
|
116
|
+
name: 'Alice',
|
|
117
|
+
mobile: '+44 7700 900000',
|
|
118
|
+
childPubkeys: [aliceChild.publicKey],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Bob shows his QR code
|
|
122
|
+
const bobQR = createQRPayload(bob.publicKey, {
|
|
123
|
+
name: 'Bob',
|
|
124
|
+
mobile: '+44 7700 900001',
|
|
125
|
+
address: '42 Oak Lane, London',
|
|
126
|
+
childPubkeys: [bobChild.publicKey],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log('Alice and Bob meet at a local meetup. They scan each other\'s QR codes.\n');
|
|
130
|
+
|
|
131
|
+
// Simulate the QR scan — each party scans the other's serialized payload
|
|
132
|
+
const aliceScannedBob = parseQRPayload(serializeQRPayload(bobQR));
|
|
133
|
+
const bobScannedAlice = parseQRPayload(serializeQRPayload(aliceQR));
|
|
134
|
+
|
|
135
|
+
// Each creates a connection
|
|
136
|
+
const aliceConnection = createConnection(alice.privateKey, aliceScannedBob, {
|
|
137
|
+
name: 'Alice',
|
|
138
|
+
mobile: '+44 7700 900000',
|
|
139
|
+
childPubkeys: [aliceChild.publicKey],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const bobConnection = createConnection(bob.privateKey, bobScannedAlice, {
|
|
143
|
+
name: 'Bob',
|
|
144
|
+
mobile: '+44 7700 900001',
|
|
145
|
+
address: '42 Oak Lane, London',
|
|
146
|
+
childPubkeys: [bobChild.publicKey],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
console.log(`Alice → Bob shared secret: ${aliceConnection.sharedSecret.slice(0, 16)}...`);
|
|
150
|
+
console.log(`Bob → Alice shared secret: ${bobConnection.sharedSecret.slice(0, 16)}...`);
|
|
151
|
+
console.log(`Secrets match: ${aliceConnection.sharedSecret === bobConnection.sharedSecret ? 'YES ✓' : 'NO ✗'}`);
|
|
152
|
+
console.log(`\nAlice sees Bob's info:`);
|
|
153
|
+
console.log(` Name: ${aliceConnection.theirInfo.name}`);
|
|
154
|
+
console.log(` Mobile: ${aliceConnection.theirInfo.mobile}`);
|
|
155
|
+
console.log(` Kids: ${aliceConnection.theirInfo.childPubkeys?.length} child key(s)`);
|
|
156
|
+
console.log(`\nBob sees Alice's info:`);
|
|
157
|
+
console.log(` Name: ${bobConnection.theirInfo.name}`);
|
|
158
|
+
console.log(` Mobile: ${bobConnection.theirInfo.mobile}`);
|
|
159
|
+
console.log(` Kids: ${bobConnection.theirInfo.childPubkeys?.length} child key(s)`);
|
|
160
|
+
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
// Step 5: Months later — Bob calls Alice asking for money
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
console.log('\n═══════════════════════════════════════════════════');
|
|
166
|
+
console.log(' STEP 5: "Signet Me" — Phone Verification');
|
|
167
|
+
console.log('═══════════════════════════════════════════════════\n');
|
|
168
|
+
|
|
169
|
+
console.log('Six months later, Bob calls Alice:');
|
|
170
|
+
console.log('"Hey Alice, it\'s Bob. I\'m in a tight spot — can you send');
|
|
171
|
+
console.log(' some cash to my new bank account?"');
|
|
172
|
+
console.log('');
|
|
173
|
+
console.log('Alice: "Sure, no problem. Signet me first."\n');
|
|
174
|
+
|
|
175
|
+
// Both open each other's profiles at the same time
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
|
|
178
|
+
const aliceSees = getSignetDisplay(aliceConnection.sharedSecret, now);
|
|
179
|
+
const bobSees = getSignetDisplay(bobConnection.sharedSecret, now);
|
|
180
|
+
|
|
181
|
+
console.log('Alice opens Bob\'s profile and sees:');
|
|
182
|
+
console.log(` My Signet: ${aliceSees.formatted}`);
|
|
183
|
+
console.log(` Expires in: ${aliceSees.expiresIn.toFixed(0)}s`);
|
|
184
|
+
console.log('');
|
|
185
|
+
console.log('Bob opens Alice\'s profile and sees:');
|
|
186
|
+
console.log(` My Signet: ${bobSees.formatted}`);
|
|
187
|
+
console.log(` Expires in: ${bobSees.expiresIn.toFixed(0)}s`);
|
|
188
|
+
console.log('');
|
|
189
|
+
console.log(`Words match: ${aliceSees.formatted === bobSees.formatted ? 'YES ✓' : 'NO ✗'}`);
|
|
190
|
+
|
|
191
|
+
// Bob reads the words to Alice
|
|
192
|
+
const bobReadsWords = getSignetWords(bobConnection.sharedSecret, now);
|
|
193
|
+
const aliceVerifies = verifySignetWords(aliceConnection.sharedSecret, bobReadsWords, now);
|
|
194
|
+
|
|
195
|
+
console.log(`\nBob reads: "${formatSignetWords(bobReadsWords)}"`);
|
|
196
|
+
console.log(`Alice verifies: ${aliceVerifies ? 'MATCH ✓ — It\'s really Bob!' : 'NO MATCH ✗ — Impostor!'}`);
|
|
197
|
+
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
// Step 6: Demonstrating word rotation
|
|
200
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
console.log('\n═══════════════════════════════════════════════════');
|
|
203
|
+
console.log(' STEP 6: Word Rotation');
|
|
204
|
+
console.log('═══════════════════════════════════════════════════\n');
|
|
205
|
+
|
|
206
|
+
console.log('Words rotate every 30 seconds:\n');
|
|
207
|
+
for (let i = 0; i < 4; i++) {
|
|
208
|
+
const futureTime = now + i * 30_000;
|
|
209
|
+
const words = getSignetWords(aliceConnection.sharedSecret, futureTime);
|
|
210
|
+
const label = i === 0 ? 'Now ' : `+${i * 30}s `;
|
|
211
|
+
console.log(` ${label} ${formatSignetWords(words)}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
// Step 7: Bank scenario
|
|
216
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
console.log('\n═══════════════════════════════════════════════════');
|
|
219
|
+
console.log(' STEP 7: Bank Verification');
|
|
220
|
+
console.log('═══════════════════════════════════════════════════\n');
|
|
221
|
+
|
|
222
|
+
// The bank has a Nostr keypair too
|
|
223
|
+
const bankMnemonic = generateMnemonic();
|
|
224
|
+
const bankIdentity = createSignetIdentity(bankMnemonic);
|
|
225
|
+
const bank = {
|
|
226
|
+
privateKey: bytesToHex(bankIdentity.naturalPerson.identity.privateKey),
|
|
227
|
+
publicKey: bytesToHex(bankIdentity.naturalPerson.identity.publicKey),
|
|
228
|
+
};
|
|
229
|
+
bankIdentity.root.destroy();
|
|
230
|
+
|
|
231
|
+
// Alice visits the bank and scans their QR code
|
|
232
|
+
const bankQR = createQRPayload(bank.publicKey, { name: 'High Street Bank' });
|
|
233
|
+
const aliceBankConnection = createConnection(alice.privateKey, parseQRPayload(serializeQRPayload(bankQR)), {
|
|
234
|
+
name: 'Alice',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Bank also stores the connection
|
|
238
|
+
const bankAliceSecret = computeSharedSecret(bank.privateKey, alice.publicKey);
|
|
239
|
+
|
|
240
|
+
console.log('"The bank" calls Alice about suspicious activity.\n');
|
|
241
|
+
console.log('Alice: "Read me my signet."\n');
|
|
242
|
+
|
|
243
|
+
const bankNow = Date.now();
|
|
244
|
+
const bankWords = getSignetWords(bankAliceSecret, bankNow);
|
|
245
|
+
const aliceBankWords = getSignetWords(aliceBankConnection.sharedSecret, bankNow);
|
|
246
|
+
|
|
247
|
+
console.log(`Bank agent reads: ${formatSignetWords(bankWords)}`);
|
|
248
|
+
console.log(`Alice sees: ${formatSignetWords(aliceBankWords)}`);
|
|
249
|
+
console.log(`Match: ${formatSignetWords(bankWords) === formatSignetWords(aliceBankWords) ? 'YES ✓ — It\'s really the bank.' : 'NO ✗ — Scam call!'}`);
|
|
250
|
+
|
|
251
|
+
console.log('\n═══════════════════════════════════════════════════');
|
|
252
|
+
console.log(' Done. Your signet is your proof.');
|
|
253
|
+
console.log('═══════════════════════════════════════════════════');
|
package/llms-full.txt
CHANGED
|
@@ -24,6 +24,7 @@ const events = await fetchFromRelay('wss://relay.example.com', filters);
|
|
|
24
24
|
// Compute badge (async: returns Promise<BadgeInfo>)
|
|
25
25
|
const badge = await computeBadge('<hex-pubkey>', events, { verifySignatures: true });
|
|
26
26
|
// badge.tier => 3, badge.score => 106, badge.displayLabel => "Verified (Tier 3)"
|
|
27
|
+
// (illustrative values; badge.score is a Level 1 approximation, see "Trust & Badges")
|
|
27
28
|
|
|
28
29
|
// Gate features on verification level
|
|
29
30
|
if (meetsMinimumTier(badge, 2)) {
|
|
@@ -31,6 +32,8 @@ if (meetsMinimumTier(badge, 2)) {
|
|
|
31
32
|
}
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
`verifySignatures` defaults to `false` in `computeBadge`, so keep `{ verifySignatures: true }` in copied code unless the events were already verified. `computeBadge` also does not check who issued a Tier 3 or Tier 4 credential; see "Who may issue Tier 3 and Tier 4" before gating on a professional tier.
|
|
36
|
+
|
|
34
37
|
`fetchFromRelay(relayUrl: string, filters: NostrFilter[]): Promise<NostrEvent[]>` connects, collects events until EOSE and disconnects. Non-localhost relay URLs must be `wss://`. Any other Nostr client works equally well: pass the `buildBadgeFilters` output as REQ filters and give the returned events to `computeBadge`.
|
|
35
38
|
|
|
36
39
|
## Key Concepts
|
|
@@ -48,13 +51,16 @@ Conventions used throughout:
|
|
|
48
51
|
- Of the `create*` functions documented in this file, the event-signing ones (credentials, vouches, policies, verifiers, challenges, revocations, identity bridges, guardian delegations) are async and return a signed `Promise<NostrEvent>` (with `id` and `sig`). The matching `build*Event` functions return an unsigned `UnsignedEvent`. The documented `create*` helpers that are not event signers return what is stated beside them: `createAgeRangeProof` (synchronous, `RangeProof`), `createSignetIdentity` / `createSignetIdentityFromNsec` (synchronous, `SignetIdentity`) and `createLinkageProof` (synchronous, `LinkageProof`). The package root exports other `create*` functions that this file does not cover (for example `createTwoCredentialCeremony`, `createBondProof`); check their return types in `src/` rather than assuming the event-signer shape.
|
|
49
52
|
- Tiers are `SignetTier` (`1 | 2 | 3 | 4`). Timestamps (`expiresAt`, `occurredAt`, `accountCreatedAt`) are unix seconds.
|
|
50
53
|
- Throws use the `SignetError` hierarchy (`SignetValidationError`, `SignetCryptoError`).
|
|
54
|
+
- Types are exported from the package root as type-only exports (use `import type { NostrEvent } from 'signet-protocol'`; they have no runtime value): `NostrEvent` (`{ id, pubkey, created_at, kind, tags: string[][], content, sig }`), `UnsignedEvent` (the same without `id` and `sig`), `NostrFilter` (`{ ids?, authors?, kinds?, '#d'?, '#p'?, '#L'?, '#l'?, since?, until?, limit? }`), `BadgeInfo` and `RangeProof`. The error classes `SignetError`, `SignetValidationError` and `SignetCryptoError` are exported from the root as ordinary values you can use with `instanceof`. `src/errors.ts` also defines `SignetVotingError`, but it is not exported from the root and nothing in this package throws it.
|
|
55
|
+
- Arguments named `events`, `credentials`, `vouches`, `bridges`, `revocations`, `verifierCredentials` and `confirmationEvents` are raw signed `NostrEvent[]` as returned by a relay, not parsed objects (`ParsedCredential`, `ParsedVouch` and so on). The functions read the tags themselves and skip events of the wrong kind or `type`. Unless a signature check is stated beside a function, it does not verify signatures: verify first, or fetch with `fetchFromRelay`, whose `RelayClient` drops events with an invalid signature or id by default.
|
|
56
|
+
- Issuer standing is not checked by the Level 1 and scoring functions. See "Who may issue Tier 3 and Tier 4" below.
|
|
51
57
|
|
|
52
58
|
### Credentials (kind 31000, type: credential)
|
|
53
59
|
- `createSelfDeclaredCredential(privateKey, scope = 'adult', expiresAt?)`: Tier 1. `scope` is `'adult' | 'adult+child'`.
|
|
54
60
|
- `createPeerVouchedCredential(issuerPrivateKey, subjectPubkey, opts)`: Tier 2. `opts: { assertionEventId: string; assertionRelay?: string; expiresAt?: number }`. `assertionEventId` is the subject's Tier 1 event.
|
|
55
61
|
- `createProfessionalCredential(verifierPrivateKey, subjectPubkey, opts)`: Tier 3. `opts: { assertionEventId: string; profession: string; jurisdiction: string; actualAge: number; assertionRelay?: string; expiresAt?: number; occurredAt?: number; proofBlob?: string }`. Generates an `'18+'` range proof from `actualAge` into the event content.
|
|
56
62
|
- `createChildSafetyCredential(verifierPrivateKey, subjectPubkey, opts)`: Tier 4. Same `opts` as Tier 3 plus `ageRange: string` (the range proved, e.g. `'8-12'`).
|
|
57
|
-
- `verifyCredential(event)`: `Promise<{ signatureValid: boolean; structureValid: boolean; expired: boolean; errors: string[] }
|
|
63
|
+
- `verifyCredential(event: NostrEvent)`: `Promise<{ signatureValid: boolean; structureValid: boolean; expired: boolean; errors: string[] }>`. Checks the event's own Schnorr signature, tag structure (for example a Tier 3 or Tier 4 credential must carry `verification-type: professional` and a `profession` tag) and expiry. It does not look at who signed it: a valid result does not mean the issuer is an activated, unrevoked verifier. See "Who may issue Tier 3 and Tier 4".
|
|
58
64
|
- `parseCredential(event)`: `ParsedCredential | null`
|
|
59
65
|
|
|
60
66
|
`ageRange` format everywhere: `'<min>-<max>'` inclusive (e.g. `'8-12'`, `'13-17'`) or `'<min>+'` (e.g. `'18+'`, upper bound 150). Anything else fails to parse.
|
|
@@ -63,16 +69,26 @@ Default credential expiry is two years (`DEFAULT_CREDENTIAL_EXPIRY_SECONDS`) whe
|
|
|
63
69
|
|
|
64
70
|
### Vouches (kind 31000, type: vouch)
|
|
65
71
|
- `createVouch(voucherPrivateKey, params)`: vouch for another user. `params: { subjectPubkey: string; method: 'in-person' | 'online'; voucherTier: SignetTier; voucherScore: number; context?: string; occurredAt?: number }`. `voucherScore` is the voucher's own Signet IQ (0–200).
|
|
66
|
-
- `hasEnoughVouches(vouches, subjectPubkey, threshold = 3, minVoucherTier = 2)`: `boolean`. `true` when the number of qualifying vouches (one per voucher, voucher
|
|
72
|
+
- `hasEnoughVouches(vouches: NostrEvent[], subjectPubkey: string, threshold = 3, minVoucherTier: SignetTier = 2)`: `boolean`. `vouches` is raw kind 31000 vouch events (`NostrEvent[]`), not `ParsedVouch` objects. `true` when the number of qualifying vouches (one per voucher, `voucher-tier` tag ≥ `minVoucherTier`) reaches `threshold`. It does not verify signatures, and the voucher tier is the self-reported tag, not looked up. `countQualifyingVouches(vouches: NostrEvent[], subjectPubkey: string, minVoucherTier?: SignetTier)` returns the count.
|
|
67
73
|
|
|
68
74
|
### Trust & Badges
|
|
69
|
-
- `computeBadge(pubkey, events, opts?)`: `Promise<BadgeInfo>`. `opts: { verifySignatures?: boolean (default false); now?: number }`. `BadgeInfo: { pubkey, tier, tierLabel, score, isVerified, displayLabel, credentialCount, vouchCount, entityType? }`.
|
|
70
|
-
- `buildBadgeFilters(pubkeys: string[])`: `Array<{ kinds: number[]; '#d': string[] }>`. One filter for kind 31000 with d-tags `credential:<pubkey>` and `vouch:<pubkey>` for each pubkey.
|
|
75
|
+
- `computeBadge(pubkey: string, events: NostrEvent[], opts?)`: `Promise<BadgeInfo>`. `opts: { verifySignatures?: boolean (default false); now?: number }`. `BadgeInfo: { pubkey, tier, tierLabel, score, isVerified, displayLabel, credentialCount, vouchCount, entityType? }`. `verifySignatures` defaults to `false`: with the default, `computeBadge` does not check any signature, so a call that omits it trusts every event it is given. Pass `{ verifySignatures: true }` unless the events came from `fetchFromRelay` or another source that has already dropped events with an invalid signature or id. `computeBadge` reads the `tier`, `verification-type`, `expiration`, `method` and `voucher-score` tags. It does not run `validateCredential` and does not check who signed a credential, so it does not enforce issuer standing (see "Who may issue Tier 3 and Tier 4").
|
|
76
|
+
- `buildBadgeFilters(pubkeys: string[])`: `Array<{ kinds: number[]; '#d': string[] }>`. One filter for kind 31000 with d-tags `credential:<pubkey>` and `vouch:<pubkey>` for each pubkey. It fetches no verifier, challenge or revocation events, so the events it returns cannot be used to check issuer standing.
|
|
71
77
|
- `meetsMinimumTier(badge: BadgeInfo, minTier: SignetTier)`: `boolean`. `true` only when `badge.isVerified` and `badge.tier >= minTier`.
|
|
72
|
-
- `computeTrustScore(subjectPubkey, credentials, vouches, accountCreatedAt
|
|
78
|
+
- `computeTrustScore(subjectPubkey: string, credentials: NostrEvent[], vouches: NostrEvent[], accountCreatedAt?: number, bridges?: NostrEvent[])`: `TrustScoreBreakdown` (`{ score, tier, professionalVerifications, inPersonVouches, onlineVouches, accountAgeDays, signals }`). This is the full Signet IQ. `credentials`, `vouches` and `bridges` are raw kind 31000 events (`NostrEvent[]`), not parsed objects. It does not verify signatures and, like `computeBadge`, does not check issuer standing. `bridges` are matched by `event.pubkey === subjectPubkey` and are not ring-signature verified here: run `verifyIdentityBridge` first.
|
|
73
79
|
|
|
74
80
|
`badge.score` is on the same 0–200 scale and uses the same weights as `computeTrustScore(...).score` (professional verification 80, in-person vouch up to 16, online vouch up to 4, each vouch scaled by the voucher's score ÷ 200), but it is a Level 1 approximation, not the full IQ: `computeBadge` ignores account age and identity bridges and does not apply the per-type caps (1 professional, 3 in-person vouches, 5 online vouches). Use `computeTrustScore` when you need the full IQ.
|
|
75
81
|
|
|
82
|
+
### Who may issue Tier 3 and Tier 4
|
|
83
|
+
|
|
84
|
+
The library does not enforce issuer standing in `computeBadge`, `computeTrustScore`, `verifyCredential` or `countChallengeConfirmations`. A kind 31000 `type: credential` event that carries `tier: 3` (or 4) and `verification-type: professional` counts toward the tier and the score whichever key signed it, including a key that has never published a verifier credential, and `verifySignatures: true` only proves the signer wrote the event. `buildBadgeFilters` fetches only `credential:` and `vouch:` events, so a badge built from its output cannot tell a licensed verifier from any other key. The protocol requires issuers of Tier 3 and Tier 4 to be activated, unrevoked verifiers (see `spec/protocol.md`); your client has to apply that rule before calling `computeBadge`:
|
|
85
|
+
|
|
86
|
+
1. Fetch the candidate issuer's `type: verifier` credential, the vouches for that issuer, the verifier credentials of those vouchers, and any `type: revocation` events (all kind 31000). `buildBadgeFilters` does not fetch these, so build your own filters.
|
|
87
|
+
2. Treat an issuer as acceptable only when `checkCrossVerification(issuerPubkey, vouches, verifierCredentials).activated` is `true` and `isVerifierRevoked(issuerPubkey, revocations)` is `false`.
|
|
88
|
+
3. Drop every Tier 3 and Tier 4 credential whose `event.pubkey` is not an acceptable issuer, then pass the remaining events to `computeBadge`.
|
|
89
|
+
|
|
90
|
+
`checkCrossVerification` does not check that each voucher is itself activated or unrevoked, so filter `verifierCredentials` the same way before step 2 if you need the spec's full rule.
|
|
91
|
+
|
|
76
92
|
### Policies (kind 30078, NIP-78)
|
|
77
93
|
- `createPolicy(operatorPrivateKey, params)`: community verification requirements. `params: { communityId: string; adultMinTier: SignetTier; childMinTier: SignetTier; enforcement: 'client' | 'relay' | 'both'; minScore?: number; modMinTier?: SignetTier; verifierBond?: number; revocationThreshold?: number; description?: string }`. The d-tag is `signet:policy:<communityId>`.
|
|
78
94
|
- `new PolicyChecker(policyEvent: NostrEvent)`: throws `SignetValidationError('Invalid policy event')` if the event is not a valid policy. Methods: `getPolicy(): ParsedPolicy`, `checkAdult(tier, score)`, `checkChild(tier, score)`, `checkModerator(tier, score)`. Each check returns `PolicyCheckResult`: `{ allowed: boolean; reason?: string; requiredTier: SignetTier; actualTier: SignetTier; requiredScore?: number; actualScore?: number }`.
|
|
@@ -80,20 +96,20 @@ Default credential expiry is two years (`DEFAULT_CREDENTIAL_EXPIRY_SECONDS`) whe
|
|
|
80
96
|
|
|
81
97
|
### Verifiers (kind 31000, type: verifier)
|
|
82
98
|
- `createVerifierCredential(privateKey, params)`: register as professional verifier. `params: { profession: string; jurisdiction: string; licenceHash: string; professionalBody: string; statement?: string; bondProof?: BondProof }`.
|
|
83
|
-
- `checkCrossVerification(verifierPubkey, vouches, verifierCredentials)`: activation check. Returns `{ activated: boolean; vouchCount: number; professions: string[]; errors: string[] }`.
|
|
84
|
-
- `isVerifierRevoked(verifierPubkey, revocations)`: `boolean`
|
|
99
|
+
- `checkCrossVerification(verifierPubkey: string, vouches: NostrEvent[], verifierCredentials: NostrEvent[])`: activation check. `vouches` is kind 31000 vouch events (`NostrEvent[]`) and `verifierCredentials` is kind 31000 `type: verifier` events (`NostrEvent[]`), not parsed objects. Returns `{ activated: boolean; vouchCount: number; professions: string[]; errors: string[] }`. `activated` is `true` when at least `VERIFIER_ACTIVATION.MIN_VOUCHES` vouches for `verifierPubkey` come from distinct vouchers that each appear in `verifierCredentials` with a `profession` tag, and those vouchers cover at least `VERIFIER_ACTIVATION.MIN_PROFESSIONS` different professions. It does not verify signatures, and it does not check that a voucher is itself activated or unrevoked (the spec asks for that, the function does not do it), so filter `verifierCredentials` to activated, unrevoked verifiers yourself.
|
|
100
|
+
- `isVerifierRevoked(verifierPubkey: string, revocations: NostrEvent[])`: `boolean`. `true` when any kind 31000 `type: revocation` event in `revocations` targets `verifierPubkey`. It does not verify signatures or check who signed the revocation.
|
|
85
101
|
|
|
86
102
|
### Challenges and Revocations (kind 31000, type: challenge / revocation)
|
|
87
103
|
- `createChallenge(reporterPrivateKey, params)`: `Promise<NostrEvent>`. `params: { verifierPubkey: string; reason: 'anomalous-volume' | 'registry-mismatch' | 'fraudulent-attestation' | 'licence-revoked' | 'other'; evidenceType: string; reporterTier: SignetTier; evidence: string; occurredAt?: number }`. `evidence` becomes the event content.
|
|
88
104
|
- `createRevocation(authorityPrivateKey, params)`: `Promise<NostrEvent>`. `params: { verifierPubkey: string; challengeEventId: string; confirmations: number; bondAction: 'slashed' | 'returned' | 'held'; scope: 'full' | 'partial'; effectiveAt: number; summary: string }`.
|
|
89
|
-
- `countChallengeConfirmations(challengeEventId, confirmationEvents, credentialEvents)`: `number`
|
|
105
|
+
- `countChallengeConfirmations(challengeEventId: string, confirmationEvents: NostrEvent[], credentialEvents: NostrEvent[])`: `number`. All arrays are raw `NostrEvent[]`. Counts distinct confirmation signers whose `challenge` tag equals `challengeEventId` and who hold a credential event with a `tier` tag of 3 or higher in `credentialEvents`. It does not verify signatures or the issuers of those credentials.
|
|
90
106
|
- `hasReachedRevocationThreshold(confirmations, threshold = 5)`: `boolean`
|
|
91
107
|
- `parseChallenge(event)` / `parseRevocation(event)`: parsed object or `null`.
|
|
92
108
|
|
|
93
109
|
### Identity Bridge (kind 31000, type: identity-bridge)
|
|
94
|
-
- `createIdentityBridge(anonPrivateKey, realPrivateKey, ring, signerIndex, ringMinTier, opts?)`:
|
|
95
|
-
- `selectDecoyRing(verifiedPubkeys, realPubkey, ringSize = 5)`: `{ ring: string[]; signerIndex: number }`
|
|
96
|
-
- `verifyIdentityBridge(event, opts?)`: `Promise<boolean>`. `opts: { maxAgeSeconds?: number }` (default 24 hours; `0` disables the freshness check).
|
|
110
|
+
- `createIdentityBridge(anonPrivateKey: string, realPrivateKey: string, ring: string[], signerIndex: number, ringMinTier: SignetTier, opts?)`: `Promise<NostrEvent>`. Links an anonymous account to a verified identity via ring signature. `ring: string[]` is the anonymity set of verified pubkeys (at least 5, `MIN_BRIDGE_RING_SIZE`) and must contain the public key of `realPrivateKey`. `signerIndex` is the zero-based position in `ring` of that real public key (`selectDecoyRing` returns it for you); a wrong index makes ring signing throw (`Private key does not match ring member at signerIndex`). `ringMinTier` is the lowest verification tier among the ring members, which you assert yourself: it is written to the event's `ring-min-tier` tag and scales the bridge's contribution to `computeTrustScore` (`TRUST_WEIGHTS.IDENTITY_BRIDGE` of 50 × `ringMinTier` ÷ 4). Neither `createIdentityBridge` nor `verifyIdentityBridge` checks that the ring members hold that tier. `opts: { occurredAt?: number }`. Signed by the anonymous key.
|
|
111
|
+
- `selectDecoyRing(verifiedPubkeys: string[], realPubkey: string, ringSize = 5)`: `{ ring: string[]; signerIndex: number }`. Picks decoys from `verifiedPubkeys` (`realPubkey` is filtered out of the pool if present) and places `realPubkey` at a random position. Throws if the pool has fewer than `ringSize - 1` other pubkeys.
|
|
112
|
+
- `verifyIdentityBridge(event: NostrEvent, opts?)`: `Promise<boolean>`. `opts: { maxAgeSeconds?: number }` (default 24 hours; `0` disables the freshness check).
|
|
97
113
|
|
|
98
114
|
### ZKP Age Proofs
|
|
99
115
|
- `createAgeRangeProof(age: number, ageRange: string, subjectPubkey?: string)`: `RangeProof` (synchronous). `subjectPubkey` binds the proof to one credential.
|
|
@@ -124,6 +140,8 @@ The voting extension (kinds 30482–30484) is specified in `spec/voting.md` and
|
|
|
124
140
|
|
|
125
141
|
## Optional
|
|
126
142
|
|
|
143
|
+
These paths ship in the npm package (`spec/`, `examples/`, and the `docs/` pages named below and in the experimental section above), so the relative links resolve inside `node_modules/signet-protocol/`. The examples import from the package's own `src/` directory; in your own code import from `signet-protocol`.
|
|
144
|
+
|
|
127
145
|
- [llms-full.txt](llms-full.txt): llms.txt plus the full protocol and voting specifications and the developer guides in one file
|
|
128
146
|
- [Full protocol specification](spec/protocol.md)
|
|
129
147
|
- [Voting extension specification](spec/voting.md)
|
|
@@ -3406,7 +3424,7 @@ kind 30078 (NIP-78) policy parsing so your client respects community verificatio
|
|
|
3406
3424
|
|
|
3407
3425
|
Issue professional credentials, manage verifier lifecycle (kind 31000 types: verifier, challenge, revocation), build
|
|
3408
3426
|
guardian delegation (kind 31000, `type: delegation`), support credential chains (supersedes / superseded-by),
|
|
3409
|
-
and integrate the voting extension (kinds 30482–30484).
|
|
3427
|
+
and integrate the voting extension (kinds 30482–30484). The voting extension is specified in `spec/voting.md` and implemented in the separate `nostr-anon-vote` package, not in `signet-protocol`; this package ships only the linkable ring signature primitives it builds on (`lsagSign`, `lsagVerify`, `computeKeyImage`).
|
|
3410
3428
|
|
|
3411
3429
|
|
|
3412
3430
|
## How Verification Works (Tier 3 / 4)
|
|
@@ -3456,6 +3474,8 @@ by two other licensed professionals before becoming active. A challenge / revoca
|
|
|
3456
3474
|
| 30483 | — | Voting extension: cast an anonymous ballot |
|
|
3457
3475
|
| 30484 | — | Voting extension: publish tallied result |
|
|
3458
3476
|
|
|
3477
|
+
Kinds 30482–30484 are specified in `spec/voting.md` and implemented in the separate `nostr-anon-vote` package, not in `signet-protocol`.
|
|
3478
|
+
|
|
3459
3479
|
|
|
3460
3480
|
## Integrating at Level 1
|
|
3461
3481
|
|
|
@@ -3483,7 +3503,7 @@ const events: NostrEvent[] = await fetchFromRelay('wss://relay.example.com', fil
|
|
|
3483
3503
|
const badge = await computeBadge(pubkeys[0], events, { verifySignatures: true });
|
|
3484
3504
|
|
|
3485
3505
|
// badge.displayLabel => "Verified (Tier 3)"
|
|
3486
|
-
// badge.score => 106
|
|
3506
|
+
// badge.score => 106 (illustrative; see the score note below)
|
|
3487
3507
|
// badge.tier => 3
|
|
3488
3508
|
// badge.vouchCount => 5
|
|
3489
3509
|
|
|
@@ -3497,6 +3517,10 @@ if (meetsMinimumTier(badge, 2)) {
|
|
|
3497
3517
|
(one vouch counted per voucher pubkey), and optional Schnorr signature verification. The result is
|
|
3498
3518
|
a plain object — no rendering assumptions are made.
|
|
3499
3519
|
|
|
3520
|
+
- `verifySignatures` defaults to `false`. `computeBadge(pubkey, events)` with no options checks no signatures, so keep `{ verifySignatures: true }` unless the events were already verified. `fetchFromRelay` drops events with an invalid signature or id by default; events from any other source are not checked for you.
|
|
3521
|
+
- `badge.score` is on the 0–200 Signet IQ scale but is a Level 1 approximation of `computeTrustScore(...).score`: it ignores account age and identity bridges and applies no per-type caps (1 professional verification, 3 in-person vouches, 5 online vouches). Use `computeTrustScore` for the full score.
|
|
3522
|
+
- `computeBadge` does not check who issued a Tier 3 or Tier 4 credential, and `buildBadgeFilters` fetches no verifier or revocation events. Any key can sign an event that carries `tier: 3` and it will count. To require an activated, unrevoked verifier, fetch the verifier credentials, their vouches and revocations yourself, apply `checkCrossVerification(...).activated` and `isVerifierRevoked(...)`, and drop other issuers' Tier 3 and Tier 4 credentials before calling `computeBadge` (see `llms.txt`).
|
|
3523
|
+
|
|
3500
3524
|
The `buildBadgeFilters` helper returns a standard Nostr REQ filter array ready for use with any
|
|
3501
3525
|
relay client library.
|
|
3502
3526
|
|
|
@@ -3551,19 +3575,21 @@ npm install signet-protocol
|
|
|
3551
3575
|
### Code example
|
|
3552
3576
|
|
|
3553
3577
|
```typescript
|
|
3554
|
-
import { computeBadge, buildBadgeFilters } from 'signet-protocol';
|
|
3578
|
+
import { computeBadge, buildBadgeFilters, fetchFromRelay } from 'signet-protocol';
|
|
3579
|
+
|
|
3580
|
+
const pubkey = '<hex-pubkey>';
|
|
3555
3581
|
|
|
3556
|
-
// 1. Fetch events from relay
|
|
3582
|
+
// 1. Fetch events from a relay (fetchFromRelay is the package's own client; any Nostr client works)
|
|
3557
3583
|
const filters = buildBadgeFilters([pubkey]);
|
|
3558
|
-
// Send a REQ message to your relay with these filters.
|
|
3559
3584
|
// The filters request kind 31000 with credential and vouch type tags.
|
|
3585
|
+
const events = await fetchFromRelay('wss://relay.example.com', filters);
|
|
3560
3586
|
|
|
3561
3587
|
// 2. Compute badge from the events you receive
|
|
3562
|
-
const badge = await computeBadge(pubkey, events); // async: returns Promise<BadgeInfo>
|
|
3588
|
+
const badge = await computeBadge(pubkey, events, { verifySignatures: true }); // async: returns Promise<BadgeInfo>
|
|
3563
3589
|
|
|
3564
3590
|
// 3. Display
|
|
3565
3591
|
if (badge.isVerified) {
|
|
3566
|
-
|
|
3592
|
+
console.log(badge.tierLabel, badge.score); // replace with your own rendering
|
|
3567
3593
|
}
|
|
3568
3594
|
```
|
|
3569
3595
|
|
|
@@ -3590,6 +3616,11 @@ The `badge` object includes:
|
|
|
3590
3616
|
- Merkle trees
|
|
3591
3617
|
- Any cryptographic primitives beyond standard Nostr signature verification
|
|
3592
3618
|
|
|
3619
|
+
Two things Level 1 does not do for you:
|
|
3620
|
+
|
|
3621
|
+
- Signature checking is opt-in. `verifySignatures` defaults to `false`, so `computeBadge(pubkey, events)` with no options trusts every event it is given. Keep `{ verifySignatures: true }` unless the events were already verified (`fetchFromRelay` drops events with an invalid signature or id by default; events from other sources are not checked for you).
|
|
3622
|
+
- Issuer standing is not checked. `computeBadge` does not look at who signed a Tier 3 or Tier 4 credential, and `buildBadgeFilters` fetches no verifier or revocation events, so any key can sign an event that carries `tier: 3` and it will count. To require an activated, unrevoked verifier, fetch the verifier credentials, their vouches and revocations yourself, apply `checkCrossVerification(...).activated` and `isVerifierRevoked(...)`, and drop other issuers' Tier 3 and Tier 4 credentials before calling `computeBadge` (see `llms.txt`).
|
|
3623
|
+
|
|
3593
3624
|
---
|
|
3594
3625
|
|
|
3595
3626
|
## Level 2 — Issue Vouches
|
|
@@ -3681,7 +3712,7 @@ Reference: `src/policies.ts`
|
|
|
3681
3712
|
**Event kinds:** 31000 (all attestation types), 30078 (policies), 30482-30484 (voting)
|
|
3682
3713
|
**New crypto required:** Merkle trees, Pedersen range proofs (age range proofs), linkable ring signatures
|
|
3683
3714
|
|
|
3684
|
-
Level 3 is full reference implementation. This is appropriate for clients that want to act as professional verifiers, run elections, or issue and receive Tier 3/4 credentials with zero-knowledge proofs.
|
|
3715
|
+
Level 3 is full reference implementation. This is appropriate for clients that want to act as professional verifiers, run elections, or issue and receive Tier 3/4 credentials with zero-knowledge proofs. Elections themselves (kinds 30482–30484) live in the separate `nostr-anon-vote` package, not in `signet-protocol`.
|
|
3685
3716
|
|
|
3686
3717
|
### What to build (beyond Level 2)
|
|
3687
3718
|
|
|
@@ -3689,7 +3720,7 @@ Level 3 is full reference implementation. This is appropriate for clients that w
|
|
|
3689
3720
|
- **Merkle selective disclosure:** Verified attributes are stored as private leaves of a Merkle tree. Only the root is published on-chain. Users disclose individual leaves with inclusion proofs when required.
|
|
3690
3721
|
- **Age range proofs:** Pedersen range proofs prove that a user is within an age range without revealing their exact date of birth.
|
|
3691
3722
|
- **Professional verifier onboarding:** Verifier accounts (kind `31000` with `type: verifier`) are backed by recognised professional bodies (law societies, medical boards, notary commissions). The protocol has no central authority — trust anchors are these external bodies.
|
|
3692
|
-
- **Ring signatures and elections:** The voting extension (kinds `30482`–`30484`) uses linkable ring signatures for anonymous voting with double-spend prevention.
|
|
3723
|
+
- **Ring signatures and elections:** The voting extension (kinds `30482`–`30484`) uses linkable ring signatures for anonymous voting with double-spend prevention. It is specified in `spec/voting.md` and implemented in the separate `nostr-anon-vote` package; `signet-protocol` ships only the linkable ring signature primitives (`lsagSign`, `lsagVerify`, `computeKeyImage`).
|
|
3693
3724
|
- **Guardian delegation:** Delegation attestations (kind `31000` with `type: delegation`) with scopes (`full`, `activity-approval`, `content-management`, `contact-approval`) support family structures and guardian-linked sub-accounts.
|
|
3694
3725
|
- **Revocation and lifecycle:** Revocation attestations (kind `31000` with `type: revocation`) and credential chains using `supersedes`/`superseded-by` tags handle name changes, document renewal, and tier upgrades.
|
|
3695
3726
|
- **Identity bridging:** Identity bridge attestations (kind `31000` with `type: identity-bridge`) link verified identities across protocols or jurisdictions.
|
package/llms.txt
CHANGED
|
@@ -24,6 +24,7 @@ const events = await fetchFromRelay('wss://relay.example.com', filters);
|
|
|
24
24
|
// Compute badge (async: returns Promise<BadgeInfo>)
|
|
25
25
|
const badge = await computeBadge('<hex-pubkey>', events, { verifySignatures: true });
|
|
26
26
|
// badge.tier => 3, badge.score => 106, badge.displayLabel => "Verified (Tier 3)"
|
|
27
|
+
// (illustrative values; badge.score is a Level 1 approximation, see "Trust & Badges")
|
|
27
28
|
|
|
28
29
|
// Gate features on verification level
|
|
29
30
|
if (meetsMinimumTier(badge, 2)) {
|
|
@@ -31,6 +32,8 @@ if (meetsMinimumTier(badge, 2)) {
|
|
|
31
32
|
}
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
`verifySignatures` defaults to `false` in `computeBadge`, so keep `{ verifySignatures: true }` in copied code unless the events were already verified. `computeBadge` also does not check who issued a Tier 3 or Tier 4 credential; see "Who may issue Tier 3 and Tier 4" before gating on a professional tier.
|
|
36
|
+
|
|
34
37
|
`fetchFromRelay(relayUrl: string, filters: NostrFilter[]): Promise<NostrEvent[]>` connects, collects events until EOSE and disconnects. Non-localhost relay URLs must be `wss://`. Any other Nostr client works equally well: pass the `buildBadgeFilters` output as REQ filters and give the returned events to `computeBadge`.
|
|
35
38
|
|
|
36
39
|
## Key Concepts
|
|
@@ -48,13 +51,16 @@ Conventions used throughout:
|
|
|
48
51
|
- Of the `create*` functions documented in this file, the event-signing ones (credentials, vouches, policies, verifiers, challenges, revocations, identity bridges, guardian delegations) are async and return a signed `Promise<NostrEvent>` (with `id` and `sig`). The matching `build*Event` functions return an unsigned `UnsignedEvent`. The documented `create*` helpers that are not event signers return what is stated beside them: `createAgeRangeProof` (synchronous, `RangeProof`), `createSignetIdentity` / `createSignetIdentityFromNsec` (synchronous, `SignetIdentity`) and `createLinkageProof` (synchronous, `LinkageProof`). The package root exports other `create*` functions that this file does not cover (for example `createTwoCredentialCeremony`, `createBondProof`); check their return types in `src/` rather than assuming the event-signer shape.
|
|
49
52
|
- Tiers are `SignetTier` (`1 | 2 | 3 | 4`). Timestamps (`expiresAt`, `occurredAt`, `accountCreatedAt`) are unix seconds.
|
|
50
53
|
- Throws use the `SignetError` hierarchy (`SignetValidationError`, `SignetCryptoError`).
|
|
54
|
+
- Types are exported from the package root as type-only exports (use `import type { NostrEvent } from 'signet-protocol'`; they have no runtime value): `NostrEvent` (`{ id, pubkey, created_at, kind, tags: string[][], content, sig }`), `UnsignedEvent` (the same without `id` and `sig`), `NostrFilter` (`{ ids?, authors?, kinds?, '#d'?, '#p'?, '#L'?, '#l'?, since?, until?, limit? }`), `BadgeInfo` and `RangeProof`. The error classes `SignetError`, `SignetValidationError` and `SignetCryptoError` are exported from the root as ordinary values you can use with `instanceof`. `src/errors.ts` also defines `SignetVotingError`, but it is not exported from the root and nothing in this package throws it.
|
|
55
|
+
- Arguments named `events`, `credentials`, `vouches`, `bridges`, `revocations`, `verifierCredentials` and `confirmationEvents` are raw signed `NostrEvent[]` as returned by a relay, not parsed objects (`ParsedCredential`, `ParsedVouch` and so on). The functions read the tags themselves and skip events of the wrong kind or `type`. Unless a signature check is stated beside a function, it does not verify signatures: verify first, or fetch with `fetchFromRelay`, whose `RelayClient` drops events with an invalid signature or id by default.
|
|
56
|
+
- Issuer standing is not checked by the Level 1 and scoring functions. See "Who may issue Tier 3 and Tier 4" below.
|
|
51
57
|
|
|
52
58
|
### Credentials (kind 31000, type: credential)
|
|
53
59
|
- `createSelfDeclaredCredential(privateKey, scope = 'adult', expiresAt?)`: Tier 1. `scope` is `'adult' | 'adult+child'`.
|
|
54
60
|
- `createPeerVouchedCredential(issuerPrivateKey, subjectPubkey, opts)`: Tier 2. `opts: { assertionEventId: string; assertionRelay?: string; expiresAt?: number }`. `assertionEventId` is the subject's Tier 1 event.
|
|
55
61
|
- `createProfessionalCredential(verifierPrivateKey, subjectPubkey, opts)`: Tier 3. `opts: { assertionEventId: string; profession: string; jurisdiction: string; actualAge: number; assertionRelay?: string; expiresAt?: number; occurredAt?: number; proofBlob?: string }`. Generates an `'18+'` range proof from `actualAge` into the event content.
|
|
56
62
|
- `createChildSafetyCredential(verifierPrivateKey, subjectPubkey, opts)`: Tier 4. Same `opts` as Tier 3 plus `ageRange: string` (the range proved, e.g. `'8-12'`).
|
|
57
|
-
- `verifyCredential(event)`: `Promise<{ signatureValid: boolean; structureValid: boolean; expired: boolean; errors: string[] }
|
|
63
|
+
- `verifyCredential(event: NostrEvent)`: `Promise<{ signatureValid: boolean; structureValid: boolean; expired: boolean; errors: string[] }>`. Checks the event's own Schnorr signature, tag structure (for example a Tier 3 or Tier 4 credential must carry `verification-type: professional` and a `profession` tag) and expiry. It does not look at who signed it: a valid result does not mean the issuer is an activated, unrevoked verifier. See "Who may issue Tier 3 and Tier 4".
|
|
58
64
|
- `parseCredential(event)`: `ParsedCredential | null`
|
|
59
65
|
|
|
60
66
|
`ageRange` format everywhere: `'<min>-<max>'` inclusive (e.g. `'8-12'`, `'13-17'`) or `'<min>+'` (e.g. `'18+'`, upper bound 150). Anything else fails to parse.
|
|
@@ -63,16 +69,26 @@ Default credential expiry is two years (`DEFAULT_CREDENTIAL_EXPIRY_SECONDS`) whe
|
|
|
63
69
|
|
|
64
70
|
### Vouches (kind 31000, type: vouch)
|
|
65
71
|
- `createVouch(voucherPrivateKey, params)`: vouch for another user. `params: { subjectPubkey: string; method: 'in-person' | 'online'; voucherTier: SignetTier; voucherScore: number; context?: string; occurredAt?: number }`. `voucherScore` is the voucher's own Signet IQ (0–200).
|
|
66
|
-
- `hasEnoughVouches(vouches, subjectPubkey, threshold = 3, minVoucherTier = 2)`: `boolean`. `true` when the number of qualifying vouches (one per voucher, voucher
|
|
72
|
+
- `hasEnoughVouches(vouches: NostrEvent[], subjectPubkey: string, threshold = 3, minVoucherTier: SignetTier = 2)`: `boolean`. `vouches` is raw kind 31000 vouch events (`NostrEvent[]`), not `ParsedVouch` objects. `true` when the number of qualifying vouches (one per voucher, `voucher-tier` tag ≥ `minVoucherTier`) reaches `threshold`. It does not verify signatures, and the voucher tier is the self-reported tag, not looked up. `countQualifyingVouches(vouches: NostrEvent[], subjectPubkey: string, minVoucherTier?: SignetTier)` returns the count.
|
|
67
73
|
|
|
68
74
|
### Trust & Badges
|
|
69
|
-
- `computeBadge(pubkey, events, opts?)`: `Promise<BadgeInfo>`. `opts: { verifySignatures?: boolean (default false); now?: number }`. `BadgeInfo: { pubkey, tier, tierLabel, score, isVerified, displayLabel, credentialCount, vouchCount, entityType? }`.
|
|
70
|
-
- `buildBadgeFilters(pubkeys: string[])`: `Array<{ kinds: number[]; '#d': string[] }>`. One filter for kind 31000 with d-tags `credential:<pubkey>` and `vouch:<pubkey>` for each pubkey.
|
|
75
|
+
- `computeBadge(pubkey: string, events: NostrEvent[], opts?)`: `Promise<BadgeInfo>`. `opts: { verifySignatures?: boolean (default false); now?: number }`. `BadgeInfo: { pubkey, tier, tierLabel, score, isVerified, displayLabel, credentialCount, vouchCount, entityType? }`. `verifySignatures` defaults to `false`: with the default, `computeBadge` does not check any signature, so a call that omits it trusts every event it is given. Pass `{ verifySignatures: true }` unless the events came from `fetchFromRelay` or another source that has already dropped events with an invalid signature or id. `computeBadge` reads the `tier`, `verification-type`, `expiration`, `method` and `voucher-score` tags. It does not run `validateCredential` and does not check who signed a credential, so it does not enforce issuer standing (see "Who may issue Tier 3 and Tier 4").
|
|
76
|
+
- `buildBadgeFilters(pubkeys: string[])`: `Array<{ kinds: number[]; '#d': string[] }>`. One filter for kind 31000 with d-tags `credential:<pubkey>` and `vouch:<pubkey>` for each pubkey. It fetches no verifier, challenge or revocation events, so the events it returns cannot be used to check issuer standing.
|
|
71
77
|
- `meetsMinimumTier(badge: BadgeInfo, minTier: SignetTier)`: `boolean`. `true` only when `badge.isVerified` and `badge.tier >= minTier`.
|
|
72
|
-
- `computeTrustScore(subjectPubkey, credentials, vouches, accountCreatedAt
|
|
78
|
+
- `computeTrustScore(subjectPubkey: string, credentials: NostrEvent[], vouches: NostrEvent[], accountCreatedAt?: number, bridges?: NostrEvent[])`: `TrustScoreBreakdown` (`{ score, tier, professionalVerifications, inPersonVouches, onlineVouches, accountAgeDays, signals }`). This is the full Signet IQ. `credentials`, `vouches` and `bridges` are raw kind 31000 events (`NostrEvent[]`), not parsed objects. It does not verify signatures and, like `computeBadge`, does not check issuer standing. `bridges` are matched by `event.pubkey === subjectPubkey` and are not ring-signature verified here: run `verifyIdentityBridge` first.
|
|
73
79
|
|
|
74
80
|
`badge.score` is on the same 0–200 scale and uses the same weights as `computeTrustScore(...).score` (professional verification 80, in-person vouch up to 16, online vouch up to 4, each vouch scaled by the voucher's score ÷ 200), but it is a Level 1 approximation, not the full IQ: `computeBadge` ignores account age and identity bridges and does not apply the per-type caps (1 professional, 3 in-person vouches, 5 online vouches). Use `computeTrustScore` when you need the full IQ.
|
|
75
81
|
|
|
82
|
+
### Who may issue Tier 3 and Tier 4
|
|
83
|
+
|
|
84
|
+
The library does not enforce issuer standing in `computeBadge`, `computeTrustScore`, `verifyCredential` or `countChallengeConfirmations`. A kind 31000 `type: credential` event that carries `tier: 3` (or 4) and `verification-type: professional` counts toward the tier and the score whichever key signed it, including a key that has never published a verifier credential, and `verifySignatures: true` only proves the signer wrote the event. `buildBadgeFilters` fetches only `credential:` and `vouch:` events, so a badge built from its output cannot tell a licensed verifier from any other key. The protocol requires issuers of Tier 3 and Tier 4 to be activated, unrevoked verifiers (see `spec/protocol.md`); your client has to apply that rule before calling `computeBadge`:
|
|
85
|
+
|
|
86
|
+
1. Fetch the candidate issuer's `type: verifier` credential, the vouches for that issuer, the verifier credentials of those vouchers, and any `type: revocation` events (all kind 31000). `buildBadgeFilters` does not fetch these, so build your own filters.
|
|
87
|
+
2. Treat an issuer as acceptable only when `checkCrossVerification(issuerPubkey, vouches, verifierCredentials).activated` is `true` and `isVerifierRevoked(issuerPubkey, revocations)` is `false`.
|
|
88
|
+
3. Drop every Tier 3 and Tier 4 credential whose `event.pubkey` is not an acceptable issuer, then pass the remaining events to `computeBadge`.
|
|
89
|
+
|
|
90
|
+
`checkCrossVerification` does not check that each voucher is itself activated or unrevoked, so filter `verifierCredentials` the same way before step 2 if you need the spec's full rule.
|
|
91
|
+
|
|
76
92
|
### Policies (kind 30078, NIP-78)
|
|
77
93
|
- `createPolicy(operatorPrivateKey, params)`: community verification requirements. `params: { communityId: string; adultMinTier: SignetTier; childMinTier: SignetTier; enforcement: 'client' | 'relay' | 'both'; minScore?: number; modMinTier?: SignetTier; verifierBond?: number; revocationThreshold?: number; description?: string }`. The d-tag is `signet:policy:<communityId>`.
|
|
78
94
|
- `new PolicyChecker(policyEvent: NostrEvent)`: throws `SignetValidationError('Invalid policy event')` if the event is not a valid policy. Methods: `getPolicy(): ParsedPolicy`, `checkAdult(tier, score)`, `checkChild(tier, score)`, `checkModerator(tier, score)`. Each check returns `PolicyCheckResult`: `{ allowed: boolean; reason?: string; requiredTier: SignetTier; actualTier: SignetTier; requiredScore?: number; actualScore?: number }`.
|
|
@@ -80,20 +96,20 @@ Default credential expiry is two years (`DEFAULT_CREDENTIAL_EXPIRY_SECONDS`) whe
|
|
|
80
96
|
|
|
81
97
|
### Verifiers (kind 31000, type: verifier)
|
|
82
98
|
- `createVerifierCredential(privateKey, params)`: register as professional verifier. `params: { profession: string; jurisdiction: string; licenceHash: string; professionalBody: string; statement?: string; bondProof?: BondProof }`.
|
|
83
|
-
- `checkCrossVerification(verifierPubkey, vouches, verifierCredentials)`: activation check. Returns `{ activated: boolean; vouchCount: number; professions: string[]; errors: string[] }`.
|
|
84
|
-
- `isVerifierRevoked(verifierPubkey, revocations)`: `boolean`
|
|
99
|
+
- `checkCrossVerification(verifierPubkey: string, vouches: NostrEvent[], verifierCredentials: NostrEvent[])`: activation check. `vouches` is kind 31000 vouch events (`NostrEvent[]`) and `verifierCredentials` is kind 31000 `type: verifier` events (`NostrEvent[]`), not parsed objects. Returns `{ activated: boolean; vouchCount: number; professions: string[]; errors: string[] }`. `activated` is `true` when at least `VERIFIER_ACTIVATION.MIN_VOUCHES` vouches for `verifierPubkey` come from distinct vouchers that each appear in `verifierCredentials` with a `profession` tag, and those vouchers cover at least `VERIFIER_ACTIVATION.MIN_PROFESSIONS` different professions. It does not verify signatures, and it does not check that a voucher is itself activated or unrevoked (the spec asks for that, the function does not do it), so filter `verifierCredentials` to activated, unrevoked verifiers yourself.
|
|
100
|
+
- `isVerifierRevoked(verifierPubkey: string, revocations: NostrEvent[])`: `boolean`. `true` when any kind 31000 `type: revocation` event in `revocations` targets `verifierPubkey`. It does not verify signatures or check who signed the revocation.
|
|
85
101
|
|
|
86
102
|
### Challenges and Revocations (kind 31000, type: challenge / revocation)
|
|
87
103
|
- `createChallenge(reporterPrivateKey, params)`: `Promise<NostrEvent>`. `params: { verifierPubkey: string; reason: 'anomalous-volume' | 'registry-mismatch' | 'fraudulent-attestation' | 'licence-revoked' | 'other'; evidenceType: string; reporterTier: SignetTier; evidence: string; occurredAt?: number }`. `evidence` becomes the event content.
|
|
88
104
|
- `createRevocation(authorityPrivateKey, params)`: `Promise<NostrEvent>`. `params: { verifierPubkey: string; challengeEventId: string; confirmations: number; bondAction: 'slashed' | 'returned' | 'held'; scope: 'full' | 'partial'; effectiveAt: number; summary: string }`.
|
|
89
|
-
- `countChallengeConfirmations(challengeEventId, confirmationEvents, credentialEvents)`: `number`
|
|
105
|
+
- `countChallengeConfirmations(challengeEventId: string, confirmationEvents: NostrEvent[], credentialEvents: NostrEvent[])`: `number`. All arrays are raw `NostrEvent[]`. Counts distinct confirmation signers whose `challenge` tag equals `challengeEventId` and who hold a credential event with a `tier` tag of 3 or higher in `credentialEvents`. It does not verify signatures or the issuers of those credentials.
|
|
90
106
|
- `hasReachedRevocationThreshold(confirmations, threshold = 5)`: `boolean`
|
|
91
107
|
- `parseChallenge(event)` / `parseRevocation(event)`: parsed object or `null`.
|
|
92
108
|
|
|
93
109
|
### Identity Bridge (kind 31000, type: identity-bridge)
|
|
94
|
-
- `createIdentityBridge(anonPrivateKey, realPrivateKey, ring, signerIndex, ringMinTier, opts?)`:
|
|
95
|
-
- `selectDecoyRing(verifiedPubkeys, realPubkey, ringSize = 5)`: `{ ring: string[]; signerIndex: number }`
|
|
96
|
-
- `verifyIdentityBridge(event, opts?)`: `Promise<boolean>`. `opts: { maxAgeSeconds?: number }` (default 24 hours; `0` disables the freshness check).
|
|
110
|
+
- `createIdentityBridge(anonPrivateKey: string, realPrivateKey: string, ring: string[], signerIndex: number, ringMinTier: SignetTier, opts?)`: `Promise<NostrEvent>`. Links an anonymous account to a verified identity via ring signature. `ring: string[]` is the anonymity set of verified pubkeys (at least 5, `MIN_BRIDGE_RING_SIZE`) and must contain the public key of `realPrivateKey`. `signerIndex` is the zero-based position in `ring` of that real public key (`selectDecoyRing` returns it for you); a wrong index makes ring signing throw (`Private key does not match ring member at signerIndex`). `ringMinTier` is the lowest verification tier among the ring members, which you assert yourself: it is written to the event's `ring-min-tier` tag and scales the bridge's contribution to `computeTrustScore` (`TRUST_WEIGHTS.IDENTITY_BRIDGE` of 50 × `ringMinTier` ÷ 4). Neither `createIdentityBridge` nor `verifyIdentityBridge` checks that the ring members hold that tier. `opts: { occurredAt?: number }`. Signed by the anonymous key.
|
|
111
|
+
- `selectDecoyRing(verifiedPubkeys: string[], realPubkey: string, ringSize = 5)`: `{ ring: string[]; signerIndex: number }`. Picks decoys from `verifiedPubkeys` (`realPubkey` is filtered out of the pool if present) and places `realPubkey` at a random position. Throws if the pool has fewer than `ringSize - 1` other pubkeys.
|
|
112
|
+
- `verifyIdentityBridge(event: NostrEvent, opts?)`: `Promise<boolean>`. `opts: { maxAgeSeconds?: number }` (default 24 hours; `0` disables the freshness check).
|
|
97
113
|
|
|
98
114
|
### ZKP Age Proofs
|
|
99
115
|
- `createAgeRangeProof(age: number, ageRange: string, subjectPubkey?: string)`: `RangeProof` (synchronous). `subjectPubkey` binds the proof to one credential.
|
|
@@ -124,6 +140,8 @@ The voting extension (kinds 30482–30484) is specified in `spec/voting.md` and
|
|
|
124
140
|
|
|
125
141
|
## Optional
|
|
126
142
|
|
|
143
|
+
These paths ship in the npm package (`spec/`, `examples/`, and the `docs/` pages named below and in the experimental section above), so the relative links resolve inside `node_modules/signet-protocol/`. The examples import from the package's own `src/` directory; in your own code import from `signet-protocol`.
|
|
144
|
+
|
|
127
145
|
- [llms-full.txt](llms-full.txt): llms.txt plus the full protocol and voting specifications and the developer guides in one file
|
|
128
146
|
- [Full protocol specification](spec/protocol.md)
|
|
129
147
|
- [Voting extension specification](spec/voting.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signet-protocol",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
4
4
|
"description": "Decentralised identity verification protocol for Nostr",
|
|
5
5
|
"homepage": "https://github.com/forgesworn/signet#readme",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
21
|
"src",
|
|
22
|
+
"spec",
|
|
23
|
+
"examples",
|
|
24
|
+
"docs/signet-in-5-minutes.md",
|
|
25
|
+
"docs/implementation-levels.md",
|
|
26
|
+
"docs/private-vault-v1.md",
|
|
27
|
+
"docs/bot-ownership-v1.md",
|
|
22
28
|
"llms.txt",
|
|
23
29
|
"llms-full.txt"
|
|
24
30
|
],
|