diff --git a/__mocks__/typedData/v1Nested.json b/__mocks__/typedData/v1Nested.json new file mode 100644 index 000000000..99f38dc40 --- /dev/null +++ b/__mocks__/typedData/v1Nested.json @@ -0,0 +1,126 @@ +{ + "domain": { + "name": "Dappland", + "chainId": "0x534e5f5345504f4c4941", + "version": "1.0.2", + "revision": "1" + }, + "message": { + "MessageId": 345, + "From": { + "Name": "Edmund", + "Address": "0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a" + }, + "To": { + "Name": "Alice", + "Address": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79" + }, + "Nft_to_transfer": { + "Collection": "Stupid monkeys", + "Address": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79", + "Nft_id": 112, + "Negotiated_for": { + "Qty": "18.4569325643", + "Unit": "ETH", + "Token_address": "0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79", + "Amount": "0x100243260D270EB00" + } + }, + "Comment1": "Monkey with banana, sunglasses,", + "Comment2": "and red hat.", + "Comment3": "" + }, + "primaryType": "TransferERC721", + "types": { + "Account1": [ + { + "name": "Name", + "type": "string" + }, + { + "name": "Address", + "type": "felt" + } + ], + "Nft": [ + { + "name": "Collection", + "type": "string" + }, + { + "name": "Address", + "type": "felt" + }, + { + "name": "Nft_id", + "type": "felt" + }, + { + "name": "Negotiated_for", + "type": "Transaction" + } + ], + "Transaction": [ + { + "name": "Qty", + "type": "string" + }, + { + "name": "Unit", + "type": "string" + }, + { + "name": "Token_address", + "type": "felt" + }, + { + "name": "Amount", + "type": "felt" + } + ], + "TransferERC721": [ + { + "name": "MessageId", + "type": "felt" + }, + { + "name": "From", + "type": "Account1" + }, + { + "name": "To", + "type": "Account1" + }, + { + "name": "Nft_to_transfer", + "type": "Nft" + }, + { + "name": "Comment1", + "type": "string" + }, + { + "name": "Comment2", + "type": "string" + }, + { + "name": "Comment3", + "type": "string" + } + ], + "StarknetDomain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "felt" + }, + { + "name": "version", + "type": "string" + } + ] + } +} diff --git a/__tests__/rpcProvider.test.ts b/__tests__/rpcProvider.test.ts index 4ebdbb004..af039910a 100644 --- a/__tests__/rpcProvider.test.ts +++ b/__tests__/rpcProvider.test.ts @@ -1,5 +1,5 @@ -import { getStarkKey, utils } from '@scure/starknet'; - +import { getStarkKey, Signature, utils } from '@scure/starknet'; +import typedDataExample from '../__mocks__/typedData/baseExample.json'; import { Account, Block, @@ -18,7 +18,7 @@ import { } from '../src'; import { StarknetChainId } from '../src/constants'; import { felt, uint256 } from '../src/utils/calldata/cairo'; -import { toHexString } from '../src/utils/num'; +import { toBigInt, toHexString } from '../src/utils/num'; import { compiledC1v2, compiledC1v2Casm, @@ -493,3 +493,47 @@ describeIfNotDevnet('waitForBlock', () => { expect(true).toBe(true); // answer without timeout Error (blocks have to be spaced with 16 minutes maximum : 200 retries * 5000ms) }); }); + +describe('EIP712 verification', () => { + const rpcProvider = getTestProvider(false); + const account = getTestAccount(rpcProvider); + + test('sign and verify message', async () => { + const signature = await account.signMessage(typedDataExample); + const verifMessageResponse: boolean = await rpcProvider.verifyMessageInStarknet( + typedDataExample, + signature, + account.address + ); + expect(verifMessageResponse).toBe(true); + + const messageHash = await account.hashMessage(typedDataExample); + const verifMessageResponse2: boolean = await rpcProvider.verifyMessageInStarknet( + messageHash, + signature, + account.address + ); + expect(verifMessageResponse2).toBe(true); + }); + + test('sign and verify EIP712 message fail', async () => { + const signature = await account.signMessage(typedDataExample); + const [r, s] = stark.formatSignature(signature); + + // change the signature to make it invalid + const r2 = toBigInt(r) + 123n; + const wrongSignature = new Signature(toBigInt(r2.toString()), toBigInt(s)); + if (!wrongSignature) return; + const verifMessageResponse: boolean = await rpcProvider.verifyMessageInStarknet( + typedDataExample, + wrongSignature, + account.address + ); + expect(verifMessageResponse).toBe(false); + + const wrongAccountAddress = '0x123456789'; + await expect( + rpcProvider.verifyMessageInStarknet(typedDataExample, signature, wrongAccountAddress) + ).rejects.toThrow(); + }); +}); diff --git a/__tests__/utils/num.test.ts b/__tests__/utils/num.test.ts index 78b08d194..81f0e7bb1 100644 --- a/__tests__/utils/num.test.ts +++ b/__tests__/utils/num.test.ts @@ -216,3 +216,14 @@ describe('stringToSha256ToArrayBuff4', () => { expect(buff).toEqual(new Uint8Array([43, 206, 231, 219])); }); }); + +describe('isBigNumberish', () => { + test('determine if value is a BigNumberish', () => { + expect(num.isBigNumberish(234)).toBe(true); + expect(num.isBigNumberish(234n)).toBe(true); + expect(num.isBigNumberish('234')).toBe(true); + expect(num.isBigNumberish('0xea')).toBe(true); + expect(num.isBigNumberish('ea')).toBe(false); + expect(num.isBigNumberish('zero')).toBe(false); + }); +}); diff --git a/__tests__/utils/stark.test.ts b/__tests__/utils/stark.test.ts index 0252e45f1..04a82d456 100644 --- a/__tests__/utils/stark.test.ts +++ b/__tests__/utils/stark.test.ts @@ -116,3 +116,12 @@ describe('stark', () => { expect(stark.v3Details(detailsUndefined)).toEqual(expect.objectContaining(detailsAnything)); }); }); + +describe('ec full public key', () => { + test('determine if value is a BigNumberish', () => { + const privateKey1 = '0x43b7240d227aa2fb8434350b3321c40ac1b88c7067982549e7609870621b535'; + expect(stark.getFullPublicKey(privateKey1)).toBe( + '0x0400b730bd22358612b5a67f8ad52ce80f9e8e893639ade263537e6ef35852e5d3057795f6b090f7c6985ee143f798608a53b3659222c06693c630857a10a92acf' + ); + }); +}); diff --git a/__tests__/utils/typedData.test.ts b/__tests__/utils/typedData.test.ts index 4018e7f54..945f12075 100644 --- a/__tests__/utils/typedData.test.ts +++ b/__tests__/utils/typedData.test.ts @@ -6,7 +6,17 @@ import exampleEnum from '../../__mocks__/typedData/example_enum.json'; import examplePresetTypes from '../../__mocks__/typedData/example_presetTypes.json'; import typedDataStructArrayExample from '../../__mocks__/typedData/mail_StructArray.json'; import typedDataSessionExample from '../../__mocks__/typedData/session_MerkleTree.json'; -import { BigNumberish, StarknetDomain, num } from '../../src'; +import v1NestedExample from '../../__mocks__/typedData/v1Nested.json'; +import { + Account, + BigNumberish, + StarknetDomain, + num, + stark, + typedData, + type ArraySignatureType, + type Signature, +} from '../../src'; import { PRIME } from '../../src/constants'; import { getSelectorFromName } from '../../src/utils/hash'; import { MerkleTree } from '../../src/utils/merkle'; @@ -346,4 +356,48 @@ describe('typedData', () => { expect(() => getMessageHash(baseTypes(type), exampleAddress)).toThrow(RegExp(type)); }); }); + + describe('verifyMessage', () => { + const addr = '0x64b48806902a367c8598f4f95c305e8c1a1acba5f082d294a43793113115691'; + const privK = '0x71d7bb07b9a64f6f78ac4c816aff4da9'; + const fullPubK = stark.getFullPublicKey(privK); + const myAccount = new Account({ nodeUrl: 'fake' }, addr, privK); + let signedMessage: Signature; + let hashedMessage: string; + let arraySign: ArraySignatureType; + + beforeAll(async () => { + signedMessage = await myAccount.signMessage(v1NestedExample); + hashedMessage = await myAccount.hashMessage(v1NestedExample); + arraySign = stark.formatSignature(signedMessage); + }); + + test('with TypedMessage', () => { + expect( + typedData.verifyMessage(v1NestedExample, signedMessage, fullPubK, myAccount.address) + ).toBe(true); + expect(typedData.verifyMessage(v1NestedExample, arraySign, fullPubK, myAccount.address)).toBe( + true + ); + }); + + test('with messageHash', () => { + expect(typedData.verifyMessage(hashedMessage, signedMessage, fullPubK)).toBe(true); + expect(typedData.verifyMessage(hashedMessage, arraySign, fullPubK)).toBe(true); + }); + + test('failure cases', () => { + expect(() => typedData.verifyMessage('zero', signedMessage, fullPubK)).toThrow( + 'message has a wrong format.' + ); + + expect(() => + typedData.verifyMessage(v1NestedExample as any, signedMessage, fullPubK) + ).toThrow(/^When providing a TypedData .* the accountAddress parameter has to be provided/); + + expect(() => + typedData.verifyMessage(v1NestedExample, signedMessage, fullPubK, 'wrong') + ).toThrow('accountAddress shall be a BigNumberish'); + }); + }); }); diff --git a/src/account/default.ts b/src/account/default.ts index e3820dca9..84faff47c 100644 --- a/src/account/default.ts +++ b/src/account/default.ts @@ -50,7 +50,6 @@ import { parseContract } from '../utils/provider'; import { isString } from '../utils/shortString'; import { estimateFeeToBounds, - formatSignature, reduceV2, toFeeVersion, toTransactionVersion, @@ -543,87 +542,37 @@ export class Account extends Provider implements AccountInterface { return getMessageHash(typedData, this.address); } + /** + * @deprecated To replace by `myRpcProvider.verifyMessageInStarknet()` + */ public async verifyMessageHash( hash: BigNumberish, signature: Signature, signatureVerificationFunctionName?: string, signatureVerificationResponse?: { okResponse: string[]; nokResponse: string[]; error: string[] } ): Promise { - // HOTFIX: Accounts should conform to SNIP-6 - // (https://github.com/starknet-io/SNIPs/blob/f6998f779ee2157d5e1dea36042b08062093b3c5/SNIPS/snip-6.md?plain=1#L61), - // but they don't always conform. Also, the SNIP doesn't standardize the response if the signature isn't valid. - const knownSigVerificationFName = signatureVerificationFunctionName - ? [signatureVerificationFunctionName] - : ['isValidSignature', 'is_valid_signature']; - const knownSignatureResponse = signatureVerificationResponse || { - okResponse: [ - // any non-nok response is true - ], - nokResponse: [ - '0x0', // Devnet - '0x00', // OpenZeppelin 0.7.0 to 0.9.0 invalid signature - ], - error: [ - 'argent/invalid-signature', // ArgentX 0.3.0 to 0.3.1 - 'is invalid, with respect to the public key', // OpenZeppelin until 0.6.1, Braavos 0.0.11 - 'INVALID_SIG', // Braavos 1.0.0 - ], - }; - let error: any; - - // eslint-disable-next-line no-restricted-syntax - for (const SigVerificationFName of knownSigVerificationFName) { - try { - // eslint-disable-next-line no-await-in-loop - const resp = await this.callContract({ - contractAddress: this.address, - entrypoint: SigVerificationFName, - calldata: CallData.compile({ - hash: toBigInt(hash).toString(), - signature: formatSignature(signature), - }), - }); - // Response NOK Signature - if (knownSignatureResponse.nokResponse.includes(resp[0].toString())) { - return false; - } - // Response OK Signature - // Empty okResponse assume all non-nok responses are valid signatures - // OpenZeppelin 0.7.0 to 0.9.0, ArgentX 0.3.0 to 0.3.1 & Braavos Cairo 0.0.11 to 1.0.0 valid signature - if ( - knownSignatureResponse.okResponse.length === 0 || - knownSignatureResponse.okResponse.includes(resp[0].toString()) - ) { - return true; - } - throw Error('signatureVerificationResponse Error: response is not part of known responses'); - } catch (err) { - // Known NOK Errors - if ( - knownSignatureResponse.error.some((errMessage) => - (err as Error).message.includes(errMessage) - ) - ) { - return false; - } - // Unknown Error - error = err; - } - } - - throw Error(`Signature verification Error: ${error}`); + return this.verifyMessageInStarknet( + hash, + signature, + this.address, + signatureVerificationFunctionName, + signatureVerificationResponse + ); } + /** + * @deprecated To replace by `myRpcProvider.verifyMessageInStarknet()` + */ public async verifyMessage( typedData: TypedData, signature: Signature, signatureVerificationFunctionName?: string, signatureVerificationResponse?: { okResponse: string[]; nokResponse: string[]; error: string[] } ): Promise { - const hash = await this.hashMessage(typedData); - return this.verifyMessageHash( - hash, + return this.verifyMessageInStarknet( + typedData, signature, + this.address, signatureVerificationFunctionName, signatureVerificationResponse ); diff --git a/src/account/interface.ts b/src/account/interface.ts index 7327856d7..d86405a2c 100644 --- a/src/account/interface.ts +++ b/src/account/interface.ts @@ -3,7 +3,6 @@ import { SignerInterface } from '../signer'; import { Abi, AllowArray, - BigNumberish, BlockIdentifier, CairoVersion, Call, @@ -363,27 +362,6 @@ export abstract class AccountInterface extends ProviderInterface { */ public abstract hashMessage(typedData: TypedData): Promise; - /** - * Verify a signature of a TypedData object - * - * @param typedData - TypedData object to be verified - * @param signature - signature of the TypedData object - * @returns true if the signature is valid, false otherwise - * @throws {Error} if typedData is not a valid TypedData or the signature is not a valid signature - */ - public abstract verifyMessage(typedData: TypedData, signature: Signature): Promise; - - /** - * Verify a signature of a given hash - * @warning This method is not recommended, use verifyMessage instead - * - * @param hash - hash to be verified - * @param signature - signature of the hash - * @returns true if the signature is valid, false otherwise - * @throws {Error} if the signature is not a valid signature - */ - public abstract verifyMessageHash(hash: BigNumberish, signature: Signature): Promise; - /** * Gets the nonce of the account with respect to a specific block * diff --git a/src/provider/rpc.ts b/src/provider/rpc.ts index 548e1215f..7b16f1735 100644 --- a/src/provider/rpc.ts +++ b/src/provider/rpc.ts @@ -26,18 +26,23 @@ import { getEstimateFeeBulkOptions, getSimulateTransactionOptions, waitForTransactionOptions, + type Signature, + type TypedData, } from '../types'; import type { TransactionWithHash } from '../types/provider/spec'; import assert from '../utils/assert'; import { getAbiContractVersion } from '../utils/calldata/cairo'; import { isSierra } from '../utils/contract'; -import { toHex } from '../utils/num'; +import { isBigNumberish, toBigInt, toHex } from '../utils/num'; import { wait } from '../utils/provider'; import { RPCResponseParser } from '../utils/responseParser/rpc'; import { GetTransactionReceiptResponse, ReceiptTx } from '../utils/transactionReceipt'; import { LibraryError } from './errors'; import { ProviderInterface } from './interface'; import { solidityUint256PackedKeccak256 } from '../utils/hash'; +import { CallData } from '../utils/calldata'; +import { formatSignature } from '../utils/stark'; +import { getMessageHash, validateTypedData } from '../utils/typedData'; export class RpcProvider implements ProviderInterface { public responseParser: RPCResponseParser; @@ -467,4 +472,102 @@ export class RpcProvider implements ProviderInterface { public async getEvents(eventFilter: RPC.EventFilter) { return this.channel.getEvents(eventFilter); } + + /** + * Verify in Starknet a signature of a TypedData object or of a given hash. + * @param {BigNumberish | TypedData} message TypedData object to be verified, or message hash to be verified. + * @param {Signature} signature signature of the message. + * @param {BigNumberish} accountAddress address of the account that has signed the message. + * @param {string} [signatureVerificationFunctionName] if account contract with non standard account verification function name. + * @param { okResponse: string[]; nokResponse: string[]; error: string[] } [signatureVerificationResponse] if account contract with non standard response of verification function. + * @returns + * ```typescript + * const myTypedMessage: TypedMessage = .... ; + * const messageHash = typedData.getMessageHash(myTypedMessage,accountAddress); + * const sign: WeierstrassSignatureType = ec.starkCurve.sign(messageHash, privateKey); + * const accountAddress = "0x43b7240d227aa2fb8434350b3321c40ac1b88c7067982549e7609870621b535"; + * const result1 = myRpcProvider.verifyMessageInStarknet(myTypedMessage, sign, accountAddress); + * const result2 = myRpcProvider.verifyMessageInStarknet(messageHash, sign, accountAddress); + * // result1 = result2 = true + * ``` + */ + public async verifyMessageInStarknet( + message: BigNumberish | TypedData, + signature: Signature, + accountAddress: BigNumberish, + signatureVerificationFunctionName?: string, + signatureVerificationResponse?: { okResponse: string[]; nokResponse: string[]; error: string[] } + ): Promise { + const isTypedData = validateTypedData(message); + if (!isBigNumberish(message) && !isTypedData) { + throw new Error('message has a wrong format.'); + } + if (!isBigNumberish(accountAddress)) { + throw new Error('accountAddress shall be a BigNumberish'); + } + const messageHash = isTypedData ? getMessageHash(message, accountAddress) : toHex(message); + // HOTFIX: Accounts should conform to SNIP-6 + // (https://github.com/starknet-io/SNIPs/blob/f6998f779ee2157d5e1dea36042b08062093b3c5/SNIPS/snip-6.md?plain=1#L61), + // but they don't always conform. Also, the SNIP doesn't standardize the response if the signature isn't valid. + const knownSigVerificationFName = signatureVerificationFunctionName + ? [signatureVerificationFunctionName] + : ['isValidSignature', 'is_valid_signature']; + const knownSignatureResponse = signatureVerificationResponse || { + okResponse: [ + // any non-nok response is true + ], + nokResponse: [ + '0x0', // Devnet + '0x00', // OpenZeppelin 0.7.0 to 0.9.0 invalid signature + ], + error: [ + 'argent/invalid-signature', // ArgentX 0.3.0 to 0.3.1 + 'is invalid, with respect to the public key', // OpenZeppelin until 0.6.1, Braavos 0.0.11 + 'INVALID_SIG', // Braavos 1.0.0 + ], + }; + let error: any; + + // eslint-disable-next-line no-restricted-syntax + for (const SigVerificationFName of knownSigVerificationFName) { + try { + // eslint-disable-next-line no-await-in-loop + const resp = await this.callContract({ + contractAddress: toHex(accountAddress), + entrypoint: SigVerificationFName, + calldata: CallData.compile({ + hash: toBigInt(messageHash).toString(), + signature: formatSignature(signature), + }), + }); + // Response NOK Signature + if (knownSignatureResponse.nokResponse.includes(resp[0].toString())) { + return false; + } + // Response OK Signature + // Empty okResponse assume all non-nok responses are valid signatures + // OpenZeppelin 0.7.0 to 0.9.0, ArgentX 0.3.0 to 0.3.1 & Braavos Cairo 0.0.11 to 1.0.0 valid signature + if ( + knownSignatureResponse.okResponse.length === 0 || + knownSignatureResponse.okResponse.includes(resp[0].toString()) + ) { + return true; + } + throw Error('signatureVerificationResponse Error: response is not part of known responses'); + } catch (err) { + // Known NOK Errors + if ( + knownSignatureResponse.error.some((errMessage) => + (err as Error).message.includes(errMessage) + ) + ) { + return false; + } + // Unknown Error + error = err; + } + } + + throw Error(`Signature verification Error: ${error}`); + } } diff --git a/src/utils/num.ts b/src/utils/num.ts index 5c5e622d2..d9a097f11 100644 --- a/src/utils/num.ts +++ b/src/utils/num.ts @@ -397,3 +397,22 @@ export function stringToSha256ToArrayBuff4(str: string): Uint8Array { const result: number = int31(BigInt(addHexPrefix(buf2hex(sha256(str))))); return hexToBytes(toHex(result)); } + +/** + * Checks if a given value is of BigNumberish type. + * 234, 234n, "234", "0xea" are valid + * @param {unknown} input a value + * @returns {boolean} true if type of input is `BigNumberish` + * @example + * ```typescript + * const res = num.isBigNumberish("ZERO"); + * // res = false + * ``` + */ +export function isBigNumberish(input: unknown): input is BigNumberish { + return ( + isNumber(input) || + isBigInt(input) || + (typeof input === 'string' && (isHex(input) || isStringWholeNumber(input))) + ); +} diff --git a/src/utils/stark.ts b/src/utils/stark.ts index 5bf89bbb2..4add7ae6a 100644 --- a/src/utils/stark.ts +++ b/src/utils/stark.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars import type { SPEC } from 'starknet-types-07'; -import { getStarkKey, utils } from '@scure/starknet'; +import { getPublicKey, getStarkKey, utils } from '@scure/starknet'; import { gzip, ungzip } from 'pako'; import { ZERO, FeeMarginPercentage } from '../constants'; @@ -14,7 +14,7 @@ import { } from '../types'; import { EDAMode, EDataAvailabilityMode, ETransactionVersion, ResourceBounds } from '../types/api'; import { FeeEstimate } from '../types/provider'; -import { addHexPrefix, arrayBufferToString, atobUniversal, btoaUniversal } from './encode'; +import { addHexPrefix, arrayBufferToString, atobUniversal, btoaUniversal, buf2hex } from './encode'; import { parse, stringify } from './json'; import { addPercent, @@ -351,3 +351,19 @@ export function reduceV2(providedVersion: ETransactionVersion): ETransactionVers if (providedVersion === ETransactionVersion.V2) return ETransactionVersion.V1; return providedVersion; } + +/** + * get the hex string of the full public key related to a Starknet private key. + * @param {BigNumberish} privateKey a 252 bits private key. + * @returns {string} an hex string of a 520 bit number, representing the full public key related to `privateKey`. + * @example + * ```typescript + * const result = ec.getFullPublicKey("0x43b7240d227aa2fb8434350b3321c40ac1b88c7067982549e7609870621b535"); + * // result = "0x0400b730bd22358612b5a67f8ad52ce80f9e8e893639ade263537e6ef35852e5d3057795f6b090f7c6985ee143f798608a53b3659222c06693c630857a10a92acf" + * ``` + */ +export function getFullPublicKey(privateKey: BigNumberish): string { + const privKey = toHex(privateKey); + const fullPrivKey = addHexPrefix(buf2hex(getPublicKey(privKey, false))); + return fullPrivKey; +} diff --git a/src/utils/typedData.ts b/src/utils/typedData.ts index 20bc31429..07c1e19f8 100644 --- a/src/utils/typedData.ts +++ b/src/utils/typedData.ts @@ -7,9 +7,11 @@ import { StarknetMerkleType, StarknetType, TypedData, + type Signature, } from '../types'; import assert from './assert'; import { byteArrayFromString } from './calldata/byteArray'; +import { starkCurve } from './ec'; import { computePedersenHash, computePedersenHashOnElements, @@ -18,7 +20,7 @@ import { getSelectorFromName, } from './hash'; import { MerkleTree } from './merkle'; -import { isHex, toHex } from './num'; +import { isBigNumberish, isHex, toHex } from './num'; import { encodeShortString, isString } from './shortString'; /** @deprecated prefer importing from 'types' over 'typedData' */ @@ -96,7 +98,7 @@ function getHex(value: BigNumberish): string { /** * Validates that `data` matches the EIP-712 JSON schema. */ -function validateTypedData(data: unknown): data is TypedData { +export function validateTypedData(data: unknown): data is TypedData { const typedData = data as TypedData; return Boolean( typedData.message && typedData.primaryType && typedData.types && identifyRevision(typedData) @@ -596,3 +598,62 @@ export function getMessageHash(typedData: TypedData, account: BigNumberish): str return hashMethod(message); } + +/** + * Checks if a signed EIP712 message is related to an account. + * Valid for a standard Starknet signature. + * @param {BigNumberish | TypedData} message a TypedMessage message, or the hash of an EIP712 message (SNIP-12). + * @param {Signature} signature a WeierstrassSignatureType signature, or an array of 2 strings. + * @param {BigNumberish} fullPublicKey a number coded on 520 bits (from ec.getFullPublicKey()). + * @param {BigNumberish} [accountAddress] address of the account that has signed the message. Not needed with a message hash is provided in `message` + * @returns {boolean} true if the message is verified. + * @example + * ```typescript + * const myTypedMessage: TypedMessage = .... ; + * const sign: Signature = ["0x123...abc", "0x345...def"]; + * const fullPubK = "0x0400b730bd22358612b5a67f8ad52ce80f9e8e893639ade263537e6ef35852e5d3057795f6b090f7c6985ee143f798608a53b3659222c06693c630857a10a92acf"; + * const accountAddress = "0x43b7240d227aa2fb8434350b3321c40ac1b88c7067982549e7609870621b535"; + * const result1 = typedData.verifyMessage(myTypedMessage, sign, fullPubK, accountAddress); + * const result2 = typedData.verifyMessage(messageHash, sign, fullPubK); + * // result1 = result2 = true + * ``` + */ +export function verifyMessage( + message: TypedData, + signature: Signature, + fullPublicKey: BigNumberish, + accountAddress: BigNumberish +): boolean; +export function verifyMessage( + message: BigNumberish, + signature: Signature, + fullPublicKey: BigNumberish +): boolean; +export function verifyMessage( + message: BigNumberish | TypedData, + signature: Signature, + fullPublicKey: BigNumberish, + accountAddress?: BigNumberish +): boolean { + const isTypedData = validateTypedData(message); + if (!isBigNumberish(message) && !isTypedData) { + throw new Error('message has a wrong format.'); + } + if (isTypedData && accountAddress === undefined) { + throw new Error( + 'When providing a TypedData in message parameter, the accountAddress parameter has to be provided.' + ); + } + if (isTypedData && !isBigNumberish(accountAddress)) { + throw new Error('accountAddress shall be a BigNumberish'); + } + const messageHash = isTypedData + ? getMessageHash(message, accountAddress as BigNumberish) + : toHex(message); + const sign = Array.isArray(signature) + ? new starkCurve.Signature(BigInt(signature[0]), BigInt(signature[1])) + : signature; + const fullPubKey = toHex(fullPublicKey); + const isValid = starkCurve.verify(sign, messageHash, fullPubKey); + return isValid; +} diff --git a/www/docs/guides/signature.md b/www/docs/guides/signature.md index 740497ffa..0c2044933 100644 --- a/www/docs/guides/signature.md +++ b/www/docs/guides/signature.md @@ -13,14 +13,11 @@ Your message has to be an array of `BigNumberish`. First, calculate the hash of > If the message does not respect some safety rules of composition, this method could be a way of attack of your smart contract. If you have any doubt, prefer the [EIP712 like method](#sign-and-verify-following-eip712), which is safe, but is also more complicated. ```typescript -import { ec, hash, num, json, Contract, WeierstrassSignatureType } from 'starknet'; +import { ec, hash, type BigNumberish, type WeierstrassSignatureType } from 'starknet'; const privateKey = '0x1234567890987654321'; const starknetPublicKey = ec.starkCurve.getStarkKey(privateKey); -const fullPublicKey = encode.addHexPrefix( - encode.buf2hex(ec.starkCurve.getPublicKey(privateKey, false)) -); - +const fullPublicKey = stark.getFullPublicKey(privateKey); const message: BigNumberish[] = [1, 128, 18, 14]; const msgHash = hash.computeHashOnElements(message); @@ -51,8 +48,11 @@ The sender provides the message, the signature, and the full public key. Verific ```typescript const msgHash1 = hash.computeHashOnElements(message); -const result1 = ec.starkCurve.verify(signature, msgHash1, fullPublicKey); -console.log('Result (boolean) =', result1); +const isValid1 = typedData.verifyMessage(msgHash1, signature, fullPublicKey); +console.log('Result (boolean) =', isValid1); + +// with a low level function (take care of Types limitations) : +const isValid2 = ec.starkCurve.verify(signature1, msgHash, fullPublicKey); ``` > The sender can also provide their account address. Then you can check that this full public key is linked to this account. The public Key that you can read in the account contract is part (part X) of the full public Key (parts X & Y): @@ -73,8 +73,8 @@ Check that the Public Key of the account is part of the full public Key: ```typescript const isFullPubKeyRelatedToAccount: boolean = - publicKey.publicKey == BigInt(encode.addHexPrefix(fullPublicKey.slice(4, 68))); -console.log('Result (boolean)=', isFullPubKeyRelatedToAccount); + pubKey3 == BigInt(encode.addHexPrefix(fullPublicKey.slice(4, 68))); +console.log('Result (boolean) =', isFullPubKeyRelatedToAccount); ``` ### Verify in the Starknet network, with the account: @@ -82,113 +82,112 @@ console.log('Result (boolean)=', isFullPubKeyRelatedToAccount); The sender can provide an account address, despite a full public key. ```typescript -const provider = new RpcProvider({ nodeUrl: 'http://127.0.0.1:5050/rpc' }); //devnet -const compiledAccount = json.parse( - fs.readFileSync('./__mocks__/cairo/account/accountOZ080.json').toString('ascii') -); - +const myProvider = new RpcProvider({ nodeUrl: 'http://127.0.0.1:5050/rpc' }); //devnet-rs const accountAddress = '0x...'; // account of sender -const contractAccount = new Contract(compiledAccount.abi, accountAddress, provider); + const msgHash2 = hash.computeHashOnElements(message); -// The call of isValidSignature will generate an error if not valid -let result2: boolean; -try { - await contractAccount.isValidSignature(msgHash2, [signature.r, signature.s]); - result2 = true; -} catch { - result2 = false; -} +const result2: Boolean = rpcProvider.verifyMessageInStarknet(msgHash2, signature, accountAddress); console.log('Result (boolean) =', result2); ``` -## Sign and verify the following EIP712 +## Sign and verify following EIP712 -Previous examples are valid for an array of numbers. In the case of a more complex structure of an object, you have to work in the spirit of [EIP 712](https://eips.ethereum.org/EIPS/eip-712). This JSON structure has 4 mandatory items: `types`, `primaryType`, `domain`, and `message`. -These items are designed to be able to be an interface with a wallet. At sign request, the wallet will display: +Previous examples are valid for an array of numbers. In the case of a more complex structure, you have to work in the spirit of [EIP 712](https://eips.ethereum.org/EIPS/eip-712). This JSON structure has 4 mandatory items: `types`, `primaryType`, `domain`, and `message`. +These items are designed to be able to be an interface with a browser wallet. At sign request, the wallet will display: -- the `message` will be displayed at the bottom of the wallet display, showing clearly (not in hex) the message to sign. Its structure has to be in accordance with the type listed in `primaryType`, defined in `types`. -- the `domain` will be shown above the message. Its structure has to be in accordance with `StarkNetDomain`. +- the `message` at the bottom of the wallet window, showing clearly (not in hex) the message to sign. Its structure has to be in accordance with the type listed in `primaryType`, defined in `types`. +- the `domain` above the message. Its structure has to be in accordance with `StarknetDomain`. -The predefined types that you can use: - -- felt: for an integer on 251 bits. -- felt\*: for an array of felt. -- string: for a shortString of 31 ASCII characters max. -- selector: for a name of a smart contract function. -- merkletree: for a Root of a Merkle tree. the root is calculated with the provided data. +The types than can be used are defined in [SNIP-12](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md). An example of simple message : ```typescript -const typedDataValidate: TypedData = { - types: { - StarkNetDomain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'felt' }, - { name: 'chainId', type: 'felt' }, - ], - Airdrop: [ - { name: 'address', type: 'felt' }, - { name: 'amount', type: 'felt' }, - ], - Validate: [ - { name: 'id', type: 'felt' }, - { name: 'from', type: 'felt' }, - { name: 'amount', type: 'felt' }, - { name: 'nameGamer', type: 'string' }, - { name: 'endDate', type: 'felt' }, - { name: 'itemsAuthorized', type: 'felt*' }, // array of felt - { name: 'chkFunction', type: 'selector' }, // name of function - { name: 'rootList', type: 'merkletree', contains: 'Airdrop' }, // root of a merkle tree - ], - }, - primaryType: 'Validate', +const myTypedData: TypedData = { domain: { - name: 'myDapp', // put the name of your dapp to ensure that the signatures will not be used by other DAPP - version: '1', - chainId: shortString.encodeShortString('SN_SEPOLIA'), // shortString of 'SN_SEPOLIA' (or 'SN_MAIN'), to be sure that signature can't be used by other network. + name: 'DappLand', + chainId: constants.StarknetChainId.SN_SEPOLIA, + version: '1.0.2', + revision: TypedDataRevision.ACTIVE, }, message: { - id: '0x0000004f000f', - from: '0x2c94f628d125cd0e86eaefea735ba24c262b9a441728f63e5776661829a4066', - amount: '400', - nameGamer: 'Hector26', - endDate: '0x27d32a3033df4277caa9e9396100b7ca8c66a4ef8ea5f6765b91a7c17f0109c', - itemsAuthorized: ['0x01', '0x03', '0x0a', '0x0e'], - chkFunction: 'check_authorization', - rootList: [ + name: 'MonKeyCollection', + value: 2312, + // do not use BigInt type if message sent to a web browser + }, + primaryType: 'Simple', + types: { + Simple: [ { - address: '0x69b49c2cc8b16e80e86bfc5b0614a59aa8c9b601569c7b80dde04d3f3151b79', - amount: '1554785', + name: 'name', + type: 'shortstring', }, { - address: '0x7447084f620ba316a42c72ca5b8eefb3fe9a05ca5fe6430c65a69ecc4349b3b', - amount: '2578248', + name: 'value', + type: 'u128', }, + ], + StarknetDomain: [ { - address: '0x3cad9a072d3cf29729ab2fad2e08972b8cfde01d4979083fb6d15e8e66f8ab1', - amount: '4732581', + name: 'name', + type: 'shortstring', }, { - address: '0x7f14339f5d364946ae5e27eccbf60757a5c496bf45baf35ddf2ad30b583541a', - amount: '913548', + name: 'chainId', + type: 'shortstring', + }, + { + name: 'version', + type: 'shortstring', }, ], }, }; -// connect your account, then -const signature2 = (await account.signMessage(typedDataValidate)) as WeierstrassSignatureType; +const account0 = new Account(myProvider, address, privateKey); +const fullPublicKey = stark.getFullPublicKey(privateKey); + +const msgHash = await account0.hashMessage(myTypedData); +const signature: Signature = (await account0.signMessage(myTypedData)) as WeierstrassSignatureType; +``` + +:::note +A message can be more complex, with nested types. See an example [here](https://github.com/PhilippeR26/starknet.js-workshop-typescript/blob/main/src/scripts/signature/4c.signSnip12vActive.ts). +::: + +### Verify TypedData outside Starknet + +On the receiver side, you receive the message, the signature, the full public key and the account address. +To verify the message: + +```typescript +const isValid = typedData.verifyMessage(myTypedData, signature, fullPublicKey, account0Address); +``` + +A verification is also possible if you have the message hash, the signature and the full public key. + +```typescript +const isValid2 = typedData.verifyMessage(msgHash, signature, fullPublicKey); + +// with a low level function (take care of Types limitations) : +const isValid3 = ec.starkCurve.verify(signature, msgHash, fullPublicKey); +``` + +### Verify TypedData in Starknet + +On the receiver side, you receive the message, the signature, and the account address. +To verify the message: + +```typescript +const isValid4 = await myProvider.verifyMessageInStarknet( + myTypedData, + signature2, + account0.address +); ``` -On the receiver side, you receive the JSON, the signature, and the account address. To verify the message: +A verification is also possible if you have the message hash, the signature and the account address: ```typescript -const myAccount = new Account(provider, accountAddress, '0x0123'); // fake private key -try { - const result = await myAccount.verifyMessage(typedMessage, signature); - console.log('Result (boolean) =', result); -} catch { - console.log('verification failed:', result.error); -} +const isValid5 = await myProvider.verifyMessageInStarknet(msgHash, signature2, account0.address); ``` ## Signing with an Ethereum signer @@ -228,14 +227,15 @@ import type Transport from '@ledgerhq/hw-transport'; // type for the transporter In a Web DAPP, take care that some browsers are not compatible (FireFox, ...), and that the Bluetooth is not working in all cases and in all operating systems. -> [!NOTE] -> The last version of the Ledger Starknet APP (v1.1.1) only supports blind signing of the hash of your action. Sign only hashes from a code that you trust. +:::note +The last version of the Ledger Starknet APP (v1.1.1) only supports blind signing of the hash of your action. Sign only hashes from a code that you trust. +::: For example, for a Node script : ```typescript import TransportNodeHid from '@ledgerhq/hw-transport-node-hid'; -const myLedgerTransport = await TransportNodeHid.create(); +const myLedgerTransport: Transport = await TransportNodeHid.create(); const myLedgerSigner = new LedgerSigner(myLedgerTransport, 0); const pubK = await myLedgerSigner.getPubKey(); const fullPubK = await myLedgerSigner.getFullPubKey(); @@ -245,8 +245,9 @@ const fullPubK = await myLedgerSigner.getFullPubKey(); const ledgerAccount = new Account(myProvider, ledger0addr, myLedgerSigner); ``` -> [!IMPORTANT] -> The Ledger shall be connected, unlocked, with the Starknet internal APP activated, before launch of the script. +:::warning important +The Ledger shall be connected, unlocked, with the Starknet internal APP activated, before launch of the script. +::: Some complete examples : A Node script : [here](https://github.com/PhilippeR26/starknet.js-workshop-typescript/blob/main/src/scripts/ledgerNano/5.testLedgerAccount.ts). diff --git a/www/package-lock.json b/www/package-lock.json index f4ab1dede..e192e7cd8 100644 --- a/www/package-lock.json +++ b/www/package-lock.json @@ -4418,9 +4418,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001620", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz", - "integrity": "sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==", + "version": "1.0.30001650", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001650.tgz", + "integrity": "sha512-fgEc7hP/LB7iicdXHUI9VsBsMZmUmlVJeQP2qqQW+3lkqVhbmjEU8zp+h5stWeilX+G7uXuIUIIlWlDw9jdt8g==", "funding": [ { "type": "opencollective", @@ -4434,7 +4434,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/ccount": { "version": "1.1.0",