Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable arbitrary data signing #3720

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/four-sheep-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@fuel-ts/account": patch
"@fuel-ts/hasher": patch
---

feat: enable arbitrary data signing
3 changes: 2 additions & 1 deletion apps/docs/spell-check-custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -344,4 +344,5 @@ WSL
XOR
XORs
YAML
RESTful
RESTful
EIP
32 changes: 28 additions & 4 deletions apps/docs/src/guide/wallets/signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,39 @@

## Signing Messages

Signing messages with a wallet is a fundamental security practice in a blockchain environment. It verifies ownership and ensures the integrity of data. Here's how to use the `wallet.signMessage` method to sign messages:
Signing messages with a wallet is a fundamental security practice in a blockchain environment. It can be used to verify ownership and ensure the integrity of data.

Here's how to use the `wallet.signMessage` method to sign messages (as string):

<<< @./snippets/signing/sign-message.ts#signing-1{ts:line-numbers}

The `wallet.signMessage` method internally hashes the message using the SHA-256 algorithm, then signs the hashed message, returning the signature as a hex string.
The `signMessage` method internally:

- Hashes the message (via `hashMessage`)
- Signs the hashed message using the wallet's private key
- Returns the signature as a hex string

The `hashMessage` helper will:

- Performs a SHA-256 hash on the UTF-8 encoded message.

The `recoverAddress` method from the `Signer` class will take the hashed message and the signature to recover the signer's address. This confirms that the signature was created by the holder of the private key associated with that address, ensuring the authenticity and integrity of the signed message.

## Signing Personal Message

We can also sign arbitrary data, not just strings. This is possible by passing an object containing the `personalSign` property to the `hashMessage` and `signMessage` methods:

<<< @./snippets/signing/sign-personal-message.ts#signing-personal-message{ts:line-numbers}

The primary difference between this [personal message signing](#signing-personal-message) and [message signing](#signing-messages) is the underlying hashing format.

To format the message, we use a similar approach to a [EIP-191](https://eips.ethereum.org/EIPS/eip-191):

The `hashMessage` helper gives us the hash of the original message. This is crucial to ensure that the hash used during signing matches the one used during the address recovery process.
```console
\x19Fuel Signed Message:\n<message length><message>
```

The `recoverAddress` method from the `Signer` class takes the hashed message and the signature to recover the signer's address. This confirms that the signature was created by the holder of the private key associated with that address, ensuring the authenticity and integrity of the signed message.
> **Note**: We still hash using `SHA-256`, unlike Ethereum's [EIP-191](https://eips.ethereum.org/EIPS/eip-191) which uses `Keccak-256`.

## Signing Transactions

Expand Down
16 changes: 9 additions & 7 deletions apps/docs/src/guide/wallets/snippets/signing/sign-message.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
// #region signing-1
import { hashMessage, Provider, Signer, WalletUnlocked } from 'fuels';
import { hashMessage, Signer, WalletUnlocked } from 'fuels';

import { LOCAL_NETWORK_URL } from '../../../../env';
const wallet = WalletUnlocked.generate();

const provider = new Provider(LOCAL_NETWORK_URL);

const wallet = WalletUnlocked.generate({ provider });

const message = 'my-message';
const message: string = 'my-message';
const signedMessage = await wallet.signMessage(message);
// Example output: 0x277e1461cbb2e6a3250fa8c490221595efb3f4d66d43a4618d1013ca61ca56ba

Expand All @@ -24,3 +20,9 @@ console.log(
'Recovered address should equal original wallet address',
wallet.address.toB256() === recoveredAddress.toB256()
);

console.log(
'Hashed message should be consistent',
hashedMessage ===
'0x40436501b686546b7c660bb18791ac2ae35e77fbe2ac977fc061922b9ec83766'
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { hashMessage, Signer, WalletUnlocked } from 'fuels';

const wallet = WalletUnlocked.generate();

// #region signing-personal-message
const message: string | Uint8Array = Uint8Array.from([0x01, 0x02, 0x03]);
const signedMessage = await wallet.signMessage({ personalSign: message });
// Example output: 0x0ca4ca2a01003d076b4044e38a7ca2443640d5fb493c37e28c582e4f2b47ada7

const hashedMessage = hashMessage({ personalSign: message });
// Example output: 0x862e2d2c46b1b52fd65538c71f7ef209ee32f4647f939283b3dd2434cc5320c5
// #endregion signing-personal-message

const recoveredAddress = Signer.recoverAddress(hashedMessage, signedMessage);

console.log(
'Expect the recovered address to be the same as the original wallet address',
wallet.address.toB256() === recoveredAddress.toB256()
);

console.log(
'Hashed message should be consistent',
hashedMessage ===
'0x862e2d2c46b1b52fd65538c71f7ef209ee32f4647f939283b3dd2434cc5320c5'
);
3 changes: 2 additions & 1 deletion packages/account/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AddressInput, WithAddress } from '@fuel-ts/address';
import { Address } from '@fuel-ts/address';
import { randomBytes } from '@fuel-ts/crypto';
import { ErrorCode, FuelError } from '@fuel-ts/errors';
import type { HashableMessage } from '@fuel-ts/hasher';
import type { BigNumberish, BN } from '@fuel-ts/math';
import { bn } from '@fuel-ts/math';
import { InputType } from '@fuel-ts/transactions';
Expand Down Expand Up @@ -620,7 +621,7 @@ export class Account extends AbstractAccount implements WithAddress {
*
* @hidden
*/
async signMessage(message: string): Promise<string> {
async signMessage(message: HashableMessage): Promise<string> {
if (!this._connector) {
throw new FuelError(ErrorCode.MISSING_CONNECTOR, 'A connector is required to sign messages.');
}
Expand Down
5 changes: 3 additions & 2 deletions packages/account/src/connectors/fuel-connector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/require-await */
import { FuelError } from '@fuel-ts/errors';
import type { HashableMessage } from '@fuel-ts/hasher';
import { EventEmitter } from 'events';

import type { Asset } from '../assets/types';
Expand Down Expand Up @@ -37,7 +38,7 @@ interface Connector {
disconnect(): Promise<boolean>;
// #endregion fuel-connector-method-disconnect
// #region fuel-connector-method-signMessage
signMessage(address: string, message: string): Promise<string>;
signMessage(address: string, message: HashableMessage): Promise<string>;
// #endregion fuel-connector-method-signMessage
// #region fuel-connector-method-signTransaction
signTransaction(address: string, transaction: TransactionRequestLike): Promise<string>;
Expand Down Expand Up @@ -178,7 +179,7 @@ export abstract class FuelConnector extends EventEmitter implements Connector {
*
* @returns Message signature
*/
async signMessage(_address: string, _message: string): Promise<string> {
async signMessage(_address: string, _message: HashableMessage): Promise<string> {
throw new FuelError(FuelError.CODES.NOT_IMPLEMENTED, 'Method not implemented.');
}

Expand Down
24 changes: 21 additions & 3 deletions packages/account/src/signer/signer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sha256 } from '@fuel-ts/hasher';
import { hashMessage, sha256 } from '@fuel-ts/hasher';
import { arrayify } from '@fuel-ts/utils';

import { Signer } from './signer';
Expand All @@ -16,6 +16,8 @@ describe('Signer', () => {
const expectedB256Address = '0xf1e92c42b90934aa6372e30bc568a326f6e66a1a0288595e6e3fbd392a4f3e6e';
const expectedSignedMessage =
'0x8eeb238db1adea4152644f1cd827b552dfa9ab3f4939718bb45ca476d167c6512a656f4d4c7356bfb9561b14448c230c6e7e4bd781df5ee9e5999faa6495163d';
const expectedRawSignedMessage =
'0x435f61b60f56a624b080e0b0066b8412094ca22b886f3e69ec4fe536bc18b576fc9732aa0b19c624b070b0eaeff45386aab8c5211618c9292e224e4cee0cadff';

it('Initialize publicKey and address for new signer instance', () => {
const signer = new Signer(expectedPrivateKey);
Expand All @@ -35,13 +37,29 @@ describe('Signer', () => {
expect(signer.address.toB256()).toEqual(expectedB256Address);
});

it('Sign message', () => {
it('Sign message [string]', () => {
const signer = new Signer(expectedPrivateKey);
const signedMessage = signer.sign(sha256(Buffer.from(expectedMessage)));
const signedMessage = signer.sign(hashMessage(expectedMessage));

expect(signedMessage).toEqual(expectedSignedMessage);
});

it('Sign raw message [{ personalSign: string }]', () => {
const signer = new Signer(expectedPrivateKey);
const message = new TextEncoder().encode(expectedMessage);
const signedMessage = signer.sign(hashMessage({ personalSign: message }));

expect(signedMessage).toEqual(expectedRawSignedMessage);
});

it('Sign raw message [{ personalSign: Uint8Array }]', () => {
const signer = new Signer(expectedPrivateKey);
const message = new TextEncoder().encode(expectedMessage);
const signedMessage = signer.sign(hashMessage({ personalSign: message }));

expect(signedMessage).toEqual(expectedRawSignedMessage);
});

it('Recover publicKey and address from signed message', () => {
const signer = new Signer(expectedPrivateKey);
const hashedMessage = sha256(Buffer.from(expectedMessage));
Expand Down
3 changes: 2 additions & 1 deletion packages/account/src/wallet/base-wallet-unlocked.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HashableMessage } from '@fuel-ts/hasher';
import { hashMessage } from '@fuel-ts/hasher';
import type { BytesLike } from '@fuel-ts/utils';
import { hexlify } from '@fuel-ts/utils';
Expand Down Expand Up @@ -67,7 +68,7 @@ export class BaseWalletUnlocked extends Account {
* @param message - The message to sign.
* @returns A promise that resolves to the signature as a ECDSA 64 bytes string.
*/
override async signMessage(message: string): Promise<string> {
override async signMessage(message: HashableMessage): Promise<string> {
const signedMessage = await this.signer().sign(hashMessage(message));
return hexlify(signedMessage);
}
Expand Down
36 changes: 35 additions & 1 deletion packages/account/src/wallet/wallet-unlocked.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe('WalletUnlocked', () => {
const expectedMessage = 'my message';
const expectedSignedMessage =
'0x8eeb238db1adea4152644f1cd827b552dfa9ab3f4939718bb45ca476d167c6512a656f4d4c7356bfb9561b14448c230c6e7e4bd781df5ee9e5999faa6495163d';
const expectedRawSignedMessage =
'0x435f61b60f56a624b080e0b0066b8412094ca22b886f3e69ec4fe536bc18b576fc9732aa0b19c624b070b0eaeff45386aab8c5211618c9292e224e4cee0cadff';

it('Instantiate a new wallet', async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should ensure the current method does not work; a break change here can be catastrophic.

Like bako predicate, which uses the current sign message to verify ownership,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented a backward compatible approach.
c59c1db

All previous hashes remain unchanged

using launched = await setupTestProviderAndWallets();
Expand All @@ -38,7 +40,7 @@ describe('WalletUnlocked', () => {
expect(wallet.address.toAddress()).toEqual(expectedAddress);
});

it('Sign a message using wallet instance', async () => {
it('Sign a message using wallet instance [string]', async () => {
using launched = await setupTestProviderAndWallets();
const { provider } = launched;

Expand All @@ -50,6 +52,38 @@ describe('WalletUnlocked', () => {
expect(signedMessage).toEqual(expectedSignedMessage);
});

it('Sign a raw message using wallet instance [{ personalSign: string }]', async () => {
using launched = await setupTestProviderAndWallets();
const { provider } = launched;

const wallet = new WalletUnlocked(expectedPrivateKey, provider);
const message = expectedMessage;
const signedMessage = await wallet.signMessage({ personalSign: message });
const verifiedAddress = Signer.recoverAddress(
hashMessage({ personalSign: message }),
signedMessage
);

expect(verifiedAddress).toEqual(wallet.address);
expect(signedMessage).toEqual(expectedRawSignedMessage);
});

it('Sign a raw message using wallet instance [{ personalSign: Uint8Array }]', async () => {
using launched = await setupTestProviderAndWallets();
const { provider } = launched;

const wallet = new WalletUnlocked(expectedPrivateKey, provider);
const message = new TextEncoder().encode(expectedMessage);
const signedMessage = await wallet.signMessage({ personalSign: message });
const verifiedAddress = Signer.recoverAddress(
hashMessage({ personalSign: message }),
signedMessage
);

expect(verifiedAddress).toEqual(wallet.address);
expect(signedMessage).toEqual(expectedRawSignedMessage);
});

it('Sign a transaction using wallet instance', async () => {
using launched = await setupTestProviderAndWallets();
const { provider } = launched;
Expand Down
3 changes: 2 additions & 1 deletion packages/account/test/fixtures/mocked-connector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/require-await */

import type { HashableMessage } from '@fuel-ts/hasher';
import { setTimeout } from 'timers/promises';

import type {
Expand Down Expand Up @@ -98,7 +99,7 @@ export class MockConnector extends FuelConnector {
return false;
}

override async signMessage(_address: string, _message: string) {
override async signMessage(_address: string, _message: HashableMessage) {
const wallet = this._wallets.find((w) => w.address.toString() === _address);
if (!wallet) {
throw new Error('Wallet is not found!');
Expand Down
29 changes: 23 additions & 6 deletions packages/hasher/src/hasher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,6 @@ describe('Hasher', () => {
);
});

it('Hash message', () => {
const message = 'my message';
const hashedMessage = '0xea38e30f75767d7e6c21eba85b14016646a3b60ade426ca966dac940a5db1bab';
expect(hashMessage(message)).toEqual(hashedMessage);
});

it('Hash "20"', () => {
expect(hash(Buffer.from('20'))).toEqual(
'0xf5ca38f748a1d6eaf726b8a42fb575c3c71f1864a8143301782de13da2d9202b'
Expand All @@ -31,4 +25,27 @@ describe('Hasher', () => {
const expectedBytes = new Uint8Array([0, 0, 0, 0, 73, 150, 2, 210]);
expect(uint64ToBytesBE(value)).toEqual(expectedBytes);
});

describe('hashMessage', () => {
it('should hash a message [string]', () => {
const message: string = 'my message';
const expectHashedMessage =
'0xea38e30f75767d7e6c21eba85b14016646a3b60ade426ca966dac940a5db1bab';
expect(hashMessage(message)).toEqual(expectHashedMessage);
});

it('should hash a raw message [{ personalSign: string }]', () => {
const data: string = 'my message';
const expectHashedMessage =
'0x615128eb1ecd44765ac3dae437fa144e58f934f01ba73260c1b84e30271cfb1e';
expect(hashMessage({ personalSign: data })).toEqual(expectHashedMessage);
});

it('should hash a raw message [{ personalSign: Uint8Array }]', () => {
const data: Uint8Array = new TextEncoder().encode('my message');
const expectHashedMessage =
'0x615128eb1ecd44765ac3dae437fa144e58f934f01ba73260c1b84e30271cfb1e';
expect(hashMessage({ personalSign: data })).toEqual(expectHashedMessage);
});
});
});
49 changes: 43 additions & 6 deletions packages/hasher/src/hasher.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { bufferFromString } from '@fuel-ts/crypto';
import type { BytesLike } from '@fuel-ts/utils';
import { arrayify, hexlify } from '@fuel-ts/utils';
import { arrayify, concat, hexlify, toUtf8Bytes } from '@fuel-ts/utils';
import { sha256 as sha256AsBytes } from '@noble/hashes/sha256';

/**
* The prefix for the message to be hashed
*/
const MESSAGE_PREFIX = '\x19Fuel Signed Message:\n';

/**
* - When a string is provided, we hash as a UTF-8 string using SHA-256.
*
* - When an object with `personalSign` property is provided, we hash using SHA-256 of the following format:
* ```console
* 0x19 <0x46 (F)> <uel Signed Message:\n" + len(message)> <message>
* ```
*
* Following a similar approach to that of [EIP-191](https://eips.ethereum.org/EIPS/eip-191).
*/
export type HashableMessage = string | { personalSign: BytesLike };

/**
* @param data - The data to be hashed
* @returns A sha256 hash of the data in hex format
Expand Down Expand Up @@ -33,11 +49,32 @@ export function uint64ToBytesBE(value: number): Uint8Array {
}

/**
* hash string messages with sha256
* Hashes a message using SHA256.
*
* - When a `message` string is provided, we hash as a UTF-8 string using SHA-256.
*
* @param msg - The string message to be hashed
* - When a `message` object with `personalSign` property is provided, we hash using SHA-256 of the following format:
* ```console
* 0x19 <0x46 (F)> <uel Signed Message:\n" + len(message)> <message>
* ```
*
* Following a similar approach to that of [EIP-191](https://eips.ethereum.org/EIPS/eip-191).
*
* @param message - The message to be hashed @see {@link HashableMessage}
* @returns A sha256 hash of the message
*/
export function hashMessage(msg: string) {
return hash(bufferFromString(msg, 'utf-8'));
export function hashMessage(message: HashableMessage) {
if (typeof message === 'string') {
return sha256(toUtf8Bytes(message));
}

const { personalSign } = message;
const messageBytes: Uint8Array =
typeof personalSign === 'string' ? toUtf8Bytes(personalSign) : personalSign;
const payload = concat([
toUtf8Bytes(MESSAGE_PREFIX),
toUtf8Bytes(String(messageBytes.length)),
messageBytes,
]);
return hexlify(sha256(payload));
}