From 431497920652a37d2bcb9704a6465a7c474922eb Mon Sep 17 00:00:00 2001 From: Pawel Zelawski Date: Fri, 23 Jan 2026 12:19:10 +0100 Subject: fix: correct message hashing for signature verification - Fixed hashMessage function to not add extra length byte before message prefix - The prefix '\x19DigiByte Signed Message:\n' already contains the length indicator - Enhanced public key recovery to try all 4 recovery IDs for better compatibility - Verified with both beta.0 and beta.1 test data - All tests passing --- package-lock.json | 4 ++-- package.json | 2 +- src/digiid.ts | 66 ++++++++++++++++++++++++++++--------------------------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3fa4ba..f021d07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "digiid-ts", - "version": "2.0.0", + "version": "2.0.1-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "digiid-ts", - "version": "2.0.0", + "version": "2.0.1-beta.1", "license": "MIT", "dependencies": { "@noble/curves": "^2.0.1", diff --git a/package.json b/package.json index 1c618f8..33964d0 100644 --- a/package.json +++ b/package.json @@ -78,4 +78,4 @@ "glob": "^10.5.0", "brace-expansion": "^2.0.2" } -} \ No newline at end of file +} diff --git a/src/digiid.ts b/src/digiid.ts index c8a1eda..d413697 100644 --- a/src/digiid.ts +++ b/src/digiid.ts @@ -120,8 +120,9 @@ function convertBits(data: number[], fromBits: number, toBits: number, pad: bool * Hash message with Bitcoin/DigiByte message signing format */ function hashMessage(message: string, messagePrefix: string): Uint8Array { + // The messagePrefix already includes the length byte (e.g., '\x19DigiByte Signed Message:\n') + // where \x19 = 25 = length of "DigiByte Signed Message:\n" const prefixBuffer = new TextEncoder().encode(messagePrefix); - const prefixLength = new Uint8Array([prefixBuffer.length]); const messageBuffer = new TextEncoder().encode(message); const messageLengthBytes: number[] = []; @@ -146,13 +147,11 @@ function hashMessage(message: string, messagePrefix: string): Uint8Array { const messageLengthBuffer = new Uint8Array(messageLengthBytes); - // Concatenate: prefixLength + prefix + messageLengthBuffer + message - const totalLength = prefixLength.length + prefixBuffer.length + messageLengthBuffer.length + messageBuffer.length; + // Concatenate: prefix + messageLengthBuffer + message + const totalLength = 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); @@ -174,39 +173,41 @@ function recoverPublicKey(messageHash: Uint8Array, signature: Uint8Array): Uint8 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); + const results: Uint8Array[] = []; - 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]; + // Try all recovery IDs (0-3) to be thorough + // Some implementations may encode the recovery ID differently + for (let recId = 0; recId < 4; recId++) { + try { + // 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(recId); + + const point = sig.recoverPublicKey(messageHash); + + // Add both compressed and uncompressed versions + const compressedBytes = point.toBytes(true); + const uncompressedBytes = point.toBytes(false); + + // Add compressed first (more common for SegWit) + results.push(compressedBytes); + results.push(uncompressedBytes); + } catch (e) { + // This recovery ID didn't work, try the next one + continue; } - } catch (e) { - throw new Error('Failed to recover public key: ' + (e instanceof Error ? e.message : String(e))); } + + if (results.length === 0) { + throw new Error('Failed to recover any public keys'); + } + + return results; } /** @@ -257,6 +258,7 @@ function verifyAddress(address: string, publicKey: Uint8Array): boolean { 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; -- cgit v1.2.3