From 32e25facbf4ef47732fdf16474818585d702f79f Mon Sep 17 00:00:00 2001 From: Ken Sze Date: Fri, 5 Apr 2024 11:08:42 +0800 Subject: [PATCH 1/3] Added isValidBitcoinAddress and compressPublicKey --- package-lock.json | 26 +++++++++-- package.json | 2 + src/helpers/Address.ts | 79 +++++++++++++++++++++++++++++++++ test/helpers/Address.test.ts | 86 ++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 837cdf1..3dcc691 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "bitcoinjs-lib": "^6.1.1", "bitcoinjs-message": "^2.2.0", "ecpair": "^2.1.0", + "elliptic": "^6.5.5", "fast-sha256": "^1.3.0", "secp256k1": "^5.0.0" }, "devDependencies": { "@types/chai": "^4.3.5", + "@types/elliptic": "^6.4.18", "@types/mocha": "^10.0.1", "@types/node": "^20.2.5", "@types/secp256k1": "^4.0.3", @@ -609,12 +611,30 @@ } ] }, + "node_modules/@types/bn.js": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.1.5.tgz", + "integrity": "sha512-V46N0zwKRF5Q00AZ6hWtN0T8gGmDUaUzLWQvHFo5yThtVwK/VCenFY3wXVbOvNfajEpsTfQM4IN9k/d6gUVX3A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", "dev": true }, + "node_modules/@types/elliptic": { + "version": "6.4.18", + "resolved": "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.18.tgz", + "integrity": "sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw==", + "dev": true, + "dependencies": { + "@types/bn.js": "*" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -1357,9 +1377,9 @@ "dev": true }, "node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.5.tgz", + "integrity": "sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw==", "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", diff --git a/package.json b/package.json index c429ef6..55e01da 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "license": "MIT", "devDependencies": { "@types/chai": "^4.3.5", + "@types/elliptic": "^6.4.18", "@types/mocha": "^10.0.1", "@types/node": "^20.2.5", "@types/secp256k1": "^4.0.3", @@ -39,6 +40,7 @@ "bitcoinjs-lib": "^6.1.1", "bitcoinjs-message": "^2.2.0", "ecpair": "^2.1.0", + "elliptic": "^6.5.5", "fast-sha256": "^1.3.0", "secp256k1": "^5.0.0" } diff --git a/src/helpers/Address.ts b/src/helpers/Address.ts index 7e881f3..9af0c30 100644 --- a/src/helpers/Address.ts +++ b/src/helpers/Address.ts @@ -1,4 +1,5 @@ // Import dependency +import { ec as EC } from 'elliptic'; import * as bitcoin from 'bitcoinjs-lib'; /** @@ -199,6 +200,84 @@ class Address { } } + /** + * Validates a given Bitcoin address. + * This method checks if the provided Bitcoin address is valid by attempting to decode it + * for different Bitcoin networks: mainnet, testnet, and regtest. The method uses the + * bitcoinjs-lib's address module for decoding. + * + * The process is as follows: + * 1. Attempt to decode the address for the Bitcoin mainnet. If decoding succeeds, + * the method returns true, indicating the address is valid for mainnet. + * 2. If the first step fails, catch the resulting error and attempt to decode the + * address for the Bitcoin testnet. If decoding succeeds, the method returns true, + * indicating the address is valid for testnet. + * 3. If the second step fails, catch the resulting error and attempt to decode the + * address for the Bitcoin regtest network. If decoding succeeds, the method returns + * true, indicating the address is valid for regtest. + * 4. If all attempts fail, the method returns false, indicating the address is not valid + * for any of the checked networks. + * + * @param address The Bitcoin address to validate. + * @return boolean Returns true if the address is valid for any of the Bitcoin networks, + * otherwise returns false. + */ + public static isValidBitcoinAddress(address: string): boolean { + try { + // Attempt to decode the address using bitcoinjs-lib's address module at mainnet + bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin); + return true; // If decoding succeeds, the address is valid + } + catch (error) { } + try { + // Attempt to decode the address using bitcoinjs-lib's address module at testnet + bitcoin.address.toOutputScript(address, bitcoin.networks.testnet); + return true; // If decoding succeeds, the address is valid + } + catch (error) { } + try { + // Attempt to decode the address using bitcoinjs-lib's address module at regtest + bitcoin.address.toOutputScript(address, bitcoin.networks.regtest); + return true; // If decoding succeeds, the address is valid + } + catch (error) { } + return false; // Probably not a valid address + } + + /** + * Compresses an uncompressed public key using the elliptic curve secp256k1. + * This method takes a public key in its uncompressed form and returns a compressed + * representation of the public key. Elliptic curve public keys can be represented in + * a shorter form known as compressed format which saves space and still retains the + * full public key's capabilities. The method uses the elliptic library to convert the + * uncompressed public key into its compressed form. + * + * The steps involved in the process are: + * 1. Initialize a new elliptic curve instance for the secp256k1 curve. + * 2. Create a key pair object from the uncompressed public key buffer. + * 3. Extract the compressed public key from the key pair object. + * 4. Return the compressed public key as a Buffer object. + * + * @param uncompressedPublicKey A Buffer containing the uncompressed public key. + * @return Buffer Returns a Buffer containing the compressed public key. + * @throws Error when the provided key is not a valid uncompressed public key. + */ + public static compressPublicKey(uncompressedPublicKey: Buffer): Buffer { + // Initialize elliptic curve + const ec = new EC('secp256k1'); + // Try to compress the provided public key + try { + // Create a key pair from the uncompressed public key buffer + const keyPair = ec.keyFromPublic(Buffer.from(uncompressedPublicKey)); + // Get the compressed public key as a Buffer + const compressedPublicKey = Buffer.from(keyPair.getPublic(true, 'array')); + return compressedPublicKey; + } + catch (err) { + throw new Error('Fails to compress the provided public key. Please check if the provided key is a valid uncompressed public key.'); + } + } + } export default Address; \ No newline at end of file diff --git a/test/helpers/Address.test.ts b/test/helpers/Address.test.ts index 713562e..ade067e 100644 --- a/test/helpers/Address.test.ts +++ b/test/helpers/Address.test.ts @@ -360,4 +360,90 @@ describe('Address Test', () => { }); + describe('Bitcoin Address Validation Tests', function() { + + // Test valid mainnet addresses + it('Return true for valid mainnet addresses', function() { + // Arrange + const mainnetP2PKHAddress = '1K6KoYC69NnafWJ7YgtrpwJxBLiijWqwa6'; + const mainnetP2SHAddress = '3CVQuRpFMnDV71ABpXNg9yhUpgsWL1L8y6'; + const mainnetP2WPKHAddress = 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l'; + const mainnetP2TRAddress = 'bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3'; + // Act and Assert + expect(Address.isValidBitcoinAddress(mainnetP2PKHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(mainnetP2SHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(mainnetP2WPKHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(mainnetP2TRAddress)).to.be.true; + }); + + // Test valid testnet addresses + it('Return true for valid testnet addresses', function() { + // Arrange + const testnetP2PKHAddress = 'mjSSLdHFzft9NC5NNMik7WrMQ9rRhMhNpT'; + const testnetP2SHAddress = '2MyQBsrfRnTLwEdpjVVYNWHDB8LXLJUcub9'; + const testnetP2WPKHAddress = 'tb1q9vza2e8x573nczrlzms0wvx3gsqjx7vaxwd45v'; + const testnetP2TRAddress = 'tb1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5s3g3s37'; + // Act and Assert + expect(Address.isValidBitcoinAddress(testnetP2PKHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(testnetP2SHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(testnetP2WPKHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(testnetP2TRAddress)).to.be.true; + }); + + // Test valid regtest addresses + it('Return true for valid regtest addresses', function() { + // Arrange + const regtestP2PKHAddress = 'msiGFK1PjCk8E6FXeoGkQPTscmcpyBdkgS'; + const regtestP2SHAddress = '2NEb8N5B9jhPUCBchz16BB7bkJk8VCZQjf3'; + const regtestP2WPKHAddress = 'bcrt1q39c0vrwpgfjkhasu5mfke9wnym45nydfwaeems'; + const regtestP2TRAddress = 'bcrt1pema6mzjsr3849rg5e5ls9lfck46zc3muph65rmskt28ravzzzxwsz99c2q'; + // Act and Assert + expect(Address.isValidBitcoinAddress(regtestP2PKHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(regtestP2SHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(regtestP2WPKHAddress)).to.be.true; + expect(Address.isValidBitcoinAddress(regtestP2TRAddress)).to.be.true; + }); + + // Test invalid addresses + it('Return false for invalid addresses', function() { + // Arrange + const invalidAddressMainnet = '1K6KoYC69NnafWJ7YgtrpwJxBLiijWqwa5'; // From 1K6KoYC69NnafWJ7YgtrpwJxBLiijWqwa6 + const invalidAddressMainnetTwo = 'bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt2'; // From bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3 + const invalidAddressTesnet = '2MyQBsrfRnTLwEdpjVVYNWHDB8LXLJUcub1'; // From 2MyQBsrfRnTLwEdpjVVYNWHDB8LXLJUcub9 + const invalidAddressRegtest = 'bcrt1q39c0vrwpgfjkhasu5mfke9wnym45nydfwaeema'; // From bcrt1q39c0vrwpgfjkhasu5mfke9wnym45nydfwaeems + // Act and Assert + expect(Address.isValidBitcoinAddress(invalidAddressMainnet)).to.be.false; + expect(Address.isValidBitcoinAddress(invalidAddressMainnetTwo)).to.be.false; + expect(Address.isValidBitcoinAddress(invalidAddressTesnet)).to.be.false; + expect(Address.isValidBitcoinAddress(invalidAddressRegtest)).to.be.false; + }); + + }); + + describe('Public Key Cmpression Function', function() { + + it('Compress a uncompressed public key', function() { + // Arrange + const uncompressedPublicKey = Buffer.from('044bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599f04edf9e6ea7e0796a176fba3957560f307e4c49cb2a46b4969e710f5933e700e', 'hex'); + const compressedPublicKey = Buffer.from('024bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599f', 'hex'); + // Act + const compressed = Address.compressPublicKey(uncompressedPublicKey); + // Assert + expect(compressed).to.deep.equal(compressedPublicKey); + }); + + it('Throw with invalid uncompressed public key', function() { + // Arrange + const notUncompressedPublicKey = Buffer.from('024bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599f', 'hex'); + const notUncompressedPublicKeyAsWell = Buffer.from('044bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599f04edf9e6ea7e0796a176fba3957560f307e4c49cb2a46b4969e710f5933e700f', 'hex'); + // Act + const compressAttempt = Address.compressPublicKey.bind(notUncompressedPublicKey); + const compressAttemptTwo = Address.compressPublicKey.bind(notUncompressedPublicKeyAsWell); + // Assert + expect(compressAttempt).to.throws('Fails to compress the provided public key. Please check if the provided key is a valid uncompressed public key.'); + expect(compressAttemptTwo).to.throws('Fails to compress the provided public key. Please check if the provided key is a valid uncompressed public key.'); + }); + + }); + }); \ No newline at end of file From 38b50db68a188efc5de27291f10305ea83f3ea3a Mon Sep 17 00:00:00 2001 From: Ken Sze Date: Fri, 5 Apr 2024 11:43:49 +0800 Subject: [PATCH 2/3] Added uncompressPublicKey --- src/helpers/Address.ts | 39 +++++++++++++++++++++++++++++++++++- test/helpers/Address.test.ts | 22 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/helpers/Address.ts b/src/helpers/Address.ts index 9af0c30..2838393 100644 --- a/src/helpers/Address.ts +++ b/src/helpers/Address.ts @@ -260,7 +260,8 @@ class Address { * * @param uncompressedPublicKey A Buffer containing the uncompressed public key. * @return Buffer Returns a Buffer containing the compressed public key. - * @throws Error when the provided key is not a valid uncompressed public key. + * @throws Error Throws an error if the provided public key cannot be compressed, + * typically indicating that the key is not valid. */ public static compressPublicKey(uncompressedPublicKey: Buffer): Buffer { // Initialize elliptic curve @@ -278,6 +279,42 @@ class Address { } } + /** + * Uncompresses a given public key using the elliptic curve secp256k1. + * This method accepts a compressed public key and attempts to convert it into its + * uncompressed form. Public keys are often compressed to save space, but certain + * operations require the full uncompressed key. This method uses the elliptic + * library to perform the conversion. + * + * The function operates as follows: + * 1. Initialize a new elliptic curve instance using secp256k1. + * 2. Attempt to create a key pair from the compressed public key buffer. + * 3. Extract the uncompressed public key from the key pair object. + * 4. Return the uncompressed public key as a Buffer object. + * If the compressed public key provided is invalid and cannot be uncompressed, + * the method will throw an error with a descriptive message. + * + * @param compressedPublicKey A Buffer containing the compressed public key. + * @return Buffer The uncompressed public key as a Buffer. + * @throws Error Throws an error if the provided public key cannot be uncompressed, + * typically indicating that the key is not valid. + */ + public static uncompressPublicKey(compressedPublicKey: Buffer): Buffer { + // Initialize elliptic curve + const ec = new EC('secp256k1'); + // Try to compress the provided public key + try { + // Create a key pair from the compressed public key buffer + const keyPair = ec.keyFromPublic(Buffer.from(compressedPublicKey)); + // Get the compressed public key as a Buffer + const uncompressedPublicKey = Buffer.from(keyPair.getPublic(false, 'array')); + return uncompressedPublicKey; + } + catch (err) { + throw new Error('Fails to uncompress the provided public key. Please check if the provided key is a valid compressed public key.'); + } + } + } export default Address; \ No newline at end of file diff --git a/test/helpers/Address.test.ts b/test/helpers/Address.test.ts index ade067e..874ed33 100644 --- a/test/helpers/Address.test.ts +++ b/test/helpers/Address.test.ts @@ -444,6 +444,28 @@ describe('Address Test', () => { expect(compressAttemptTwo).to.throws('Fails to compress the provided public key. Please check if the provided key is a valid uncompressed public key.'); }); + it('Uncompress a compressed public key', function() { + // Arrange + const uncompressedPublicKey = Buffer.from('044bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599f04edf9e6ea7e0796a176fba3957560f307e4c49cb2a46b4969e710f5933e700e', 'hex'); + const compressedPublicKey = Buffer.from('024bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599f', 'hex'); + // Act + const uncompressed = Address.uncompressPublicKey(compressedPublicKey); + // Assert + expect(uncompressed).to.deep.equal(uncompressedPublicKey); + }); + + it('Throw with invalid compressed public key', function() { + // Arrange + const notCompressedPublicKey = Buffer.from('044bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599f04edf9e6ea7e0796a176fba3957560f307e4c49cb2a46b4969e710f5933e700f', 'hex'); + const notCompressedPublicKeyAsWell = Buffer.from('024bc3c1746b7f526b560517a61f2fad554c24d6a457503e4ec7e69f817f68599e', 'hex'); + // Act + const uncompressAttempt = Address.uncompressPublicKey.bind(notCompressedPublicKey); + const uncompressAttemptTwo = Address.uncompressPublicKey.bind(notCompressedPublicKeyAsWell); + // Assert + expect(uncompressAttempt).to.throws('Fails to uncompress the provided public key. Please check if the provided key is a valid compressed public key.'); + expect(uncompressAttemptTwo).to.throws('Fails to uncompress the provided public key. Please check if the provided key is a valid compressed public key.'); + }); + }); }); \ No newline at end of file From 9ff43b5b62575b708d76cc6f54459c9322258004 Mon Sep 17 00:00:00 2001 From: Ken Sze Date: Fri, 5 Apr 2024 11:56:14 +0800 Subject: [PATCH 3/3] Fix issue #7 caused by address type in BIP-137 header --- src/Verifier.ts | 122 ++++++++++++++++++++++++--------- test/Verifier.test.ts | 153 ++++++++++++++++++++++++++++-------------- 2 files changed, 193 insertions(+), 82 deletions(-) diff --git a/src/Verifier.ts b/src/Verifier.ts index 13e7a33..e8ff73c 100644 --- a/src/Verifier.ts +++ b/src/Verifier.ts @@ -21,6 +21,10 @@ class Verifier { * @throws If the provided signature fails basic validation, or if unsupported address and signature are provided */ public static verifySignature(signerAddress: string, message: string, signatureBase64: string) { + // Check whether the given signerAddress is valid + if (!Address.isValidBitcoinAddress(signerAddress)) { + throw new Error("Invalid Bitcoin address is provided."); + } // Handle legacy BIP-137 signature // For P2PKH address, assume the signature is also a legacy signature if (Address.isP2PKH(signerAddress) || BIP137.isBIP137Signature(signatureBase64)) { @@ -121,44 +125,98 @@ class Verifier { * @throws If the provided signature fails basic validation, or if unsupported address and signature are provided */ private static verifyBIP137Signature(signerAddress: string, message: string, signatureBase64: string) { - if (Address.isP2PKH(signerAddress)) { - return bitcoinMessage.verify(message, signerAddress, signatureBase64); + // Recover the public key associated with the signature + const publicKeySignedRaw = BIP137.derivePubKey(message, signatureBase64); + // Compress and uncompress the public key if necessary + let publicKeySignedUncompressed: Buffer; + let publicKeySigned: Buffer; + if (publicKeySignedRaw.byteLength === 65) { + publicKeySignedUncompressed = publicKeySignedRaw; // The key recovered is an uncompressed key + publicKeySigned = Address.compressPublicKey(publicKeySignedRaw); } else { - // Recover the public key associated with the signature - const publicKeySigned = BIP137.derivePubKey(message, signatureBase64); - // Set the equivalent legacy address to prepare for validation from bitcoinjs-message - const legacySigningAddress = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2pkh').mainnet; - // Make sure that public key recovered corresponds to the claimed signing address - if (Address.isP2SH(signerAddress)) { - // Assume it is a P2SH-P2WPKH address, derive a P2SH-P2WPKH address based on the public key recovered - const p2shAddressDerived = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2sh-p2wpkh'); - // Assert that the derived address is identical to the claimed signing address - if (p2shAddressDerived.mainnet !== signerAddress && p2shAddressDerived.testnet !== signerAddress) { - return false; // Derived address did not match with the claimed signing address - } + publicKeySignedUncompressed = Address.uncompressPublicKey(publicKeySignedRaw); + publicKeySigned = publicKeySignedRaw; // The key recovered is a compressed key + } + // Obtain the equivalent signing address in all address types (except taproot) to prepare for validation from bitcoinjs-message + // Taproot address is not needed since technically BIP-137 signatures does not support taproot address + const p2pkhSigningAddressUncompressed = Address.convertPubKeyIntoAddress(publicKeySignedUncompressed, 'p2pkh').mainnet; + const p2pkhSigningAddressCompressed = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2pkh').mainnet; + const p2shSigningAddress = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2sh-p2wpkh').mainnet; + const p2wpkhSigningAddress = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2wpkh').mainnet; + // Make sure that public key recovered corresponds to the claimed signing address + if (Address.isP2PKH(signerAddress)) { + // Derive P2PKH address from both the uncompressed raw public key, and the compressed public key + const p2pkhAddressDerivedUncompressed = Address.convertPubKeyIntoAddress(publicKeySignedUncompressed, 'p2pkh'); + const p2pkhAddressDerivedCompressed = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2pkh'); + // Assert that the derived address is identical to the claimed signing address + if ( + p2pkhAddressDerivedUncompressed.mainnet !== signerAddress && p2pkhAddressDerivedUncompressed.testnet !== signerAddress && + p2pkhAddressDerivedCompressed.mainnet !== signerAddress && p2pkhAddressDerivedCompressed.testnet !== signerAddress + ) { + return false; // Derived address did not match with the claimed signing address } - else if (Address.isP2WPKH(signerAddress)) { - // Assume it is a P2WPKH address, derive a P2WPKH address based on the public key recovered - const p2wpkhAddressDerived = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2wpkh'); - // Assert that the derived address is identical to the claimed signing address - if (p2wpkhAddressDerived.mainnet !== signerAddress && p2wpkhAddressDerived.testnet !== signerAddress) { - return false; // Derived address did not match with the claimed signing address - } + } + else if (Address.isP2SH(signerAddress)) { + // Assume it is a P2SH-P2WPKH address, derive a P2SH-P2WPKH address based on the public key recovered + const p2shAddressDerived = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2sh-p2wpkh'); + // Assert that the derived address is identical to the claimed signing address + if (p2shAddressDerived.mainnet !== signerAddress && p2shAddressDerived.testnet !== signerAddress) { + return false; // Derived address did not match with the claimed signing address } - else if (Address.isP2TR(signerAddress)) { - // Assume it is a P2TR address, derive a P2TR address based on the public key recovered - const p2trAddressDerived = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2tr'); - // Assert that the derived address is identical to the claimed signing address - if (p2trAddressDerived.mainnet !== signerAddress && p2trAddressDerived.testnet !== signerAddress) { - return false; // Derived address did not match with the claimed signing address - } + } + else if (Address.isP2WPKH(signerAddress)) { + // Assume it is a P2WPKH address, derive a P2WPKH address based on the public key recovered + const p2wpkhAddressDerived = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2wpkh'); + // Assert that the derived address is identical to the claimed signing address + if (p2wpkhAddressDerived.mainnet !== signerAddress && p2wpkhAddressDerived.testnet !== signerAddress) { + return false; // Derived address did not match with the claimed signing address } - else { - return false; // Unsupported address type + } + else { + // Assume it is a P2TR address, derive a P2TR address based on the public key recovered + const p2trAddressDerived = Address.convertPubKeyIntoAddress(publicKeySigned, 'p2tr'); + // Assert that the derived address is identical to the claimed signing address + if (p2trAddressDerived.mainnet !== signerAddress && p2trAddressDerived.testnet !== signerAddress) { + return false; // Derived address did not match with the claimed signing address } - // Validate the signature using bitcoinjs-message if address assertion succeeded - return bitcoinMessage.verify(message, legacySigningAddress, signatureBase64); + } + // Validate the signature using bitcoinjs-message if address assertion succeeded + // Accept the signature if it originates from any address derivable from the public key + const validity = ( + this.bitcoinMessageVerifyWrap(message, p2pkhSigningAddressUncompressed, signatureBase64) || + this.bitcoinMessageVerifyWrap(message, p2pkhSigningAddressCompressed, signatureBase64) || + this.bitcoinMessageVerifyWrap(message, p2shSigningAddress, signatureBase64) || + this.bitcoinMessageVerifyWrap(message, p2wpkhSigningAddress, signatureBase64) + ); + return validity; + } + + /** + * Wraps the Bitcoin message verification process to avoid throwing exceptions. + * This method attempts to verify a BIP-137 message using the provided address and + * signature. It encapsulates the verification process within a try-catch block, + * catching any errors that occur during verification and returning false instead + * of allowing the exception to propagate. + * + * The process is as follows: + * 1. The `bitcoinjs-message.verify` function is called with the message, address, + * and signature provided in Base64 encoding. + * 2. If the verification is successful, the method returns true. + * 3. If any error occurs during the verification, the method catches the error + * and returns false, signaling an unsuccessful verification. + * + * @param message The Bitcoin message to be verified. + * @param address The Bitcoin address to which the message is allegedly signed. + * @param signatureBase64 The Base64 encoded signature corresponding to the message. + * @return boolean Returns true if the message is successfully verified, otherwise false. + */ + private static bitcoinMessageVerifyWrap(message: string, address: string, signatureBase64: string) { + try { + return bitcoinMessage.verify(message, address, signatureBase64); + } + catch (err) { + return false; // Instead of throwing, just return false } } diff --git a/test/Verifier.test.ts b/test/Verifier.test.ts index 2d20101..cc6741c 100644 --- a/test/Verifier.test.ts +++ b/test/Verifier.test.ts @@ -9,6 +9,44 @@ import ecc from '@bitcoinerlab/secp256k1'; // Import module to be tested import { Verifier } from '../src'; +/** + * Given a valid BIP-137 signature, generate compatible valid signatures with different header flag. + * + * In BIP-137 signature scheme, only the recId, which is a number from 0 - 3 inclusive, is a part of the + * signature. The header is composed of the recId and a constant which indicates address type. + * + * Based on this library's concept of "proving that you own a private key, proves that you own all associated address", + * this function is used to generate compatible signatures with different address type regardless of the address type + * indicated in the BIP-137 signature. + * + * @param {string} base64Signature - The base64 signature string to be modified. + * @param {number} start - The start of the decimal range for the header constant, defaults to 27 for P2PKH uncompressed address. + * @param {number} end - The end of the decimal range for the header constant, defaults to 42 for Segwit Bech32 address. + * @returns {string[]} An array of compatible base64 encoded signatures. + */ +function generateCompatibleSignatures(base64Signature: string, start: number = 27, end: number = 42): string[] { + // Decode the base64 string to a Buffer + let buffer = Buffer.from(base64Signature, 'base64'); + // Array for storing compatible signatures + const modifiedBase64Strings: string[] = []; + // Obtain the initial recId in the given signature + const initialRecId = buffer[0]; + // Loop through the range and replace the first byte + for (let i = start; i <= end; i++) { + // Only recId with a difference of the multiple of 4 is compatible + // For example, if initialRecId is 32, then only 28, 36, 40 are compatible + if (Math.abs(i - initialRecId) % 4 == 0) { + // Copy the buffer to avoid modifying the original data + let modifiedBuffer = Buffer.from(buffer); + // Replace the first byte with the current value in the range + modifiedBuffer[0] = i; + // Encode the modified buffer back to a base64 string and add to the result array + modifiedBase64Strings.push(modifiedBuffer.toString('base64')); + } + } + return modifiedBase64Strings; +} + // Tests describe('Verifier Test', () => { @@ -43,7 +81,13 @@ describe('Verifier Test', () => { it('Can verify legacy BIP-137 signature from P2SH-P2WPKH, P2WPKH, and P2TR address', () => { // Arrange const message = 'Hello World'; - const signature = 'IAtVrymJqo43BCt9f7Dhl6ET4Gg3SmhyvdlW6wn9iWc9PweD7tNM5+qw7xE9/bzlw/Et789AQ2F59YKEnSzQudo='; // Signed by public key "02f7fb07050d858b3289c2a0305fbac1f5b18233798665c0cbfe133e018b57cafc" + // Signed by public key "02f7fb07050d858b3289c2a0305fbac1f5b18233798665c0cbfe133e018b57cafc" using header 32 + const signatureBase = 'IAtVrymJqo43BCt9f7Dhl6ET4Gg3SmhyvdlW6wn9iWc9PweD7tNM5+qw7xE9/bzlw/Et789AQ2F59YKEnSzQudo='; + const signatures = generateCompatibleSignatures(signatureBase, 27, 42); // Range of header range that is in-spec with BIP-137 + expect(signatures).to.include(signatureBase, "Error in generating signatures with different flags!"); + // Addresses derived from uncompressed public key "04f7fb07050d858b3289c2a0305fbac1f5b18233798665c0cbfe133e018b57cafc96668ad9ba5d1b2e9db47ecd5e2b484f9b955740dcabbe61d886d0ee6f5dc1b8" + const p2pkhMainnetValidUncompress = "1Nji71ru2dMty4CPGNoXsqoU4byok8toqm"; + const p2pkhTestnetValidUncompress = "n3FfQ4wsqeo9kAfzywmuhm1nvbaWc4TpvC"; // Addresses derived from public key "02f7fb07050d858b3289c2a0305fbac1f5b18233798665c0cbfe133e018b57cafc" const p2pkhMainnetValid = "1QDZfWJTVXqHFmJFRkyrnidvHyPyG5bynY"; const p2pkhTestnetValid = "n4jWxZPSJZGY2sms9KxEcdrF9xzgEbrHHj"; @@ -62,53 +106,52 @@ describe('Verifier Test', () => { const p2wpkhTestnetInvalid = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"; const p2trMainnetInvalid = "bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3"; const p2trTestnetInvalid = "tb1p000273lqsqqfw2a6h2vqxr2tll4wgtv7zu8a30rz4mhree8q5jzq8cjtyp"; - // Invalid address - const invalidAddress = "bc1apv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3"; - const invalidAddressTestnet = "tb1a000273lqsqqfw2a6h2vqxr2tll4wgtv7zu8a30rz4mhree8q5jzq8cjtyp"; // Act - const p2pkhMainnetValidResult = Verifier.verifySignature(p2pkhMainnetValid, message, signature); - const p2pkhTestnetValidResult = Verifier.verifySignature(p2pkhTestnetValid, message, signature); - const p2shMainnetValidResult = Verifier.verifySignature(p2shMainnetValid, message, signature); - const p2shTestnetValidResult = Verifier.verifySignature(p2shTestnetValid, message, signature); - const p2wpkhMainnetValidResult = Verifier.verifySignature(p2wpkhMainnetValid, message, signature); - const p2wpkhTestnetValidResult = Verifier.verifySignature(p2wpkhTestnetValid, message, signature); - const p2trMainnetValidResult = Verifier.verifySignature(p2trMainnetValid, message, signature); - const p2trTestnetValidResult = Verifier.verifySignature(p2trTestnetValid, message, signature); - - const p2pkhMainnetInvalidResult = Verifier.verifySignature(p2pkhMainnetInvalid, message, signature); - const p2pkhTestnetInvalidResult = Verifier.verifySignature(p2pkhTestnetInvalid, message, signature); - const p2shMainnetInvalidResult = Verifier.verifySignature(p2shMainnetInvalid, message, signature); - const p2shTestnetInvalidResult = Verifier.verifySignature(p2shTestnetInvalid, message, signature); - const p2wpkhMainnetInvalidResult = Verifier.verifySignature(p2wpkhMainnetInvalid, message, signature); - const p2wpkhTestnetInvalidResult = Verifier.verifySignature(p2wpkhTestnetInvalid, message, signature); - const p2trMainnetInvalidResult = Verifier.verifySignature(p2trMainnetInvalid, message, signature); - const p2trTestnetInvalidResult = Verifier.verifySignature(p2trTestnetInvalid, message, signature); - - const invalidAddressResult = Verifier.verifySignature(invalidAddress, message, signature); - const invalidAddressTestnetResult = Verifier.verifySignature(invalidAddressTestnet, message, signature); - - // Assert - expect(p2pkhMainnetValidResult).to.be.true; - expect(p2pkhTestnetValidResult).to.be.true; - expect(p2shMainnetValidResult).to.be.true; - expect(p2shTestnetValidResult).to.be.true; - expect(p2wpkhMainnetValidResult).to.be.true; - expect(p2wpkhTestnetValidResult).to.be.true; - expect(p2trMainnetValidResult).to.be.true; - expect(p2trTestnetValidResult).to.be.true; - - expect(p2pkhMainnetInvalidResult).to.be.false; - expect(p2pkhTestnetInvalidResult).to.be.false; - expect(p2shMainnetInvalidResult).to.be.false; - expect(p2shTestnetInvalidResult).to.be.false; - expect(p2wpkhMainnetInvalidResult).to.be.false; - expect(p2wpkhTestnetInvalidResult).to.be.false; - expect(p2trMainnetInvalidResult).to.be.false; - expect(p2trTestnetInvalidResult).to.be.false; - - expect(invalidAddressResult).to.be.false; - expect(invalidAddressTestnetResult).to.be.false; + for (let signature of signatures) { + + const p2pkhMainnetValidUncompressResult = Verifier.verifySignature(p2pkhMainnetValidUncompress, message, signature); + const p2pkhTestnetValidUncompressResult = Verifier.verifySignature(p2pkhTestnetValidUncompress, message, signature); + const p2pkhMainnetValidResult = Verifier.verifySignature(p2pkhMainnetValid, message, signature); + const p2pkhTestnetValidResult = Verifier.verifySignature(p2pkhTestnetValid, message, signature); + const p2shMainnetValidResult = Verifier.verifySignature(p2shMainnetValid, message, signature); + const p2shTestnetValidResult = Verifier.verifySignature(p2shTestnetValid, message, signature); + const p2wpkhMainnetValidResult = Verifier.verifySignature(p2wpkhMainnetValid, message, signature); + const p2wpkhTestnetValidResult = Verifier.verifySignature(p2wpkhTestnetValid, message, signature); + const p2trMainnetValidResult = Verifier.verifySignature(p2trMainnetValid, message, signature); + const p2trTestnetValidResult = Verifier.verifySignature(p2trTestnetValid, message, signature); + + const p2pkhMainnetInvalidResult = Verifier.verifySignature(p2pkhMainnetInvalid, message, signature); + const p2pkhTestnetInvalidResult = Verifier.verifySignature(p2pkhTestnetInvalid, message, signature); + const p2shMainnetInvalidResult = Verifier.verifySignature(p2shMainnetInvalid, message, signature); + const p2shTestnetInvalidResult = Verifier.verifySignature(p2shTestnetInvalid, message, signature); + const p2wpkhMainnetInvalidResult = Verifier.verifySignature(p2wpkhMainnetInvalid, message, signature); + const p2wpkhTestnetInvalidResult = Verifier.verifySignature(p2wpkhTestnetInvalid, message, signature); + const p2trMainnetInvalidResult = Verifier.verifySignature(p2trMainnetInvalid, message, signature); + const p2trTestnetInvalidResult = Verifier.verifySignature(p2trTestnetInvalid, message, signature); + + // Assert + expect(p2pkhMainnetValidUncompressResult).to.be.true; + expect(p2pkhTestnetValidUncompressResult).to.be.true; + expect(p2pkhMainnetValidResult).to.be.true; + expect(p2pkhTestnetValidResult).to.be.true; + expect(p2shMainnetValidResult).to.be.true; + expect(p2shTestnetValidResult).to.be.true; + expect(p2wpkhMainnetValidResult).to.be.true; + expect(p2wpkhTestnetValidResult).to.be.true; + expect(p2trMainnetValidResult).to.be.true; + expect(p2trTestnetValidResult).to.be.true; + + expect(p2pkhMainnetInvalidResult).to.be.false; + expect(p2pkhTestnetInvalidResult).to.be.false; + expect(p2shMainnetInvalidResult).to.be.false; + expect(p2shTestnetInvalidResult).to.be.false; + expect(p2wpkhMainnetInvalidResult).to.be.false; + expect(p2wpkhTestnetInvalidResult).to.be.false; + expect(p2trMainnetInvalidResult).to.be.false; + expect(p2trTestnetInvalidResult).to.be.false; + + } }); it('Can verify and falsify BIP-322 signature for P2SH-P2WPKH address', () => { @@ -331,10 +374,10 @@ describe('Verifier Test', () => { const resultP2Tr = Verifier.verifySignature.bind(Verifier, malformedP2TR, message, signatureP2TR); // Assert - expect(resultP2PKH).to.throw(); // Throw by bitcoinjs-message library - expect(resultP2WPKHInP2SH).to.throw(); // Throw by bitcoinjs-lib - expect(resultP2WPKH).to.throws(); // Throw by helper/Address - expect(resultP2Tr).to.throws(); // Throw by helper/Address + expect(resultP2PKH).to.throw("Invalid Bitcoin address is provided."); + expect(resultP2WPKHInP2SH).to.throw("Invalid Bitcoin address is provided."); + expect(resultP2WPKH).to.throws("Invalid Bitcoin address is provided."); + expect(resultP2Tr).to.throws("Invalid Bitcoin address is provided."); }); it('Reject Schnorr signature with incorrect length', () => { @@ -420,4 +463,14 @@ describe('Verifier Test', () => { expect(resultSingle).to.throws('Invalid SIGHASH used in signature. Must be either SIGHASH_ALL or SIGHASH_DEFAULT.'); }); + it('Fix issue #7', () => { + // Arrange + const address = "3Agx7m86mJgVbLZP3Wk1qjYkzv6gGemz9X"; + const message = "48656c6c6f20426974636f696e2034352e3133302e3130352e313436"; + const signature = "JDkLNaM8vWoobA34PGQE9FIZaLF7peRh4r7DOqOHls1cP1DPwR3Hcy26+zk6yRb0qtJRHEdUflVxkScbwsOCSMw="; + + // Act and Assert + expect(Verifier.verifySignature(address, message, signature)).to.be.true; + }); + }); \ No newline at end of file