From 4aaf91505cbb63650d97e5739f162585a5aa439a Mon Sep 17 00:00:00 2001 From: blackmoshui Date: Mon, 4 Nov 2024 20:54:14 +0800 Subject: [PATCH] Add accout.signRaw function to sign a message without prefix (#7346) * workable code and test * refine comments and docs * add parallel test back * using default value instead of ? * update changelog * update changelog * remove wrong doc * update package changelog * Update CHANGELOG.md --- CHANGELOG.md | 6 +- docs/docs/guides/03_wallet/index.md | 1 + packages/web3-eth-accounts/CHANGELOG.md | 4 + packages/web3-eth-accounts/src/account.ts | 47 ++++++++++- .../test/fixtures/account.ts | 83 +++++++++++++++++++ .../test/integration/account.test.ts | 34 ++++++++ .../test/unit/account.test.ts | 34 ++++++++ .../test/unit/account_dom.test.ts | 34 ++++++++ 8 files changed, 239 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c279326d17a..d96a9e23283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2772,6 +2772,10 @@ If there are any bugs, improvements, optimizations or any new feature proposal f ### Added +#### web3-eth-accounts + +- `hashMessage` now has a new optional param `skipPrefix` with a default value of `false`. A new function `signRaw` was added to sign a message without prefix. (#7346) + #### web3-rpc-providers -- PublicNodeProvider was added (#7322) \ No newline at end of file +- PublicNodeProvider was added (#7322) diff --git a/docs/docs/guides/03_wallet/index.md b/docs/docs/guides/03_wallet/index.md index d93ba737a50..5a8c7e270a5 100644 --- a/docs/docs/guides/03_wallet/index.md +++ b/docs/docs/guides/03_wallet/index.md @@ -90,6 +90,7 @@ The following is a list of [`Accounts`](/libdocs/Accounts) methods in the `web3. - [recover](/libdocs/Accounts#recover) - [recoverTransaction](/libdocs/Accounts#recovertransaction) - [sign](/libdocs/Accounts#sign) +- [signRaw](/libdocs/Accounts#signraw) - [signTransaction](/libdocs/Accounts#signtransaction) ## Wallets diff --git a/packages/web3-eth-accounts/CHANGELOG.md b/packages/web3-eth-accounts/CHANGELOG.md index dc08d0cc692..22498dca080 100644 --- a/packages/web3-eth-accounts/CHANGELOG.md +++ b/packages/web3-eth-accounts/CHANGELOG.md @@ -184,3 +184,7 @@ Documentation: - Revert `TransactionFactory.registerTransactionType` if there is a version mistatch between `web3-eth` and `web3-eth-accounts` and fix nextjs problem. (#7216) ## [Unreleased] + +### Added + +- `hashMessage` now has a new optional param `skipPrefix` with a default value of `false`. A new function `signRaw` was added to sign a message without prefix. (#7346) diff --git a/packages/web3-eth-accounts/src/account.ts b/packages/web3-eth-accounts/src/account.ts index e928a95f53f..511ebcc92d2 100644 --- a/packages/web3-eth-accounts/src/account.ts +++ b/packages/web3-eth-accounts/src/account.ts @@ -144,6 +144,7 @@ export const parseAndValidatePrivateKey = (data: Bytes, ignoreLength?: boolean): * `"\x19Ethereum Signed Message:\n" + message.length + message` and hashed using keccak256. * * @param message - A message to hash, if its HEX it will be UTF8 decoded. + * @param skipPrefix - (default: false) If true, the message will be not prefixed with "\x19Ethereum Signed Message:\n" + message.length * @returns The hashed message * * ```ts @@ -154,9 +155,13 @@ export const parseAndValidatePrivateKey = (data: Bytes, ignoreLength?: boolean): * web3.eth.accounts.hashMessage(web3.utils.utf8ToHex("Hello world")) // Will be hex decoded in hashMessage * * > "0x8144a6fa26be252b86456491fbcd43c1de7e022241845ffea1c3df066f7cfede" + * + * web3.eth.accounts.hashMessage("Hello world", true) + * + * > "0xed6c11b0b5b808960df26f5bfc471d04c1995b0ffd2055925ad1be28d6baadfd" * ``` */ -export const hashMessage = (message: string): string => { +export const hashMessage = (message: string, skipPrefix = false): string => { const messageHex = isHexStrict(message) ? message : utf8ToHex(message); const messageBytes = hexToBytes(messageHex); @@ -165,7 +170,7 @@ export const hashMessage = (message: string): string => { fromUtf8(`\x19Ethereum Signed Message:\n${messageBytes.byteLength}`), ); - const ethMessage = uint8ArrayConcat(preamble, messageBytes); + const ethMessage = skipPrefix ? messageBytes : uint8ArrayConcat(preamble, messageBytes); return sha3Raw(ethMessage); // using keccak in web3-utils.sha3Raw instead of SHA3 (NIST Standard) as both are different }; @@ -230,6 +235,42 @@ export const sign = (data: string, privateKey: Bytes): SignResult => { }; }; +/** + * Signs raw data with a given private key without adding the Ethereum-specific prefix. + * + * @param data - The raw data to sign. If it's a hex string, it will be used as-is. Otherwise, it will be UTF-8 encoded. + * @param privateKey - The 32 byte private key to sign with + * @returns The signature Object containing the message, messageHash, signature r, s, v + * + * ```ts + * web3.eth.accounts.signRaw('Some data', '0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318') + * > { + * message: 'Some data', + * messageHash: '0x43a26051362b8040b289abe93334a5e3662751aa691185ae9e9a2e1e0c169350', + * v: '0x1b', + * r: '0x93da7e2ddd6b2ff1f5af0c752f052ed0d7d5bff19257db547a69cd9a879b37d4', + * s: '0x334485e42b33815fd2cf8a245a5393b282214060844a9681495df2257140e75c', + * signature: '0x93da7e2ddd6b2ff1f5af0c752f052ed0d7d5bff19257db547a69cd9a879b37d4334485e42b33815fd2cf8a245a5393b282214060844a9681495df2257140e75c1b' + * } + * ``` + */ +export const signRaw = (data: string, privateKey: Bytes): SignResult => { + // Hash the message without the Ethereum-specific prefix + const hash = hashMessage(data, true); + + // Sign the hash with the private key + const { messageHash, v, r, s, signature } = signMessageWithPrivateKey(hash, privateKey); + + return { + message: data, + messageHash, + v, + r, + s, + signature, + }; +}; + /** * Signs an Ethereum transaction with a given private key. * @@ -380,7 +421,7 @@ export const recoverTransaction = (rawTransaction: HexString): Address => { * @param signatureOrV - signature or V * @param prefixedOrR - prefixed or R * @param s - S value in signature - * @param prefixed - (default: false) If the last parameter is true, the given message will NOT automatically be prefixed with `"\\x19Ethereum Signed Message:\\n" + message.length + message`, and assumed to be already prefixed. + * @param prefixed - (default: false) If the last parameter is true, the given message will NOT automatically be prefixed with `"\\x19Ethereum Signed Message:\\n" + message.length + message`, and assumed to be already prefixed and hashed. * @returns The Ethereum address used to sign this data * * ```ts diff --git a/packages/web3-eth-accounts/test/fixtures/account.ts b/packages/web3-eth-accounts/test/fixtures/account.ts index 08c6a19ef54..0ccb52c9bec 100644 --- a/packages/web3-eth-accounts/test/fixtures/account.ts +++ b/packages/web3-eth-accounts/test/fixtures/account.ts @@ -164,6 +164,79 @@ export const signatureRecoverData: [string, any][] = [ ], ]; +export const signatureRecoverWithoutPrefixData: [string, any][] = [ + [ + 'Some long text with integers 1233 and special characters and unicode \u1234 as well.', + { + prefixedOrR: true, + r: '0x66ff35193d5763bbb86428b87cd10451704fa1d00a8831e75cc0eca16701521d', + s: '0x5ec294b63778e854929a53825191222415bf93871d091a137f61d92f2f3d37bb', + address: '0x6E599DA0bfF7A6598AC1224E4985430Bf16458a4', + privateKey: '0xcb89ec4b01771c6c8272f4c0aafba2f8ee0b101afb22273b786939a8af7c1912', + data: 'Some long text with integers 1233 and special characters and unicode \u1234 as well.', + // signature done with personal_sign + signatureOrV: + '0x66ff35193d5763bbb86428b87cd10451704fa1d00a8831e75cc0eca16701521d5ec294b63778e854929a53825191222415bf93871d091a137f61d92f2f3d37bb1b', + }, + ], + [ + 'Some data', + { + prefixedOrR: true, + r: '0xbbae52f4cd6776e66e01673228474866cead8ccc9530e0ae06b42d0f5917865f', + s: '0x170e7a9e792288955e884c9b2da7d2c69b69d3b29e24372d1dec1164a7deaec0', + address: '0xEB014f8c8B418Db6b45774c326A0E64C78914dC0', + privateKey: '0xbe6383dad004f233317e46ddb46ad31b16064d14447a95cc1d8c8d4bc61c3728', + data: 'Some data', + // signature done with personal_sign + signatureOrV: + '0xbbae52f4cd6776e66e01673228474866cead8ccc9530e0ae06b42d0f5917865f170e7a9e792288955e884c9b2da7d2c69b69d3b29e24372d1dec1164a7deaec01c', + }, + ], + [ + 'Some data!%$$%&@*', + { + prefixedOrR: true, + r: '0x91b3ccd107995becaca361e9f282723176181bb9250e8ebb8a5119f5e0b91978', + s: '0x5e67773c632e036712befe130577d2954b91f7c5fb4999bc94d80d471dfd468b', + address: '0xEB014f8c8B418Db6b45774c326A0E64C78914dC0', + privateKey: '0xbe6383dad004f233317e46ddb46ad31b16064d14447a95cc1d8c8d4bc61c3728', + data: 'Some data!%$$%&@*', + // signature done with personal_sign + signatureOrV: + '0x91b3ccd107995becaca361e9f282723176181bb9250e8ebb8a5119f5e0b919785e67773c632e036712befe130577d2954b91f7c5fb4999bc94d80d471dfd468b1c', + }, + ], + [ + '102', + { + prefixedOrR: true, + r: '0xecbd18fc2919bef2a9371536df0fbabdb09fda9823b15c5ce816ab71d7b5e359', + s: '0x3860327ffde34fe72ae5d6abdcdc91e984f936ea478cfb8b1547383d6e4d6a98', + address: '0xEB014f8c8B418Db6b45774c326A0E64C78914dC0', + privateKey: '0xbe6383dad004f233317e46ddb46ad31b16064d14447a95cc1d8c8d4bc61c3728', + data: '102', + // signature done with personal_sign + signatureOrV: + '0xecbd18fc2919bef2a9371536df0fbabdb09fda9823b15c5ce816ab71d7b5e3593860327ffde34fe72ae5d6abdcdc91e984f936ea478cfb8b1547383d6e4d6a981b', + }, + ], + [ + // testcase for recover(data, V, R, S) + 'some data', + { + signatureOrV: '0x1b', + prefixedOrR: '0x48f828a3ed107ce28551a3264d75b18df806d6960c273396dc022baadd0cf26e', + r: '0x48f828a3ed107ce28551a3264d75b18df806d6960c273396dc022baadd0cf26e', + s: '0x373e1b6709512c2dab9dff4066c6b40d32bd747bdb84469023952bc82123e8cc', + address: '0x54BF9ed7F22b64a5D69Beea57cFCd378763bcdc5', + privateKey: '0x03a0021a87dc354855f900fd15c063bcc9c155c33b8f2321ec294e0933ef29d2', + signature: + '0x48f828a3ed107ce28551a3264d75b18df806d6960c273396dc022baadd0cf26e373e1b6709512c2dab9dff4066c6b40d32bd747bdb84469023952bc82123e8cc1b', + }, + ], +]; + export const transactionsTestData: [TxData | AccessListEIP2930TxData | FeeMarketEIP1559TxData][] = [ [ // 'TxLegacy' @@ -526,3 +599,13 @@ export const validHashMessageData: [string, string][] = [ ['non utf8 string', '0x8862c6a425a83c082216090e4f0e03b64106189e93c29b11d0112e77b477cce2'], ['', '0x5f35dce98ba4fba25530a026ed80b2cecdaa31091ba4958b99b52ea1d068adad'], ]; + +export const validHashMessageWithoutPrefixData: [string, string][] = [ + ['🤗', '0x4bf650e97ac50e9e4b4c51deb9e01455c1a9b2f35143bc0a43f1ea5bc9e51856'], + [ + 'Some long text with integers 1233 and special characters and unicode \u1234 as well.', + '0x6965440cc2890e0f118738d6300a21afb2de316c578dad144aa55c9ea45c0fa7', + ], + ['non utf8 string', '0x52000fc43fe3aa422eecafff3e0d82205a1409850c4bd2871dfde932de1fec13'], + ['', '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'], +]; diff --git a/packages/web3-eth-accounts/test/integration/account.test.ts b/packages/web3-eth-accounts/test/integration/account.test.ts index 06b703e496f..c4986b8a5a2 100644 --- a/packages/web3-eth-accounts/test/integration/account.test.ts +++ b/packages/web3-eth-accounts/test/integration/account.test.ts @@ -27,6 +27,7 @@ import { recover, recoverTransaction, sign, + signRaw, signTransaction, } from '../../src'; import { TransactionFactory } from '../../src/tx/transactionFactory'; @@ -37,10 +38,12 @@ import { invalidPrivateKeytoAccountData, invalidPrivateKeyToAddressData, signatureRecoverData, + signatureRecoverWithoutPrefixData, transactionsTestData, validDecryptData, validEncryptData, validHashMessageData, + validHashMessageWithoutPrefixData, validPrivateKeytoAccountData, validPrivateKeyToAddressData, } from '../fixtures/account'; @@ -128,6 +131,12 @@ describe('accounts', () => { }); }); + describe('Hash Message Without Prefix', () => { + it.each(validHashMessageWithoutPrefixData)('%s', (message, hash) => { + expect(hashMessage(message, true)).toEqual(hash); + }); + }); + describe('Sign Message', () => { describe('sign', () => { it.each(signatureRecoverData)('%s', (data, testObj) => { @@ -144,6 +153,31 @@ describe('accounts', () => { }); }); + describe('Sign Raw Message', () => { + describe('signRaw', () => { + it.each(signatureRecoverWithoutPrefixData)('%s', (data, testObj) => { + const result = signRaw(data, testObj.privateKey); + expect(result.signature).toEqual(testObj.signature || testObj.signatureOrV); // makes sure we get signature and not V value + expect(result.r).toEqual(testObj.r); + expect(result.s).toEqual(testObj.s); + }); + }); + + describe('recover', () => { + it.each(signatureRecoverWithoutPrefixData)('%s', (data, testObj) => { + const hashedMessage = hashMessage(data, true); // hash the message first without prefix + const address = recover( + hashedMessage, + testObj.signatureOrV, + testObj.prefixedOrR, + testObj.s, + true, // make sure the prefixed is true since we already hashed the message + ); + expect(address).toEqual(testObj.address); + }); + }); + }); + describe('encrypt', () => { describe('valid cases', () => { it.each(validEncryptData)('%s', async (input, output) => { diff --git a/packages/web3-eth-accounts/test/unit/account.test.ts b/packages/web3-eth-accounts/test/unit/account.test.ts index 10dae39fbc7..7285f65e6e3 100644 --- a/packages/web3-eth-accounts/test/unit/account.test.ts +++ b/packages/web3-eth-accounts/test/unit/account.test.ts @@ -29,6 +29,7 @@ import { sign, signTransaction, privateKeyToPublicKey, + signRaw, } from '../../src/account'; import { invalidDecryptData, @@ -37,10 +38,12 @@ import { invalidPrivateKeytoAccountData, invalidPrivateKeyToAddressData, signatureRecoverData, + signatureRecoverWithoutPrefixData, transactionsTestData, validDecryptData, validEncryptData, validHashMessageData, + validHashMessageWithoutPrefixData, validPrivateKeytoAccountData, validPrivateKeyToAddressData, validPrivateKeyToPublicKeyData, @@ -143,6 +146,12 @@ describe('accounts', () => { }); }); + describe('Hash Message Without Prefix', () => { + it.each(validHashMessageWithoutPrefixData)('%s', (message, hash) => { + expect(hashMessage(message, true)).toEqual(hash); + }); + }); + describe('Sign Message', () => { describe('sign', () => { it.each(signatureRecoverData)('%s', (data, testObj) => { @@ -161,6 +170,31 @@ describe('accounts', () => { }); }); + describe('Sign Raw Message', () => { + describe('signRaw', () => { + it.each(signatureRecoverWithoutPrefixData)('%s', (data, testObj) => { + const result = signRaw(data, testObj.privateKey); + expect(result.signature).toEqual(testObj.signature || testObj.signatureOrV); // makes sure we get signature and not V value + expect(result.r).toEqual(testObj.r); + expect(result.s).toEqual(testObj.s); + }); + }); + + describe('recover', () => { + it.each(signatureRecoverWithoutPrefixData)('%s', (data, testObj) => { + const hashedMessage = hashMessage(data, true); // hash the message first without prefix + const address = recover( + hashedMessage, + testObj.signatureOrV, + testObj.prefixedOrR, + testObj.s, + true, // make sure the prefixed is true since we already hashed the message + ); + expect(address).toEqual(testObj.address); + }); + }); + }); + describe('encrypt', () => { describe('valid cases', () => { it.each(validEncryptData)('%s', async (input, output) => { diff --git a/packages/web3-eth-accounts/test/unit/account_dom.test.ts b/packages/web3-eth-accounts/test/unit/account_dom.test.ts index 799fc4f2f8e..e0bf85fcbd2 100644 --- a/packages/web3-eth-accounts/test/unit/account_dom.test.ts +++ b/packages/web3-eth-accounts/test/unit/account_dom.test.ts @@ -44,6 +44,7 @@ import { recover, recoverTransaction, sign, + signRaw, signTransaction, privateKeyToPublicKey, } from '../../src/account'; @@ -54,10 +55,12 @@ import { invalidPrivateKeytoAccountData, invalidPrivateKeyToAddressData, signatureRecoverData, + signatureRecoverWithoutPrefixData, transactionsTestData, validDecryptData, validEncryptData, validHashMessageData, + validHashMessageWithoutPrefixData, validPrivateKeytoAccountData, validPrivateKeyToAddressData, validPrivateKeyToPublicKeyData, @@ -158,6 +161,12 @@ describe('accounts', () => { }); }); + describe('Hash Message Without Prefix', () => { + it.each(validHashMessageWithoutPrefixData)('%s', (message, hash) => { + expect(hashMessage(message, true)).toEqual(hash); + }); + }); + describe('Sign Message', () => { describe('sign', () => { it.each(signatureRecoverData)('%s', (data, testObj) => { @@ -176,6 +185,31 @@ describe('accounts', () => { }); }); + describe('Sign Raw Message', () => { + describe('signRaw', () => { + it.each(signatureRecoverWithoutPrefixData)('%s', (data, testObj) => { + const result = signRaw(data, testObj.privateKey); + expect(result.signature).toEqual(testObj.signature || testObj.signatureOrV); // makes sure we get signature and not V value + expect(result.r).toEqual(testObj.r); + expect(result.s).toEqual(testObj.s); + }); + }); + + describe('recover', () => { + it.each(signatureRecoverWithoutPrefixData)('%s', (data, testObj) => { + const hashedMessage = hashMessage(data, true); // hash the message first without prefix + const address = recover( + hashedMessage, + testObj.signatureOrV, + testObj.prefixedOrR, + testObj.s, + true, // make sure the prefixed is true since we already hashed the message + ); + expect(address).toEqual(testObj.address); + }); + }); + }); + describe('encrypt', () => { describe('valid cases', () => { it.each(validEncryptData)('%s', async (input, output) => {