1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
|
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { ripemd160 } from '@noble/hashes/ripemd160';
import { sha256 } from '@noble/hashes/sha256';
import { randomBytes } from 'crypto';
import {
DigiIDCallbackData,
DigiIDError,
DigiIDUriOptions,
DigiIDVerificationResult,
DigiIDVerifyOptions
} from './types';
/**
* Base58 alphabet used for Bitcoin/DigiByte addresses
*/
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/**
* Decode a base58 string to bytes
*/
function base58Decode(str: string): Uint8Array {
const bytes: number[] = [0];
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (!char) continue;
const value = BASE58_ALPHABET.indexOf(char);
if (value === -1) throw new Error('Invalid base58 character');
for (let j = 0; j < bytes.length; j++) {
bytes[j]! *= 58;
}
bytes[0]! += value;
let carry = 0;
for (let j = 0; j < bytes.length; j++) {
const byte = bytes[j]!;
bytes[j] = byte + carry;
carry = bytes[j]! >> 8;
bytes[j]! &= 0xff;
}
while (carry > 0) {
bytes.push(carry & 0xff);
carry >>= 8;
}
}
// Add leading zeros
for (let i = 0; i < str.length && str[i] === '1'; i++) {
bytes.push(0);
}
return new Uint8Array(bytes.reverse());
}
/**
* Decode a bech32 address (simplified for verification purposes)
*/
function decodeBech32(address: string): { version: number; program: Uint8Array } | null {
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const lowerAddr = address.toLowerCase();
const parts = lowerAddr.split('1');
if (parts.length !== 2) return null;
const hrp = parts[0];
const data = parts[1];
if (!hrp || !data) return null;
if (hrp !== 'dgb') return null;
const values: number[] = [];
for (const char of data) {
const val = CHARSET.indexOf(char);
if (val === -1) return null;
values.push(val);
}
// Remove checksum (last 6 chars)
const payload = values.slice(0, -6);
if (payload.length < 1) return null;
const version = payload[0];
if (version === undefined) return null;
// Convert from 5-bit to 8-bit
const converted = convertBits(payload.slice(1), 5, 8, false);
if (!converted) return null;
return { version, program: new Uint8Array(converted) };
}
/**
* Convert bits between different bit groups
*/
function convertBits(data: number[], fromBits: number, toBits: number, pad: boolean): number[] | null {
let acc = 0;
let bits = 0;
const result: number[] = [];
const maxv = (1 << toBits) - 1;
for (const value of data) {
if (value < 0 || value >> fromBits !== 0) return null;
acc = (acc << fromBits) | value;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
result.push((acc >> bits) & maxv);
}
}
if (pad) {
if (bits > 0) result.push((acc << (toBits - bits)) & maxv);
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
return null;
}
return result;
}
/**
* Hash message with Bitcoin/DigiByte message signing format
*/
function hashMessage(message: string, messagePrefix: string): Uint8Array {
const prefixBuffer = new TextEncoder().encode(messagePrefix);
const prefixLength = new Uint8Array([prefixBuffer.length]);
const messageBuffer = new TextEncoder().encode(message);
const messageLengthBytes: number[] = [];
let messageLength = messageBuffer.length;
// Encode message length as variable-length integer
if (messageLength < 0xfd) {
messageLengthBytes.push(messageLength);
} else if (messageLength <= 0xffff) {
messageLengthBytes.push(0xfd, messageLength & 0xff, (messageLength >> 8) & 0xff);
} else if (messageLength <= 0xffffffff) {
messageLengthBytes.push(
0xfe,
messageLength & 0xff,
(messageLength >> 8) & 0xff,
(messageLength >> 16) & 0xff,
(messageLength >> 24) & 0xff
);
} else {
throw new Error('Message too long');
}
const messageLengthBuffer = new Uint8Array(messageLengthBytes);
// Concatenate: prefixLength + prefix + messageLengthBuffer + message
const totalLength = prefixLength.length + prefixBuffer.length + messageLengthBuffer.length + messageBuffer.length;
const combined = new Uint8Array(totalLength);
let offset = 0;
combined.set(prefixLength, offset);
offset += prefixLength.length;
combined.set(prefixBuffer, offset);
offset += prefixBuffer.length;
combined.set(messageLengthBuffer, offset);
offset += messageLengthBuffer.length;
combined.set(messageBuffer, offset);
// Double SHA256
return sha256(sha256(combined));
}
/**
* Recover public key from signature
*/
function recoverPublicKey(messageHash: Uint8Array, signature: Uint8Array): Uint8Array[] {
if (signature.length !== 65) {
throw new Error('Invalid signature length');
}
const firstByte = signature[0];
if (firstByte === undefined) throw new Error('Invalid signature');
const recoveryId = firstByte - 27;
const compressed = recoveryId >= 4;
const actualRecoveryId = recoveryId % 4;
if (actualRecoveryId < 0 || actualRecoveryId > 3) {
throw new Error('Invalid recovery ID');
}
const r = signature.slice(1, 33);
const s = signature.slice(33, 65);
// Create signature object
const sig = new secp256k1.Signature(
BigInt('0x' + Array.from(r).map(b => b.toString(16).padStart(2, '0')).join('')),
BigInt('0x' + Array.from(s).map(b => b.toString(16).padStart(2, '0')).join(''))
).addRecoveryBit(actualRecoveryId);
try {
const point = sig.recoverPublicKey(messageHash);
// Return both compressed and uncompressed versions to try both
const compressedBytes = point.toBytes(true);
const uncompressedBytes = point.toBytes(false);
// Based on the recoveryId flag, return the appropriate format(s)
if (compressed) {
return [compressedBytes];
} else {
// For uncompressed signatures, try both formats as different wallets may encode differently
return [uncompressedBytes, compressedBytes];
}
} catch (e) {
throw new Error('Failed to recover public key: ' + (e instanceof Error ? e.message : String(e)));
}
}
/**
* Hash160: RIPEMD160(SHA256(data))
*/
function hash160(buffer: Uint8Array): Uint8Array {
return ripemd160(sha256(buffer));
}
/**
* Verify address matches public key
*/
function verifyAddress(address: string, publicKey: Uint8Array): boolean {
// Legacy address (starts with D or S)
if (address.startsWith('D') || address.startsWith('S')) {
try {
const decoded = base58Decode(address);
if (decoded.length < 25) return false;
const payload = decoded.slice(0, -4);
const checksum = decoded.slice(-4);
const hash = sha256(sha256(payload));
const expectedChecksum = hash.slice(0, 4);
// Verify checksum
if (!checksum.every((byte, i) => byte === expectedChecksum[i])) {
return false;
}
const pubKeyHash = payload.slice(1);
const computedHash = hash160(publicKey);
return pubKeyHash.every((byte, i) => byte === computedHash[i]);
} catch {
return false;
}
}
// Bech32 address (starts with dgb1)
if (address.toLowerCase().startsWith('dgb1')) {
try {
const decoded = decodeBech32(address);
if (!decoded) return false;
const { version, program } = decoded;
if (version === 0) {
// For witness v0 P2WPKH, use hash160 of compressed public key
let pkToHash = publicKey;
// If uncompressed (65 bytes), convert to compressed (33 bytes)
if (publicKey.length === 65) {
const isEven = publicKey[64]! % 2 === 0;
pkToHash = new Uint8Array(33);
pkToHash[0] = isEven ? 0x02 : 0x03;
pkToHash.set(publicKey.slice(1, 33), 1);
}
const computedHash = hash160(pkToHash);
return program.every((byte, i) => byte === computedHash[i]);
}
return false;
} catch {
return false;
}
}
return false;
}
/**
* INTERNAL: Verifies the signature using @noble/curves.
* Exported primarily for testing purposes (mocking/spying).
* @internal
*/
export async function _internalVerifySignature(
uri: string,
address: string,
signature: string
): Promise<boolean> {
// DigiByte Message Prefix
const messagePrefix = '\x19DigiByte Signed Message:\n';
try {
// Decode base64 signature
const sigBytes = Uint8Array.from(atob(signature), c => c.charCodeAt(0));
if (sigBytes.length !== 65) {
throw new Error('Invalid signature length');
}
// Hash the message
const messageHash = hashMessage(uri, messagePrefix);
// Recover public key from signature
const publicKeys = recoverPublicKey(messageHash, sigBytes);
// Verify that at least one recovered public key matches the address
for (const pubKey of publicKeys) {
if (verifyAddress(address, pubKey)) {
return true;
}
}
return false;
} catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e);
throw new DigiIDError(`Signature verification failed: ${errorMessage}`);
}
}
/**
* Generates a secure random nonce (hex string).
* @param length - The number of bytes to generate (default: 16, resulting in 32 hex chars).
* @returns A hex-encoded random string.
*/
function generateNonce(length = 16): string {
return randomBytes(length).toString('hex');
}
/**
* Generates a DigiID authentication URI.
*
* @param options - Options for URI generation, including the callback URL.
* @returns The generated DigiID URI string.
* @throws {DigiIDError} If the callback URL is invalid or missing.
*/
export function generateDigiIDUri(options: DigiIDUriOptions): string {
if (!options.callbackUrl) {
throw new DigiIDError('Callback URL is required.');
}
let parsedUrl: URL;
try {
parsedUrl = new URL(options.callbackUrl);
} catch (e) {
throw new DigiIDError(`Invalid callback URL: ${(e as Error).message}`);
}
// DigiID spec requires stripping the scheme (http/https)
const domainAndPath = parsedUrl.host + parsedUrl.pathname;
const nonce = options.nonce || generateNonce();
const unsecureFlag = options.unsecure ? '1' : '0'; // 1 for http, 0 for https
// Validate scheme based on unsecure flag
if (options.unsecure && parsedUrl.protocol !== 'http:') {
throw new DigiIDError('Unsecure flag is true, but callback URL does not use http protocol.');
}
if (!options.unsecure && parsedUrl.protocol !== 'https:') {
throw new DigiIDError('Callback URL must use https protocol unless unsecure flag is set to true.');
}
// Construct the URI
// Example: digiid://example.com/callback?x=nonce_value&u=0
const uri = `digiid://${domainAndPath}?x=${nonce}&u=${unsecureFlag}`;
// Clean up potential trailing slash in path if no query params exist (though DigiID always has params)
// This check might be redundant given DigiID structure, but good practice
// const cleanedUri = uri.endsWith('/') && parsedUrl.search === '' ? uri.slice(0, -1) : uri;
return uri;
}
/**
* Verifies the signature and data received from a DigiID callback.
*
* @param callbackData - The data received from the wallet (address, uri, signature).
* @param verifyOptions - Options for verification, including the expected callback URL and nonce.
* @returns {Promise<DigiIDVerificationResult>} A promise that resolves with verification details if successful.
* @throws {DigiIDError} If validation or signature verification fails.
*/
export async function verifyDigiIDCallback(
callbackData: DigiIDCallbackData,
verifyOptions: DigiIDVerifyOptions
): Promise<DigiIDVerificationResult> {
const { address, uri, signature } = callbackData;
const { expectedCallbackUrl, expectedNonce } = verifyOptions;
if (!address || !uri || !signature) {
throw new DigiIDError('Missing required callback data: address, uri, or signature.');
}
// 1. Parse the received URI
let parsedReceivedUri: URL;
try {
// Temporarily replace digiid:// with http:// for standard URL parsing
const parsableUri = uri.replace(/^digiid:/, 'http:');
parsedReceivedUri = new URL(parsableUri);
} catch (e) {
throw new DigiIDError(`Invalid URI received in callback: ${(e as Error).message}`);
}
const receivedNonce = parsedReceivedUri.searchParams.get('x');
const receivedUnsecure = parsedReceivedUri.searchParams.get('u'); // 0 or 1
const receivedDomainAndPath = parsedReceivedUri.host + parsedReceivedUri.pathname;
if (receivedNonce === null || receivedUnsecure === null) {
throw new DigiIDError('URI missing nonce (x) or unsecure (u) parameter.');
}
// 2. Validate Callback URL
let parsedExpectedUrl: URL;
try {
// Allow expectedCallbackUrl to be a string or URL object
parsedExpectedUrl = typeof expectedCallbackUrl === 'string' ? new URL(expectedCallbackUrl) : expectedCallbackUrl;
} catch (e) {
throw new DigiIDError(`Invalid expectedCallbackUrl provided: ${(e as Error).message}`);
}
const expectedDomainAndPath = parsedExpectedUrl.host + parsedExpectedUrl.pathname;
if (receivedDomainAndPath !== expectedDomainAndPath) {
throw new DigiIDError(`Callback URL mismatch: URI contained "${receivedDomainAndPath}", expected "${expectedDomainAndPath}"`);
}
// Validate scheme consistency
const expectedScheme = parsedExpectedUrl.protocol;
if (receivedUnsecure === '1' && expectedScheme !== 'http:') {
throw new DigiIDError('URI indicates unsecure (u=1), but expectedCallbackUrl is not http.');
}
if (receivedUnsecure === '0' && expectedScheme !== 'https:') {
throw new DigiIDError('URI indicates secure (u=0), but expectedCallbackUrl is not https.');
}
// 3. Validate Nonce (optional)
if (expectedNonce && receivedNonce !== expectedNonce) {
throw new DigiIDError(`Nonce mismatch: URI contained "${receivedNonce}", expected "${expectedNonce}". Possible replay attack.`);
}
// 4. Verify Signature using internal helper
try {
const isValidSignature = await _internalVerifySignature(uri, address, signature);
if (!isValidSignature) {
// If the helper returns false, throw the standard invalid signature error
throw new DigiIDError('Invalid signature.');
}
} catch (error) {
// If _internalVerifySignature throws (e.g., due to format/checksum errors from the lib, or our re-thrown error),
// re-throw it. It should already be a DigiIDError.
if (error instanceof DigiIDError) {
throw error;
} else {
// Catch any unexpected errors and wrap them
throw new DigiIDError(`Unexpected error during signature verification: ${(error as Error).message}`);
}
}
// 5. Return successful result
return {
isValid: true,
address: address,
nonce: receivedNonce, // Return the nonce from the URI
};
}
|