signet-protocol 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/anomaly.d.ts +42 -0
  4. package/dist/anomaly.d.ts.map +1 -0
  5. package/dist/anomaly.js +209 -0
  6. package/dist/anomaly.js.map +1 -0
  7. package/dist/badge.d.ts +56 -0
  8. package/dist/badge.d.ts.map +1 -0
  9. package/dist/badge.js +171 -0
  10. package/dist/badge.js.map +1 -0
  11. package/dist/bonds.d.ts +39 -0
  12. package/dist/bonds.d.ts.map +1 -0
  13. package/dist/bonds.js +149 -0
  14. package/dist/bonds.js.map +1 -0
  15. package/dist/challenges.d.ts +18 -0
  16. package/dist/challenges.d.ts.map +1 -0
  17. package/dist/challenges.js +145 -0
  18. package/dist/challenges.js.map +1 -0
  19. package/dist/cold-call.d.ts +74 -0
  20. package/dist/cold-call.d.ts.map +1 -0
  21. package/dist/cold-call.js +176 -0
  22. package/dist/cold-call.js.map +1 -0
  23. package/dist/compliance.d.ts +82 -0
  24. package/dist/compliance.d.ts.map +1 -0
  25. package/dist/compliance.js +478 -0
  26. package/dist/compliance.js.map +1 -0
  27. package/dist/connections.d.ts +63 -0
  28. package/dist/connections.d.ts.map +1 -0
  29. package/dist/connections.js +170 -0
  30. package/dist/connections.js.map +1 -0
  31. package/dist/constants.d.ts +86 -0
  32. package/dist/constants.d.ts.map +1 -0
  33. package/dist/constants.js +124 -0
  34. package/dist/constants.js.map +1 -0
  35. package/dist/credentials.d.ts +190 -0
  36. package/dist/credentials.d.ts.map +1 -0
  37. package/dist/credentials.js +686 -0
  38. package/dist/credentials.js.map +1 -0
  39. package/dist/crypto.d.ts +27 -0
  40. package/dist/crypto.d.ts.map +1 -0
  41. package/dist/crypto.js +75 -0
  42. package/dist/crypto.js.map +1 -0
  43. package/dist/errors.d.ts +17 -0
  44. package/dist/errors.d.ts.map +1 -0
  45. package/dist/errors.js +29 -0
  46. package/dist/errors.js.map +1 -0
  47. package/dist/i18n.d.ts +98 -0
  48. package/dist/i18n.d.ts.map +1 -0
  49. package/dist/i18n.js +1118 -0
  50. package/dist/i18n.js.map +1 -0
  51. package/dist/identity-bridge.d.ts +52 -0
  52. package/dist/identity-bridge.d.ts.map +1 -0
  53. package/dist/identity-bridge.js +228 -0
  54. package/dist/identity-bridge.js.map +1 -0
  55. package/dist/identity-tree.d.ts +47 -0
  56. package/dist/identity-tree.d.ts.map +1 -0
  57. package/dist/identity-tree.js +69 -0
  58. package/dist/identity-tree.js.map +1 -0
  59. package/dist/index.d.ts +55 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +86 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/key-derivation.d.ts +43 -0
  64. package/dist/key-derivation.d.ts.map +1 -0
  65. package/dist/key-derivation.js +212 -0
  66. package/dist/key-derivation.js.map +1 -0
  67. package/dist/lsag.d.ts +23 -0
  68. package/dist/lsag.d.ts.map +1 -0
  69. package/dist/lsag.js +35 -0
  70. package/dist/lsag.js.map +1 -0
  71. package/dist/merkle.d.ts +19 -0
  72. package/dist/merkle.d.ts.map +1 -0
  73. package/dist/merkle.js +155 -0
  74. package/dist/merkle.js.map +1 -0
  75. package/dist/policies.d.ts +22 -0
  76. package/dist/policies.d.ts.map +1 -0
  77. package/dist/policies.js +123 -0
  78. package/dist/policies.js.map +1 -0
  79. package/dist/range-proof.d.ts +6 -0
  80. package/dist/range-proof.d.ts.map +1 -0
  81. package/dist/range-proof.js +45 -0
  82. package/dist/range-proof.js.map +1 -0
  83. package/dist/relay.d.ts +106 -0
  84. package/dist/relay.d.ts.map +1 -0
  85. package/dist/relay.js +336 -0
  86. package/dist/relay.js.map +1 -0
  87. package/dist/ring-signature.d.ts +35 -0
  88. package/dist/ring-signature.d.ts.map +1 -0
  89. package/dist/ring-signature.js +56 -0
  90. package/dist/ring-signature.js.map +1 -0
  91. package/dist/shamir.d.ts +55 -0
  92. package/dist/shamir.d.ts.map +1 -0
  93. package/dist/shamir.js +253 -0
  94. package/dist/shamir.js.map +1 -0
  95. package/dist/signet-words.d.ts +42 -0
  96. package/dist/signet-words.d.ts.map +1 -0
  97. package/dist/signet-words.js +82 -0
  98. package/dist/signet-words.js.map +1 -0
  99. package/dist/store.d.ts +65 -0
  100. package/dist/store.d.ts.map +1 -0
  101. package/dist/store.js +290 -0
  102. package/dist/store.js.map +1 -0
  103. package/dist/trust-score.d.ts +9 -0
  104. package/dist/trust-score.d.ts.map +1 -0
  105. package/dist/trust-score.js +186 -0
  106. package/dist/trust-score.js.map +1 -0
  107. package/dist/types.d.ts +358 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +15 -0
  110. package/dist/types.js.map +1 -0
  111. package/dist/utils.d.ts +11 -0
  112. package/dist/utils.d.ts.map +1 -0
  113. package/dist/utils.js +21 -0
  114. package/dist/utils.js.map +1 -0
  115. package/dist/validation.d.ts +33 -0
  116. package/dist/validation.d.ts.map +1 -0
  117. package/dist/validation.js +312 -0
  118. package/dist/validation.js.map +1 -0
  119. package/dist/verifiers.d.ts +18 -0
  120. package/dist/verifiers.d.ts.map +1 -0
  121. package/dist/verifiers.js +118 -0
  122. package/dist/verifiers.js.map +1 -0
  123. package/dist/vouches.d.ts +14 -0
  124. package/dist/vouches.d.ts.map +1 -0
  125. package/dist/vouches.js +103 -0
  126. package/dist/vouches.js.map +1 -0
  127. package/package.json +76 -0
  128. package/src/anomaly.ts +307 -0
  129. package/src/badge.ts +208 -0
  130. package/src/bonds.ts +203 -0
  131. package/src/challenges.ts +187 -0
  132. package/src/cold-call.ts +238 -0
  133. package/src/compliance.ts +612 -0
  134. package/src/connections.ts +216 -0
  135. package/src/constants.ts +146 -0
  136. package/src/credentials.ts +908 -0
  137. package/src/crypto.ts +85 -0
  138. package/src/errors.ts +31 -0
  139. package/src/i18n.ts +1347 -0
  140. package/src/identity-bridge.ts +262 -0
  141. package/src/identity-tree.ts +90 -0
  142. package/src/index.ts +452 -0
  143. package/src/lsag.ts +53 -0
  144. package/src/merkle.ts +176 -0
  145. package/src/policies.ts +154 -0
  146. package/src/range-proof.ts +66 -0
  147. package/src/relay.ts +433 -0
  148. package/src/ring-signature.ts +76 -0
  149. package/src/signet-words.ts +122 -0
  150. package/src/store.ts +336 -0
  151. package/src/trust-score.ts +208 -0
  152. package/src/types.ts +482 -0
  153. package/src/utils.ts +20 -0
  154. package/src/validation.ts +391 -0
  155. package/src/verifiers.ts +156 -0
  156. package/src/vouches.ts +141 -0
@@ -0,0 +1,262 @@
1
+ // Identity Bridge (kind 31000, type: identity-bridge)
2
+ // Allows an anonymous account to cryptographically prove it is controlled by
3
+ // a verified real account, without revealing which one. Uses SAG ring signatures.
4
+ //
5
+ // Published from the anon account. Content contains a ring signature proving
6
+ // one of N verified accounts also controls this anon account.
7
+
8
+ import { createAttestation } from 'nostr-attestations';
9
+ import { parseAttestation } from 'nostr-attestations';
10
+ import { ATTESTATION_KIND, ATTESTATION_TYPES, MIN_BRIDGE_RING_SIZE, TRUST_WEIGHTS, DEFAULT_CRYPTO_ALGORITHM } from './constants.js';
11
+ import { getPublicKey, signEvent, verifyEvent } from './crypto.js';
12
+ import { ringSign, ringVerify } from './ring-signature.js';
13
+ import { getTagValue } from './validation.js';
14
+ import { randomBytes } from '@noble/hashes/utils.js';
15
+ import type { NostrEvent, SignetTier, ParsedIdentityBridge, CryptoAlgorithm } from './types.js';
16
+ import { SignetValidationError, SignetCryptoError } from './errors.js';
17
+ import type { RingSignature } from './ring-signature.js';
18
+
19
+ /** Generate a cryptographically secure random integer in [0, max) using rejection sampling */
20
+ function secureRandomInt(max: number): number {
21
+ if (!Number.isInteger(max) || !Number.isSafeInteger(max) || max <= 0) {
22
+ throw new SignetCryptoError(`secureRandomInt: max must be a positive safe integer, got ${max}`);
23
+ }
24
+ const limit = Math.floor(0x100000000 / max) * max;
25
+ let val: number;
26
+ do {
27
+ const bytes = randomBytes(4);
28
+ val = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
29
+ } while (val >= limit);
30
+ return val % max;
31
+ }
32
+
33
+ /**
34
+ * Select decoy ring members from a set of verified pubkeys.
35
+ * Inserts the real pubkey at a random position among the decoys.
36
+ *
37
+ * @param verifiedPubkeys - Pool of verified pubkeys to choose decoys from (must not include realPubkey)
38
+ * @param realPubkey - The real verified account's pubkey
39
+ * @param ringSize - Desired ring size (minimum MIN_BRIDGE_RING_SIZE)
40
+ * @returns { ring, signerIndex } — the ring array and the position of the real signer
41
+ */
42
+ export function selectDecoyRing(
43
+ verifiedPubkeys: string[],
44
+ realPubkey: string,
45
+ ringSize: number = MIN_BRIDGE_RING_SIZE
46
+ ): { ring: string[]; signerIndex: number } {
47
+ if (ringSize < MIN_BRIDGE_RING_SIZE) {
48
+ throw new SignetValidationError(`Ring size must be at least ${MIN_BRIDGE_RING_SIZE}`);
49
+ }
50
+ // Filter out the real pubkey from candidates
51
+ const candidates = verifiedPubkeys.filter((pk) => pk !== realPubkey);
52
+ const decoyCount = ringSize - 1;
53
+ if (candidates.length < decoyCount) {
54
+ throw new SignetValidationError(
55
+ `Not enough verified pubkeys for ring: need ${decoyCount} decoys, have ${candidates.length}`
56
+ );
57
+ }
58
+
59
+ // Shuffle and pick decoys (Fisher-Yates partial shuffle, CSPRNG)
60
+ const shuffled = [...candidates];
61
+ for (let i = shuffled.length - 1; i > 0; i--) {
62
+ const j = secureRandomInt(i + 1);
63
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
64
+ }
65
+ const decoys = shuffled.slice(0, decoyCount);
66
+
67
+ // Insert real pubkey at a random position (CSPRNG)
68
+ const signerIndex = secureRandomInt(ringSize);
69
+ const ring: string[] = [];
70
+ let decoyIdx = 0;
71
+ for (let i = 0; i < ringSize; i++) {
72
+ if (i === signerIndex) {
73
+ ring.push(realPubkey);
74
+ } else {
75
+ ring.push(decoys[decoyIdx++]);
76
+ }
77
+ }
78
+
79
+ return { ring, signerIndex };
80
+ }
81
+
82
+ /**
83
+ * Compute the trust weight contribution of an identity bridge based on the
84
+ * minimum tier of ring members.
85
+ */
86
+ export function computeBridgeWeight(ringMinTier: SignetTier): number {
87
+ return TRUST_WEIGHTS.IDENTITY_BRIDGE * (ringMinTier / 4);
88
+ }
89
+
90
+ /**
91
+ * Create an identity bridge event (kind 31000, type: identity-bridge).
92
+ * Published from the anonymous account, it proves the anon account owner
93
+ * also controls one of the verified accounts in the ring.
94
+ *
95
+ * @param anonPrivateKey - Private key of the anonymous account (signs the Nostr event)
96
+ * @param realPrivateKey - Private key of the real verified account (signs the ring signature)
97
+ * @param ring - Array of verified pubkeys forming the anonymity set
98
+ * @param signerIndex - Position of the real account in the ring
99
+ * @param ringMinTier - Minimum verification tier among ring members
100
+ * @returns Signed kind 31000 (type: identity-bridge) NostrEvent
101
+ */
102
+ export async function createIdentityBridge(
103
+ anonPrivateKey: string,
104
+ realPrivateKey: string,
105
+ ring: string[],
106
+ signerIndex: number,
107
+ ringMinTier: SignetTier,
108
+ opts?: { occurredAt?: number }
109
+ ): Promise<NostrEvent> {
110
+ if (ring.length < MIN_BRIDGE_RING_SIZE) {
111
+ throw new SignetValidationError(`Ring must have at least ${MIN_BRIDGE_RING_SIZE} members`);
112
+ }
113
+
114
+ const anonPubkey = getPublicKey(anonPrivateKey);
115
+ const timestamp = Math.floor(Date.now() / 1000);
116
+
117
+ // The binding message ties the anon pubkey to this timestamp
118
+ const bindingMessage = `signet:identity-bridge:${anonPubkey}:${timestamp}`;
119
+
120
+ // Ring signature: real private key signs binding message inside the ring
121
+ const ringSig = ringSign(bindingMessage, ring, signerIndex, realPrivateKey);
122
+
123
+ const contentPayload = JSON.stringify({
124
+ ringSig: {
125
+ ring: ringSig.ring,
126
+ c0: ringSig.c0,
127
+ responses: ringSig.responses,
128
+ message: ringSig.message,
129
+ domain: ringSig.domain,
130
+ },
131
+ timestamp,
132
+ });
133
+
134
+ const template = createAttestation({
135
+ type: ATTESTATION_TYPES.IDENTITY_BRIDGE,
136
+ occurredAt: opts?.occurredAt,
137
+ summary: 'Anonymous account linked to verified identity via ring signature',
138
+ tags: [
139
+ ['ring-min-tier', String(ringMinTier)],
140
+ ['ring-size', String(ring.length)],
141
+ ['algo', DEFAULT_CRYPTO_ALGORITHM],
142
+ ],
143
+ content: contentPayload,
144
+ });
145
+
146
+ const unsigned = {
147
+ ...template,
148
+ pubkey: anonPubkey,
149
+ created_at: timestamp,
150
+ };
151
+
152
+ return signEvent(unsigned, anonPrivateKey);
153
+ }
154
+
155
+ /** Default maximum age for an identity bridge event: 24 hours */
156
+ const DEFAULT_MAX_AGE_SECONDS = 24 * 60 * 60;
157
+
158
+ /**
159
+ * Verify an identity bridge event.
160
+ * Checks: Nostr signature, ring signature validity, ring size >= minimum,
161
+ * and optionally that the bridge is not too old (replay resistance).
162
+ *
163
+ * @param event - The identity bridge event to verify
164
+ * @param opts - Optional verification parameters
165
+ * @param opts.maxAgeSeconds - Maximum age of the bridge in seconds (default: 24h).
166
+ * Set to 0 to disable freshness checking.
167
+ */
168
+ export async function verifyIdentityBridge(
169
+ event: NostrEvent,
170
+ opts?: { maxAgeSeconds?: number }
171
+ ): Promise<boolean> {
172
+ // Check kind + type tag
173
+ if (event.kind !== ATTESTATION_KIND) return false;
174
+ if (getTagValue(event, 'type') !== ATTESTATION_TYPES.IDENTITY_BRIDGE) return false;
175
+
176
+ // Verify Nostr event signature
177
+ const validEvent = await verifyEvent(event);
178
+ if (!validEvent) return false;
179
+
180
+ // Parse content
181
+ let parsed: { ringSig: RingSignature; timestamp: number };
182
+ try {
183
+ const raw = JSON.parse(event.content);
184
+ if (!raw || typeof raw !== 'object' ||
185
+ !raw.ringSig || typeof raw.ringSig !== 'object' ||
186
+ !Array.isArray(raw.ringSig.ring) || typeof raw.ringSig.message !== 'string' ||
187
+ typeof raw.timestamp !== 'number') {
188
+ return false;
189
+ }
190
+ parsed = raw;
191
+ } catch {
192
+ return false;
193
+ }
194
+
195
+ // Verify ring size
196
+ const ringSize = parseInt(getTagValue(event, 'ring-size') || '0', 10);
197
+ if (ringSize < MIN_BRIDGE_RING_SIZE) return false;
198
+ if (parsed.ringSig.ring.length !== ringSize) return false;
199
+
200
+ // Verify binding message format and timestamp consistency
201
+ const expectedMessage = `signet:identity-bridge:${event.pubkey}:${parsed.timestamp}`;
202
+ if (parsed.ringSig.message !== expectedMessage) return false;
203
+
204
+ // Verify timestamp in binding message matches event created_at
205
+ if (parsed.timestamp !== event.created_at) return false;
206
+
207
+ // Freshness check: reject bridges older than maxAgeSeconds (replay resistance)
208
+ const maxAge = opts?.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS;
209
+ if (maxAge > 0) {
210
+ const now = Math.floor(Date.now() / 1000);
211
+ if (now - parsed.timestamp > maxAge) return false;
212
+ }
213
+
214
+ // Verify ring signature
215
+ return ringVerify(parsed.ringSig);
216
+ }
217
+
218
+ /**
219
+ * Parse an identity bridge event into a structured form.
220
+ */
221
+ export function parseIdentityBridge(event: NostrEvent): ParsedIdentityBridge | null {
222
+ const base = parseAttestation(event);
223
+ if (!base) return null;
224
+ if (base.type !== ATTESTATION_TYPES.IDENTITY_BRIDGE) return null;
225
+
226
+ try {
227
+ const parsed = JSON.parse(event.content);
228
+
229
+ if (!parsed || typeof parsed !== 'object' ||
230
+ !parsed.ringSig || typeof parsed.ringSig !== 'object' ||
231
+ !Array.isArray(parsed.ringSig.ring) ||
232
+ typeof parsed.ringSig.message !== 'string' ||
233
+ typeof parsed.ringSig.c0 !== 'string' ||
234
+ !Array.isArray(parsed.ringSig.responses) ||
235
+ typeof parsed.timestamp !== 'number') {
236
+ return null;
237
+ }
238
+
239
+ // Validate ring elements are strings
240
+ if (!parsed.ringSig.ring.every((r: unknown) => typeof r === 'string')) {
241
+ return null;
242
+ }
243
+
244
+ const rawMinTier = parseInt(getTagValue(event, 'ring-min-tier') || '1', 10);
245
+ const ringMinTier = (!isNaN(rawMinTier) && rawMinTier >= 1 && rawMinTier <= 4 ? rawMinTier : 1) as SignetTier;
246
+ const ringSize = parseInt(getTagValue(event, 'ring-size') || '0', 10);
247
+ if (isNaN(ringSize) || ringSize < 0 || ringSize > 1000) return null;
248
+
249
+ const algorithm = (getTagValue(event, 'algo') || DEFAULT_CRYPTO_ALGORITHM) as CryptoAlgorithm;
250
+
251
+ return {
252
+ anonPubkey: event.pubkey,
253
+ ringMinTier,
254
+ ringSize,
255
+ ring: parsed.ringSig.ring,
256
+ timestamp: parsed.timestamp,
257
+ algorithm,
258
+ };
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
@@ -0,0 +1,90 @@
1
+ import { fromMnemonic, fromNsec, zeroise, createBlindProof, createFullProof, verifyProof } from 'nsec-tree'
2
+ import { derivePersona, deriveFromPersona } from 'nsec-tree/persona'
3
+ import type { TreeRoot, Persona, Identity, LinkageProof } from 'nsec-tree'
4
+ import { SignetValidationError } from './errors.js'
5
+
6
+ /** Purpose strings for signet's two required personas. */
7
+ export const NATURAL_PERSON_PERSONA = 'natural-person'
8
+ export const ANONYMOUS_PERSONA = 'persona'
9
+
10
+ /** A signet identity backed by an nsec-tree derivation tree. */
11
+ export interface SignetIdentity {
12
+ readonly root: TreeRoot
13
+ readonly naturalPerson: Persona
14
+ readonly persona: Persona
15
+ readonly mnemonic?: string
16
+ }
17
+
18
+ function deriveRequiredPersonas(root: TreeRoot): { naturalPerson: Persona; persona: Persona } {
19
+ const naturalPerson = derivePersona(root, NATURAL_PERSON_PERSONA)
20
+ const persona = derivePersona(root, ANONYMOUS_PERSONA)
21
+ return { naturalPerson, persona }
22
+ }
23
+
24
+ /**
25
+ * Create a signet identity from a BIP-39 mnemonic.
26
+ * Derives the tree root via nsec-tree's `fromMnemonic()` (path m/44'/1237'/727'/0'/0'),
27
+ * then derives both required personas.
28
+ */
29
+ export function createSignetIdentity(mnemonic: string, passphrase?: string): SignetIdentity {
30
+ const root = fromMnemonic(mnemonic, passphrase)
31
+ const { naturalPerson, persona } = deriveRequiredPersonas(root)
32
+ return { root, naturalPerson, persona, mnemonic }
33
+ }
34
+
35
+ /**
36
+ * Create a signet identity from an existing nsec.
37
+ * Wraps the nsec through nsec-tree's HMAC separation layer,
38
+ * then derives both required personas.
39
+ */
40
+ export function createSignetIdentityFromNsec(nsec: string | Uint8Array): SignetIdentity {
41
+ const root = fromNsec(nsec)
42
+ const { naturalPerson, persona } = deriveRequiredPersonas(root)
43
+ return { root, naturalPerson, persona }
44
+ }
45
+
46
+ /**
47
+ * Derive an additional named persona from the tree root.
48
+ * Use for optional personas beyond the two required ones.
49
+ */
50
+ export function deriveAdditionalPersona(root: TreeRoot, name: string, index = 0): Persona {
51
+ if (!name) {
52
+ throw new SignetValidationError('Persona name must not be empty')
53
+ }
54
+ return derivePersona(root, name, index)
55
+ }
56
+
57
+ /**
58
+ * Derive a sub-identity within a persona (two-level hierarchy).
59
+ * Useful for group signing or isolated sub-keys under a persona.
60
+ */
61
+ export function deriveSubIdentity(persona: Persona, purpose: string, index = 0): Identity {
62
+ return deriveFromPersona(persona, purpose, index)
63
+ }
64
+
65
+ /**
66
+ * Create a linkage proof proving the tree root owns a child identity.
67
+ * Blind proofs reveal nothing about derivation; full proofs include purpose and index.
68
+ */
69
+ export function createLinkageProof(
70
+ root: TreeRoot,
71
+ child: Identity,
72
+ type: 'blind' | 'full',
73
+ ): LinkageProof {
74
+ return type === 'blind'
75
+ ? createBlindProof(root, child)
76
+ : createFullProof(root, child)
77
+ }
78
+
79
+ /** Re-export nsec-tree's proof verification. */
80
+ export const verifyLinkageProof = verifyProof
81
+
82
+ /**
83
+ * Destroy a signet identity: zeroes the tree root secret and both persona private keys.
84
+ * After calling this, the identity is unusable.
85
+ */
86
+ export function destroyIdentity(identity: SignetIdentity): void {
87
+ zeroise(identity.naturalPerson.identity)
88
+ zeroise(identity.persona.identity)
89
+ identity.root.destroy()
90
+ }