@notabene/verify-proof 1.4.2 → 1.8.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/src/solana.ts CHANGED
@@ -1,12 +1,44 @@
1
1
  import nacl from "tweetnacl";
2
- import { ProofStatus, SignatureProof } from "@notabene/javascript-sdk";
2
+ import { ProofStatus, SignatureProof, type SIWXInput, type SolanaMetadata } from "@notabene/javascript-sdk";
3
3
  import { base64, base58 } from "@scure/base";
4
4
 
5
+ interface ParsedSIWSMessage {
6
+ domain: string;
7
+ address: string;
8
+ statement?: string;
9
+ uri?: string;
10
+ version?: string;
11
+ chainId?: string;
12
+ nonce?: string;
13
+ issuedAt?: string;
14
+ expirationTime?: string;
15
+ notBefore?: string;
16
+ requestId?: string;
17
+ resources?: string[];
18
+ }
19
+
20
+
21
+
22
+ /**
23
+ * Verifies a Solana signature proof.
24
+ *
25
+ * This function can verify two types of Solana signatures:
26
+ * 1. Standard Solana signatures
27
+ *
28
+ * @param proof - The signature proof containing the address, attestation, and signature
29
+ * @returns Promise that resolves to a SignatureProof with updated status (VERIFIED or FAILED)
30
+ *
31
+ * @example
32
+ * // Standard Solana signature verification
33
+ * const result = await verifySolanaSignature(proof);
34
+ *
35
+ */
5
36
  export async function verifySolanaSignature(
6
37
  proof: SignatureProof,
7
38
  ): Promise<SignatureProof> {
8
39
  const [ns, , address] = proof.address.split(/:/);
9
40
  if (ns !== "solana") return { ...proof, status: ProofStatus.FAILED };
41
+
10
42
  try {
11
43
  const publicKey = base58.decode(address);
12
44
  const messageBytes = new TextEncoder().encode(proof.attestation);
@@ -21,8 +53,454 @@ export async function verifySolanaSignature(
21
53
  ...proof,
22
54
  status: verified ? ProofStatus.VERIFIED : ProofStatus.FAILED,
23
55
  };
24
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
25
- } catch (error) {
56
+ } catch {
57
+ return { ...proof, status: ProofStatus.FAILED };
58
+ }
59
+ }
60
+
61
+ function isSolanaSignInInput(obj: unknown): obj is SIWXInput {
62
+ if (!obj || typeof obj !== 'object') return false;
63
+
64
+ const input = obj as Record<string, unknown>;
65
+
66
+ // Check for required properties
67
+ return (
68
+ typeof input.domain === 'string' &&
69
+ typeof input.address === 'string' &&
70
+ (input.statement === undefined || typeof input.statement === 'string') &&
71
+ (input.uri === undefined || typeof input.uri === 'string') &&
72
+ (input.version === undefined || typeof input.version === 'string') &&
73
+ (input.chainId === undefined || typeof input.chainId === 'string') &&
74
+ (input.nonce === undefined || typeof input.nonce === 'string') &&
75
+ (input.issuedAt === undefined || typeof input.issuedAt === 'string') &&
76
+ (input.expirationTime === undefined || typeof input.expirationTime === 'string') &&
77
+ (input.notBefore === undefined || typeof input.notBefore === 'string') &&
78
+ (input.requestId === undefined || typeof input.requestId === 'string') &&
79
+ (input.resources === undefined || Array.isArray(input.resources))
80
+ );
81
+ }
82
+
83
+ function isSolanaSignInMetadata(obj: unknown): obj is SolanaMetadata {
84
+ if (!obj || typeof obj !== 'object') return false;
85
+
86
+ const metadata = obj as Record<string, unknown>;
87
+
88
+ // Check account object
89
+ if (!metadata.account || typeof metadata.account !== 'object') return false;
90
+ const account = metadata.account as Record<string, unknown>;
91
+ if (typeof account.address !== 'string') return false;
92
+
93
+ // Handle publicKey - could be Uint8Array or serialized object with numeric keys
94
+ if (!account.publicKey) return false;
95
+ if (!(account.publicKey instanceof Uint8Array)) {
96
+ // Try to convert from serialized format
97
+ const pkObj = account.publicKey as Record<string, unknown>;
98
+ if (typeof pkObj === 'object') {
99
+ // Convert object with numeric keys to Uint8Array
100
+ const keys = Object.keys(pkObj).filter(key => !isNaN(Number(key))).sort((a, b) => Number(a) - Number(b));
101
+ if (keys.length === 32) { // Solana public keys are 32 bytes
102
+ const bytes = keys.map(key => Number(pkObj[key]));
103
+ if (bytes.every(b => typeof b === 'number' && b >= 0 && b <= 255)) {
104
+ account.publicKey = new Uint8Array(bytes);
105
+ } else {
106
+ return false;
107
+ }
108
+ } else {
109
+ return false;
110
+ }
111
+ } else {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ // Handle signedMessage - could be Uint8Array or Buffer-like object
117
+ if (!metadata.signedMessage) return false;
118
+ if (!(metadata.signedMessage instanceof Uint8Array)) {
119
+ const smObj = metadata.signedMessage as Record<string, unknown>;
120
+ if (smObj.type === 'Buffer' && Array.isArray(smObj.data)) {
121
+ metadata.signedMessage = new Uint8Array(smObj.data as number[]);
122
+ } else {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ // Handle signature - could be Uint8Array or Buffer-like object
128
+ if (!metadata.signature) return false;
129
+ if (!(metadata.signature instanceof Uint8Array)) {
130
+ const sigObj = metadata.signature as Record<string, unknown>;
131
+ if (sigObj.type === 'Buffer' && Array.isArray(sigObj.data)) {
132
+ metadata.signature = new Uint8Array(sigObj.data as number[]);
133
+ } else {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ // Check message field contains valid SolanaSignInInput
139
+ if (!metadata.message || typeof metadata.message !== 'object') {
140
+ return false;
141
+ }
142
+
143
+ const message = metadata.message as unknown;
144
+
145
+ // If address is missing from message, try to extract it from signedMessage
146
+ if (typeof message === 'object' && message !== null) {
147
+ const messageObj = message as Record<string, unknown>;
148
+ if (!messageObj.address && metadata.signedMessage instanceof Uint8Array) {
149
+ try {
150
+ const signedMessageText = new TextDecoder().decode(metadata.signedMessage);
151
+ const lines = signedMessageText.split('\n');
152
+ if (lines.length >= 2) {
153
+ const address = lines[1].trim();
154
+ if (address && /^[a-zA-Z0-9]{32,44}$/.test(address)) {
155
+ messageObj.address = address;
156
+ }
157
+ }
158
+ } catch {
159
+ // Ignore errors in address extraction
160
+ }
161
+ }
162
+ }
163
+
164
+ if (!isSolanaSignInInput(metadata.message)) {
165
+ return false;
166
+ }
167
+
168
+ return true;
169
+ }
170
+
171
+ export async function verifySolanaSIWS(
172
+ proof: SignatureProof,
173
+ ): Promise<SignatureProof> {
174
+ const [ns] = proof.address.split(/:/);
175
+ if (ns !== "solana") {
176
+ return { ...proof, status: ProofStatus.FAILED };
177
+ }
178
+
179
+ // Validate that metadata conforms to SolanaSignInMetadata
180
+ if (!proof.chainSpecificData || !isSolanaSignInMetadata(proof.chainSpecificData)) {
181
+ return { ...proof, status: ProofStatus.FAILED };
182
+ }
183
+
184
+ try {
185
+ // Now we can safely cast to SolanaMetadata since we validated it
186
+ const metadata = proof.chainSpecificData as SolanaMetadata;
187
+
188
+ const signedMessageText = new TextDecoder().decode(metadata.signedMessage);
189
+ const parsedMessage = parseSIWSMessage(signedMessageText);
190
+
191
+ if (!parsedMessage) {
192
+ return { ...proof, status: ProofStatus.FAILED };
193
+ }
194
+
195
+ // Validate the parsed message against the input
196
+ if (!validateSIWSMessage(parsedMessage, metadata.message as SIWXInput)) {
197
+ return { ...proof, status: ProofStatus.FAILED };
198
+ }
199
+
200
+ // Reconstruct the message to ensure it matches the signed message
201
+ const reconstructedMessage = createSIWSMessage(parsedMessage);
202
+ if (reconstructedMessage !== signedMessageText) {
203
+ return { ...proof, status: ProofStatus.FAILED };
204
+ }
205
+
206
+ // Verify the signature against the message
207
+ const verified = nacl.sign.detached.verify(
208
+ metadata.signedMessage,
209
+ metadata.signature,
210
+ metadata.account.publicKey as Uint8Array
211
+ );
212
+
213
+ return {
214
+ ...proof,
215
+ status: verified ? ProofStatus.VERIFIED : ProofStatus.FAILED,
216
+ };
217
+ } catch {
26
218
  return { ...proof, status: ProofStatus.FAILED };
27
219
  }
28
220
  }
221
+
222
+ // Parse SIWS message according to ABNF format
223
+ // https://github.com/phantom/sign-in-with-solana/blob/e4060d2916469116d5080a712feaf81ea1db4f65/README.md#message-construction
224
+ function parseSIWSMessage(message: string): ParsedSIWSMessage | null {
225
+ try {
226
+ const lines = message.split('\n');
227
+
228
+ // Parse header (domain and address)
229
+ const header = parseHeader(lines);
230
+ if (!header) return null;
231
+
232
+ const result: ParsedSIWSMessage = { ...header };
233
+
234
+ let lineIndex = 2;
235
+
236
+ // Parse statement if present
237
+ const statementResult = parseStatement(lines, lineIndex);
238
+ if (statementResult.statement !== undefined) {
239
+ result.statement = statementResult.statement;
240
+ lineIndex = statementResult.nextIndex;
241
+ }
242
+
243
+ // Parse advanced fields
244
+ const advancedFields = parseAdvancedFields(lines, lineIndex);
245
+ Object.assign(result, advancedFields);
246
+
247
+ return result;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ function parseHeader(lines: string[]): { domain: string; address: string } | null {
254
+ // First line: domain + " wants you to sign in with your Solana account:"
255
+ const domainMatch = lines[0]?.match(/^(.+) wants you to sign in with your Solana account:$/);
256
+ if (!domainMatch) return null;
257
+
258
+ const domain = domainMatch[1];
259
+
260
+ // Second line: address
261
+ const address = lines[1];
262
+ if (!address || !/^[a-zA-Z0-9]{32,44}$/.test(address)) return null;
263
+
264
+ return { domain, address };
265
+ }
266
+
267
+ function parseStatement(lines: string[], startIndex: number): { statement?: string; nextIndex: number } {
268
+ let lineIndex = startIndex;
269
+
270
+ // Check for statement (after empty line)
271
+ if (lines[lineIndex] === '' && lines[lineIndex + 1] && !lines[lineIndex + 1].includes(':')) {
272
+ lineIndex++; // Skip empty line
273
+ const statement = lines[lineIndex];
274
+ lineIndex++;
275
+
276
+ // Skip another empty line after statement
277
+ if (lines[lineIndex] === '') {
278
+ lineIndex++;
279
+ }
280
+
281
+ return { statement, nextIndex: lineIndex };
282
+ }
283
+
284
+ return { nextIndex: lineIndex };
285
+ }
286
+
287
+ function parseAdvancedFields(lines: string[], startIndex: number): Partial<ParsedSIWSMessage> {
288
+ const result: Partial<ParsedSIWSMessage> = {};
289
+
290
+ // Define field parsers for string fields only
291
+ const fieldParsers: Array<{
292
+ prefix: string;
293
+ key: keyof Omit<ParsedSIWSMessage, 'resources'>;
294
+ }> = [
295
+ { prefix: 'URI: ', key: 'uri' },
296
+ { prefix: 'Version: ', key: 'version' },
297
+ { prefix: 'Chain ID: ', key: 'chainId' },
298
+ { prefix: 'Nonce: ', key: 'nonce' },
299
+ { prefix: 'Issued At: ', key: 'issuedAt' },
300
+ { prefix: 'Expiration Time: ', key: 'expirationTime' },
301
+ { prefix: 'Not Before: ', key: 'notBefore' },
302
+ { prefix: 'Request ID: ', key: 'requestId' }
303
+ ];
304
+
305
+ let lineIndex = startIndex;
306
+
307
+ while (lineIndex < lines.length) {
308
+ const line = lines[lineIndex];
309
+ if (!line) {
310
+ lineIndex++;
311
+ continue;
312
+ }
313
+
314
+ // Check for resources (special case)
315
+ if (line.startsWith('Resources:')) {
316
+ const resources = parseResources(lines, lineIndex + 1);
317
+ if (resources.length > 0) {
318
+ result.resources = resources;
319
+ lineIndex += resources.length + 1; // +1 for the "Resources:" line
320
+ continue;
321
+ }
322
+ }
323
+
324
+ // Check for other fields
325
+ let fieldFound = false;
326
+ for (const { prefix, key } of fieldParsers) {
327
+ if (line.startsWith(prefix)) {
328
+ const value = line.substring(prefix.length);
329
+ result[key] = value;
330
+ fieldFound = true;
331
+ break;
332
+ }
333
+ }
334
+
335
+ if (!fieldFound) {
336
+ // Unknown field, skip it
337
+ }
338
+
339
+ lineIndex++;
340
+ }
341
+
342
+ return result;
343
+ }
344
+
345
+ function parseResources(lines: string[], startIndex: number): string[] {
346
+ const resources: string[] = [];
347
+ let lineIndex = startIndex;
348
+
349
+ while (lineIndex < lines.length && lines[lineIndex]?.startsWith('- ')) {
350
+ resources.push(lines[lineIndex].substring(2));
351
+ lineIndex++;
352
+ }
353
+
354
+ return resources;
355
+ }
356
+
357
+ // Validate parsed SIWS message against input
358
+ function validateSIWSMessage(parsed: ParsedSIWSMessage, input: SIWXInput): boolean {
359
+ // Required fields validation
360
+ if (parsed.domain !== input.domain || parsed.address !== input.address) {
361
+ return false;
362
+ }
363
+
364
+ // Define validation rules for optional fields
365
+ const fieldValidations: Array<{
366
+ inputKey: keyof SIWXInput;
367
+ parsedKey: keyof ParsedSIWSMessage;
368
+ validator?: (inputValue: unknown, parsedValue: unknown) => boolean;
369
+ }> = [
370
+ { inputKey: 'statement', parsedKey: 'statement' },
371
+ { inputKey: 'uri', parsedKey: 'uri' },
372
+ { inputKey: 'version', parsedKey: 'version' },
373
+ { inputKey: 'chainId', parsedKey: 'chainId' },
374
+ { inputKey: 'nonce', parsedKey: 'nonce' },
375
+ { inputKey: 'issuedAt', parsedKey: 'issuedAt' },
376
+ { inputKey: 'expirationTime', parsedKey: 'expirationTime' },
377
+ { inputKey: 'notBefore', parsedKey: 'notBefore' },
378
+ { inputKey: 'requestId', parsedKey: 'requestId' },
379
+ {
380
+ inputKey: 'resources',
381
+ parsedKey: 'resources',
382
+ validator: (inputValue, parsedValue) => {
383
+ if (!Array.isArray(inputValue) || !Array.isArray(parsedValue)) {
384
+ return false;
385
+ }
386
+ return inputValue.length === parsedValue.length &&
387
+ inputValue.every((item, index) => item === parsedValue[index]);
388
+ }
389
+ }
390
+ ];
391
+
392
+ // Validate optional fields
393
+ for (const { inputKey, parsedKey, validator } of fieldValidations) {
394
+ const inputValue = input[inputKey];
395
+ const parsedValue = parsed[parsedKey];
396
+
397
+ if (inputValue !== undefined) {
398
+ if (validator) {
399
+ if (!validator(inputValue, parsedValue)) {
400
+ return false;
401
+ }
402
+ } else if (inputValue !== parsedValue) {
403
+ return false;
404
+ }
405
+ }
406
+ }
407
+
408
+ // Validate timestamps
409
+ return validateTimestamps(parsed);
410
+ }
411
+
412
+ // Separate timestamp validation for better testability and clarity
413
+ function validateTimestamps(parsed: ParsedSIWSMessage): boolean {
414
+ const now = Date.now();
415
+
416
+ // Validate issuedAt (allow 24 hour threshold for testing)
417
+ if (parsed.issuedAt) {
418
+ const issuedAt = new Date(parsed.issuedAt);
419
+ const threshold = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
420
+ const timeDiff = Math.abs(issuedAt.getTime() - now);
421
+ if (timeDiff > threshold) {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ // Validate expirationTime
427
+ if (parsed.expirationTime) {
428
+ const expirationTime = new Date(parsed.expirationTime);
429
+ if (expirationTime.getTime() <= now) {
430
+ return false; // Message has expired
431
+ }
432
+ }
433
+
434
+ // Validate notBefore
435
+ if (parsed.notBefore) {
436
+ const notBefore = new Date(parsed.notBefore);
437
+ if (notBefore.getTime() > now) {
438
+ return false; // Message not yet valid
439
+ }
440
+ }
441
+
442
+ return true;
443
+ }
444
+
445
+ // Create SIWS message string according to ABNF format
446
+ // https://github.com/phantom/sign-in-with-solana/blob/e4060d2916469116d5080a712feaf81ea1db4f65/README.md#abnf-message-format
447
+ function createSIWSMessage(input: ParsedSIWSMessage): string {
448
+ let message = `${input.domain} wants you to sign in with your Solana account:\n`;
449
+ message += `${input.address}`;
450
+
451
+ if (input.statement) {
452
+ message += `\n\n${input.statement}`;
453
+ }
454
+
455
+ const fields = buildFieldLines(input);
456
+
457
+ if (fields.length) {
458
+ message += `\n\n${fields.join('\n')}`;
459
+ }
460
+
461
+ return message;
462
+ }
463
+
464
+ function buildFieldLines(input: ParsedSIWSMessage): string[] {
465
+ const fields: string[] = [];
466
+
467
+ // Define field mappings
468
+ const fieldMappings: Array<{
469
+ key: keyof ParsedSIWSMessage;
470
+ prefix: string;
471
+ formatter?: (value: unknown) => string[];
472
+ }> = [
473
+ { key: 'uri', prefix: 'URI: ' },
474
+ { key: 'version', prefix: 'Version: ' },
475
+ { key: 'chainId', prefix: 'Chain ID: ' },
476
+ { key: 'nonce', prefix: 'Nonce: ' },
477
+ { key: 'issuedAt', prefix: 'Issued At: ' },
478
+ { key: 'expirationTime', prefix: 'Expiration Time: ' },
479
+ { key: 'notBefore', prefix: 'Not Before: ' },
480
+ { key: 'requestId', prefix: 'Request ID: ' },
481
+ {
482
+ key: 'resources',
483
+ prefix: 'Resources:',
484
+ formatter: (value) => {
485
+ if (Array.isArray(value) && value.length > 0) {
486
+ return ['Resources:', ...value.map(resource => `- ${resource}`)];
487
+ }
488
+ return [];
489
+ }
490
+ }
491
+ ];
492
+
493
+ for (const { key, prefix, formatter } of fieldMappings) {
494
+ const value = input[key];
495
+ if (value !== undefined) {
496
+ if (formatter) {
497
+ const formatted = formatter(value);
498
+ fields.push(...formatted);
499
+ } else if (typeof value === 'string') {
500
+ fields.push(`${prefix}${value}`);
501
+ }
502
+ }
503
+ }
504
+
505
+ return fields;
506
+ }
@@ -78,6 +78,16 @@ const dogeProof: SignatureProof = {
78
78
  wallet_provider: "Doge",
79
79
  };
80
80
 
81
+ const zcashProof: SignatureProof = {
82
+ type: ProofTypes.BIP137,
83
+ address: "bip122:000000000019d6689c085ae165831e93:t1NmCz1oRS3e84NrUHsbHPJnz8a6KgZvbHL",
84
+ did: "did:pkh:bip122:000000000019d6689c085ae165831e93:t1NmCz1oRS3e84NrUHsbHPJnz8a6KgZvbHL",
85
+ attestation: "I certify that the blockchain address t1NmCz1oRS3e84NrUHsbHPJnz8a6KgZvbHL belongs to did:pkh:bip122:000000000019d6689c085ae165831e93:t1NmCz1oRS3e84NrUHsbHPJnz8a6KgZvbHL on Mon, 30 Jun 2025 08:41:29 GMT",
86
+ proof: "HzPW+q7EXixhtV/rBQ0sOtoXCDOml7C6P6rtIxLkBy3tenP978po7tuwj997DvJRiakL65Qw7xADK5hHs1Vu7es=",
87
+ status: ProofStatus.PENDING,
88
+ wallet_provider: "Manual Wallet Signature",
89
+ };
90
+
81
91
  describe("verifyBTCSignature", () => {
82
92
  it("handles bip322 segwit testnet addresses", async () => {
83
93
  const result = await verifyBTCSignature(bip322SegwitTestnetProof);
@@ -109,6 +119,11 @@ describe("verifyBTCSignature", () => {
109
119
  expect(result).toEqual({ ...dogeProof, status: ProofStatus.VERIFIED });
110
120
  });
111
121
 
122
+ it("verifies zcash address signature", async () => {
123
+ const result = await verifyBTCSignature(zcashProof);
124
+ expect(result).toEqual({ ...zcashProof, status: ProofStatus.VERIFIED });
125
+ });
126
+
112
127
  it("fails for invalid bitcoin signature", async () => {
113
128
  const proof: SignatureProof = { ...legacyProof, proof: "invalitd" };
114
129