diff options
| author | Pawel Zelawski <pawel.zelawski@outlook.com> | 2026-01-23 10:51:35 +0100 |
|---|---|---|
| committer | Pawel Zelawski <pawel.zelawski@outlook.com> | 2026-01-23 10:51:35 +0100 |
| commit | 8c32933900e3ed4aa294b6c06403bd406129d349 (patch) | |
| tree | bf64f4dac8d1de9787efb9bdf1567c992103db83 /src | |
| parent | 0b3e86989397d526d74c084828f9eb18e7749976 (diff) | |
feat: migrate from bitcoinjs-message to @noble/curves
BREAKING CHANGE: Replace bitcoinjs-message with @noble/curves for signature verification
- Remove elliptic vulnerability (all versions <= 6.6.1 affected)
- Implement Bitcoin message signing using @noble/curves and @noble/hashes
- Support for Legacy (D/S) and Bech32 (dgb1) addresses
- Update all dev dependencies to latest stable versions
- Remove unnecessary overrides for elliptic and lodash
This is a major version update due to dependency changes, though the public API remains unchanged.
Diffstat (limited to 'src')
| -rw-r--r-- | src/digiid.ts | 295 |
1 files changed, 279 insertions, 16 deletions
diff --git a/src/digiid.ts b/src/digiid.ts index 594d24a..11bc309 100644 --- a/src/digiid.ts +++ b/src/digiid.ts @@ -1,7 +1,7 @@ +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import { sha256 } from '@noble/hashes/sha256'; import { randomBytes } from 'crypto'; -// Import createRequire for CJS dependencies in ESM -// import { createRequire } from 'module'; // No longer needed for bitcoinjs-message -import * as bitcoinMessage from 'bitcoinjs-message'; import { DigiIDCallbackData, DigiIDError, @@ -10,11 +10,263 @@ import { DigiIDVerifyOptions } from './types'; -// Moved require inside the function that uses it to potentially help mocking -// and avoid top-level side effects if require itself does something complex. +/** + * 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()); +} /** - * INTERNAL: Verifies the signature using the bitcoinjs-message library. + * 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 > 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('')) + ); + + try { + const point = sig.recoverPublicKey(messageHash); + return [point.toHex(compressed)].map(hex => { + // Convert hex string to Uint8Array + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + return bytes; + }); + } catch { + throw new Error('Failed to recover public key'); + } +} + +/** + * 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) { + const computedHash = hash160(publicKey); + 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 */ @@ -27,17 +279,28 @@ export async function _internalVerifySignature( const messagePrefix = '\x19DigiByte Signed Message:\n'; try { - // bitcoinjs-message verify function - const isValidSignature = bitcoinMessage.verify( - uri, // The message that was signed (the DigiID URI) - address, // The DigiByte address (D..., S..., or dgb1...) - signature, // The signature string (Base64 encoded) - messagePrefix, // The DigiByte specific message prefix - true // Set checkSegwitAlways to true to handle all address types correctly - ); - return !!isValidSignature; // Ensure boolean return + // 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) { - // Catch potential errors from bitcoinjs-message (e.g., invalid address format, invalid signature format) const errorMessage = e instanceof Error ? e.message : String(e); throw new DigiIDError(`Signature verification failed: ${errorMessage}`); } |
