From 4dc6d324e505667a4820d1069ece35cf5488d1ac Mon Sep 17 00:00:00 2001 From: awkweb Date: Tue, 21 May 2024 17:54:38 -0400 Subject: [PATCH] feat: siwe (#2280) * feat: siwe * wip: check * wip: parse * refactor: comments and errors * chore: format * chore: add exports * test: utils * chore: format * refactor: time-related data types * refactor: rename utils * feat(siwe): action * chore: format * docs: siwe * chore: format * docs: up * chore: snaps * test: boost coverage --------- Co-authored-by: tmm --- .../docs/actions/public/verifyMessage.md | 4 +- .../docs/actions/public/verifyTypedData.md | 4 +- site/pages/docs/ens/utilities/labelhash.md | 4 +- site/pages/docs/glossary/errors.md | 6 + .../docs/siwe/actions/verifySiweMessage.md | 245 +++++++++++++ .../docs/siwe/utilities/createSiweMessage.md | 295 ++++++++++++++++ .../docs/siwe/utilities/generateSiweNonce.md | 27 ++ .../docs/siwe/utilities/parseSiweMessage.md | 52 +++ .../siwe/utilities/validateSiweMessage.md | 76 ++++ site/sidebar.ts | 36 ++ src/actions/public/verifyMessage.ts | 19 +- src/actions/siwe/verifySiweMessage.test.ts | 69 ++++ src/actions/siwe/verifySiweMessage.ts | 90 +++++ src/clients/createClient.test.ts | 1 + src/clients/createPublicClient.test.ts | 5 + src/clients/createTestClient.test.ts | 1 + src/clients/createWalletClient.test.ts | 1 + src/clients/decorators/public.test.ts | 26 ++ src/clients/decorators/public.ts | 37 ++ src/errors/siwe.test.ts | 26 ++ src/errors/siwe.ts | 20 ++ src/jsr.json | 2 +- src/package.json | 6 + src/siwe/index.ts | 29 ++ src/siwe/package.json | 6 + src/utils/siwe/createSiweMessage.test.ts | 334 ++++++++++++++++++ src/utils/siwe/createSiweMessage.ts | 168 +++++++++ src/utils/siwe/generateSiweNonce.test.ts | 8 + src/utils/siwe/generateSiweNonce.ts | 15 + src/utils/siwe/parseSiweMessage.test.ts | 169 +++++++++ src/utils/siwe/parseSiweMessage.ts | 55 +++ src/utils/siwe/types.ts | 61 ++++ src/utils/siwe/utils.test.ts | 42 +++ src/utils/siwe/utils.ts | 51 +++ src/utils/siwe/validateSiweMessage.test.ts | 109 ++++++ src/utils/siwe/validateSiweMessage.ts | 70 ++++ 36 files changed, 2153 insertions(+), 16 deletions(-) create mode 100644 site/pages/docs/siwe/actions/verifySiweMessage.md create mode 100644 site/pages/docs/siwe/utilities/createSiweMessage.md create mode 100644 site/pages/docs/siwe/utilities/generateSiweNonce.md create mode 100644 site/pages/docs/siwe/utilities/parseSiweMessage.md create mode 100644 site/pages/docs/siwe/utilities/validateSiweMessage.md create mode 100644 src/actions/siwe/verifySiweMessage.test.ts create mode 100644 src/actions/siwe/verifySiweMessage.ts create mode 100644 src/errors/siwe.test.ts create mode 100644 src/errors/siwe.ts create mode 100644 src/siwe/index.ts create mode 100644 src/siwe/package.json create mode 100644 src/utils/siwe/createSiweMessage.test.ts create mode 100644 src/utils/siwe/createSiweMessage.ts create mode 100644 src/utils/siwe/generateSiweNonce.test.ts create mode 100644 src/utils/siwe/generateSiweNonce.ts create mode 100644 src/utils/siwe/parseSiweMessage.test.ts create mode 100644 src/utils/siwe/parseSiweMessage.ts create mode 100644 src/utils/siwe/types.ts create mode 100644 src/utils/siwe/utils.test.ts create mode 100644 src/utils/siwe/utils.ts create mode 100644 src/utils/siwe/validateSiweMessage.test.ts create mode 100644 src/utils/siwe/validateSiweMessage.ts diff --git a/site/pages/docs/actions/public/verifyMessage.md b/site/pages/docs/actions/public/verifyMessage.md index e75b1316c8..4c32ccff3c 100644 --- a/site/pages/docs/actions/public/verifyMessage.md +++ b/site/pages/docs/actions/public/verifyMessage.md @@ -60,7 +60,7 @@ export const [account] = await walletClient.getAddresses() `boolean` -Wheather the signed message is valid for the given address. +Whether the signed message is valid for the given address. ## Parameters @@ -169,4 +169,4 @@ const valid = await publicClient.verifyMessage({ ## JSON-RPC Method -[`eth_call`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call) to a deployless [universal signature validator contract](https://eips.ethereum.org/EIPS/eip-6492). \ No newline at end of file +[`eth_call`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call) to a deployless [universal signature validator contract](https://eips.ethereum.org/EIPS/eip-6492). diff --git a/site/pages/docs/actions/public/verifyTypedData.md b/site/pages/docs/actions/public/verifyTypedData.md index 2fbf6034e6..0d170a7ddc 100644 --- a/site/pages/docs/actions/public/verifyTypedData.md +++ b/site/pages/docs/actions/public/verifyTypedData.md @@ -102,7 +102,7 @@ export const [account] = await walletClient.getAddresses() `boolean` -Wheather the signed message is valid for the given address. +Whether the signed message is valid for the given address. ## Parameters @@ -459,4 +459,4 @@ const valid = await publicClient.verifyTypedData({ ## JSON-RPC Method -[`eth_call`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call) to a deployless [universal signature validator contract](https://eips.ethereum.org/EIPS/eip-6492). \ No newline at end of file +[`eth_call`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_call) to a deployless [universal signature validator contract](https://eips.ethereum.org/EIPS/eip-6492). diff --git a/site/pages/docs/ens/utilities/labelhash.md b/site/pages/docs/ens/utilities/labelhash.md index 4fe38ec8a3..b484e51845 100644 --- a/site/pages/docs/ens/utilities/labelhash.md +++ b/site/pages/docs/ens/utilities/labelhash.md @@ -25,8 +25,6 @@ labelhash(normalize('awkweb')) // [!code focus:2] Since ENS names prohibit certain forbidden characters (e.g. underscore) and have other validation rules, you likely want to [normalize ENS labels](https://docs.ens.domains/contract-api-reference/name-processing#normalising-names) with [UTS-46 normalization](https://unicode.org/reports/tr46) before passing them to `labelhash`. You can use the built-in [`normalize`](/docs/ens/utilities/normalize) function for this. ::: -### - ## Returns `string` @@ -39,4 +37,4 @@ The hashed ENS label. - **Type:** `string` -A ENS label. \ No newline at end of file +A ENS label. diff --git a/site/pages/docs/glossary/errors.md b/site/pages/docs/glossary/errors.md index af52e31e4c..2f64303a99 100644 --- a/site/pages/docs/glossary/errors.md +++ b/site/pages/docs/glossary/errors.md @@ -137,6 +137,12 @@ When address is invalid. ### `UnsupportedProviderMethodError` ### `UserRejectedRequestError` +## SIWE + +### CreateSiweMessageErrorType +### SiweInvalidMessageFieldErrorType +### VerifySiweMessageErrorType + ## Transaction ### `FeeConflictError` diff --git a/site/pages/docs/siwe/actions/verifySiweMessage.md b/site/pages/docs/siwe/actions/verifySiweMessage.md new file mode 100644 index 0000000000..2bbdb5ce58 --- /dev/null +++ b/site/pages/docs/siwe/actions/verifySiweMessage.md @@ -0,0 +1,245 @@ +--- +description: Verifies EIP-4361 formatted message was signed. +--- + +# verifySiweMessage + +Verifies [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message was signed. + +See [`createSiweMessage`](/docs/siwe/utilities/createSiweMessage) for info on how to create a EIP-4361 formatted message. + +## Usage + +:::code-group + +```ts twoslash [example.ts] +import { account, walletClient, publicClient } from './client' +import { message } from './message' + +const signature = await walletClient.signMessage({ account, message }) +// [!code focus:99] +const valid = await publicClient.verifySiweMessage({ + message, + signature, +}) +// @log: true +``` + +```ts twoslash [client.ts] filename="client.ts" +import 'viem/window' +// ---cut--- +import { createPublicClient, createWalletClient, custom, http } from 'viem' +import { mainnet } from 'viem/chains' + +export const publicClient = createPublicClient({ + chain: mainnet, + transport: http() +}) + +export const walletClient = createWalletClient({ + transport: custom(window.ethereum!) +}) + +// @log: ↓ JSON-RPC Account +export const [account] = await walletClient.getAddresses() + +// @log: ↓ Local Account +// export const account = privateKeyToAccount(...) +``` + +```ts twoslash [message.ts] filename="message.ts" +// ---cut--- +import { createSiweMessage, generateSiweNonce } from 'viem/siwe' +import { mainnet } from 'viem/chains' +import { account } from './client' + +export const message = createSiweMessage({ + address: account.address, + chainId: mainnet.id, + domain: 'example.com', + nonce: generateSiweNonce(), + uri: 'https://example.com/path', + version: '1', +}) +``` + +::: + +## Returns + +`boolean` + +Whether the signed message is valid for the given address. + +## Parameters + +### message + +- **Type:** `string` + +[EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message to be verified. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +import { createSiweMessage, generateSiweNonce } from 'viem/siwe' +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + message: createSiweMessage({ // [!code focus:1] + address: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', // [!code focus:1] + chainId: 1, // [!code focus:1] + domain: 'example.com', // [!code focus:1] + nonce: generateSiweNonce(), // [!code focus:1] + uri: 'https://example.com/path', // [!code focus:1] + version: '1', // [!code focus:1] + }), // [!code focus:1] + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + +### signature + +- **Type:** `Hex` + +The signature that was generated by signing the message with the address's signer. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', // [!code focus:1] +}) +``` + +### address (optional) + +- **Type:** `Address` + +Ethereum address to check against. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + address: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', // [!code focus:1] + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + +### blockNumber (optional) + +- **Type:** `number` + +Only used when verifying a message that was signed by a Smart Contract Account. The block number to check if the contract was already deployed. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + blockNumber: 42069n, // [!code focus:1] + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + +### blockTag (optional) + +- **Type:** `'latest' | 'earliest' | 'pending' | 'safe' | 'finalized'` +- **Default:** `'latest'` + +Only used when verifying a message that was signed by a Smart Contract Account. The block tag to check if the contract was already deployed. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + blockTag: 'safe', // [!code focus:1] + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + +### domain (optional) + +- **Type:** `string` + +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) authority to check against. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + domain: 'viem.sh', // [!code focus:1] + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + +### nonce (optional) + +- **Type:** `string` + +Random string to check against. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +import { generateSiweNonce } from 'viem/siwe' +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + nonce: generateSiweNonce(), // [!code focus:1] + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + +### scheme (optional) + +- **Type:** `string` + +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) URI scheme to check against. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + scheme: 'https', // [!code focus:1] + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + +### time (optional) + +- **Type:** `Date` +- **Default:** `new Date()` + +Current time to check optional [`expirationTime`](http://localhost:5173/docs/siwe/utilities/createSiweMessage#expirationtime-optional) and [`notBefore`](/docs/siwe/utilities/createSiweMessage#notbefore-optional) message fields. + +```ts twoslash +// [!include ~/snippets/publicClient.ts] +declare const message: string +// ---cut--- +const valid = await publicClient.verifySiweMessage({ + time: new Date(), // [!code focus:1] + message, + signature: + '0x66edc32e2ab001213321ab7d959a2207fcef5190cc9abb6da5b0d2a8a9af2d4d2b0700e2c317c4106f337fd934fbbb0bf62efc8811a78603b33a8265d3b8f8cb1c', +}) +``` + diff --git a/site/pages/docs/siwe/utilities/createSiweMessage.md b/site/pages/docs/siwe/utilities/createSiweMessage.md new file mode 100644 index 0000000000..2f3ee31a76 --- /dev/null +++ b/site/pages/docs/siwe/utilities/createSiweMessage.md @@ -0,0 +1,295 @@ +--- +description: Creates EIP-4361 formatted message. +--- + +# createSiweMessage + +Creates [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message. + +## Import + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' +``` + +## Usage + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', +}) +``` + +## Returns + +`string` + +EIP-4361 formatted message. + +## Parameters + +### address + +- **Type:** `Address` + +The Ethereum address performing the signing. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', // [!code focus] + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', +}) +``` + +### chainId + +- **Type:** `number` + +The [EIP-155](https://eips.ethereum.org/EIPS/eip-155) Chain ID to which the session is bound. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + chainId: 1, // [!code focus] + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', +}) +``` + +### domain + +- **Type:** `string` + +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) authority that is requesting the signing. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', // [!code focus] + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', +}) +``` + +### nonce + +- **Type:** `string` + +A random string typically chosen by the relying party and used to prevent replay attacks. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', // [!code focus] + uri: 'https://example.com/path', + version: '1', +}) +``` + +### uri + +- **Type:** `string` + +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) URI referring to the resource that is the subject of the signing (as in the subject of a claim). + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', // [!code focus] + version: '1', +}) +``` + +### version + +- **Type:** `'1'` + +The current version of the SIWE Message. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', // [!code focus] +}) +``` + +### expirationTime (optional) + +- **Type:** `Date` + +Time when the signed authentication message is no longer valid. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + expirationTime: new Date(), // [!code focus] +}) +``` + +### issuedAt (optional) + +- **Type:** `Date` + +Time when the message was generated, typically the current time. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + issuedAt: new Date(), // [!code focus] +}) +``` + +### notBefore (optional) + +- **Type:** `Date` + +Time when the signed authentication message will become valid. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + notBefore: new Date(), // [!code focus] +}) +``` + +### requestId (optional) + +- **Type:** `string` + +A system-specific identifier that may be used to uniquely refer to the sign-in request. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + requestId: '123e4567-e89b-12d3-a456-426614174000', // [!code focus] +}) +``` + +### resources (optional) + +- **Type:** `string[]` + +A list of information or references to information the user wishes to have resolved as part of authentication by the relying party. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + resources: [ // [!code focus] + 'https://example.com/foo', // [!code focus] + 'https://example.com/bar', // [!code focus] + 'https://example.com/baz', // [!code focus] + ], // [!code focus] +}) +``` + +### scheme (optional) + +- **Type:** `string` + +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) URI scheme of the origin of the request. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + scheme: 'https', // [!code focus] +}) +``` + +### statement (optional) + +- **Type:** `string` + +A human-readable ASCII assertion that the user will sign. + +```ts twoslash +import { createSiweMessage } from 'viem/siwe' + +const message = createSiweMessage({ + address: '0xa0cf798816d4b9b9866b5330eea46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + statement: 'I accept the ExampleOrg Terms of Service: https://example.com/tos', // [!code focus] +}) +``` + diff --git a/site/pages/docs/siwe/utilities/generateSiweNonce.md b/site/pages/docs/siwe/utilities/generateSiweNonce.md new file mode 100644 index 0000000000..9fcbf575a6 --- /dev/null +++ b/site/pages/docs/siwe/utilities/generateSiweNonce.md @@ -0,0 +1,27 @@ +--- +description: Generates random EIP-4361 nonce. +--- + +# generateSiweNonce + +Generates random [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) nonce. + +## Import + +```ts twoslash +import { generateSiweNonce } from 'viem/siwe' +``` + +## Usage + +```ts twoslash +import { generateSiweNonce } from 'viem/siwe' + +const nonce = generateSiweNonce() +``` + +## Returns + +`string` + +A randomly generated EIP-4361 nonce. diff --git a/site/pages/docs/siwe/utilities/parseSiweMessage.md b/site/pages/docs/siwe/utilities/parseSiweMessage.md new file mode 100644 index 0000000000..7410712743 --- /dev/null +++ b/site/pages/docs/siwe/utilities/parseSiweMessage.md @@ -0,0 +1,52 @@ +--- +description: Parses EIP-4361 formatted message into message fields object. +--- + +# parseSiweMessage + +Parses [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message into message fields object. + +## Import + +```ts twoslash +import { parseSiweMessage } from 'viem/siwe' +``` + +## Usage + +```ts twoslash +import { parseSiweMessage } from 'viem/siwe' + +const message = `example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +I accept the ExampleOrg Terms of Service: https://example.com/tos + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z` +const fields = parseSiweMessage(message) +fields.address +// ^? + + + +``` + +## Returns + +`SiweMessage` + +EIP-4361 fields object + +## Parameters + +### message + +- **Type:** `string` + +EIP-4361 formatted message + + diff --git a/site/pages/docs/siwe/utilities/validateSiweMessage.md b/site/pages/docs/siwe/utilities/validateSiweMessage.md new file mode 100644 index 0000000000..6bfc4b30b0 --- /dev/null +++ b/site/pages/docs/siwe/utilities/validateSiweMessage.md @@ -0,0 +1,76 @@ +--- +description: Validates EIP-4361 message. +--- + +# validateSiweMessage + +Validates [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) message. + +## Import + +```ts twoslash +import { validateSiweMessage } from 'viem/siwe' +``` + +## Usage + +```ts twoslash +import { validateSiweMessage } from 'viem/siwe' + +const valid = validateSiweMessage({ + address: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + message: { + address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + }, +}) +``` + +## Returns + +`boolean` + +Whether the message fields are valid. + +## Parameters + +### message + +- **Type:** `Partial` + +EIP-4361 message fields. + +### address (optional) + +- **Type:** `string` + +Ethereum address to check against. + +### domain (optional) + +- **Type:** `string` + +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) authority to check against. + +### nonce (optional) + +- **Type:** `string` + +Random string to check against. + +### scheme (optional) + +- **Type:** `string` + +[RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) URI scheme to check against. + +### time (optional) + +- **Type:** `Date` +- **Default:** `new Date()` + +Current time to check optional [`expirationTime`](http://localhost:5173/docs/siwe/utilities/createSiweMessage#expirationtime-optional) and [`notBefore`](/docs/siwe/utilities/createSiweMessage#notbefore-optional) message fields. diff --git a/site/sidebar.ts b/site/sidebar.ts index 0aed8371b2..5e32d88d1f 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -642,6 +642,42 @@ export const sidebar = { }, ], }, + { + text: 'SIWE', + collapsed: true, + items: [ + { + text: 'Actions', + items: [ + { + text: 'verifySiweMessage', + link: '/docs/siwe/actions/verifySiweMessage', + }, + ], + }, + { + text: 'Utilities', + items: [ + { + text: 'createSiweMessage', + link: '/docs/siwe/utilities/createSiweMessage', + }, + { + text: 'generateSiweNonce', + link: '/docs/siwe/utilities/generateSiweNonce', + }, + { + text: 'parseSiweMessage', + link: '/docs/siwe/utilities/parseSiweMessage', + }, + { + text: 'validateSiweMessage', + link: '/docs/siwe/utilities/validateSiweMessage', + }, + ], + }, + ], + }, { text: 'ABI', collapsed: true, diff --git a/src/actions/public/verifyMessage.ts b/src/actions/public/verifyMessage.ts index fdc9ab6edd..ed2c4e1ebc 100644 --- a/src/actions/public/verifyMessage.ts +++ b/src/actions/public/verifyMessage.ts @@ -10,6 +10,7 @@ import type { SignableMessage, Signature, } from '../../types/misc.js' +import type { Prettify } from '../../types/utils.js' import { hashMessage } from '../../utils/signature/hashMessage.js' import type { HashMessageErrorType } from '../../utils/signature/hashMessage.js' import { @@ -18,14 +19,16 @@ import { verifyHash, } from './verifyHash.js' -export type VerifyMessageParameters = Omit & { - /** The address that signed the original message. */ - address: Address - /** The message to be verified. */ - message: SignableMessage - /** The signature that was generated by signing the message with the address's private key. */ - signature: Hex | ByteArray | Signature -} +export type VerifyMessageParameters = Prettify< + Omit & { + /** The address that signed the original message. */ + address: Address + /** The message to be verified. */ + message: SignableMessage + /** The signature that was generated by signing the message with the address's private key. */ + signature: Hex | ByteArray | Signature + } +> export type VerifyMessageReturnType = boolean diff --git a/src/actions/siwe/verifySiweMessage.test.ts b/src/actions/siwe/verifySiweMessage.test.ts new file mode 100644 index 0000000000..b1c0877187 --- /dev/null +++ b/src/actions/siwe/verifySiweMessage.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from 'vitest' + +import { accounts } from '~test/src/constants.js' +import { mainnetClient } from '~test/src/utils.js' + +import { signMessage } from '../../accounts/utils/signMessage.js' +import { createSiweMessage } from '../../utils/siwe/createSiweMessage.js' +import { verifySiweMessage } from './verifySiweMessage.js' + +const account = accounts[0] + +test('default', async () => { + const message = createSiweMessage({ + address: account.address, + chainId: mainnetClient.chain.id, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + }) + + const signature = await signMessage({ + message, + privateKey: account.privateKey, + }) + + expect( + await verifySiweMessage(mainnetClient, { + message, + signature, + }), + ).toBeTruthy() +}) + +test('behavior: invalid message fields', async () => { + const message = createSiweMessage({ + address: account.address, + chainId: mainnetClient.chain.id, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + }) + const signature = await signMessage({ + message, + privateKey: account.privateKey, + }) + expect( + await verifySiweMessage(mainnetClient, { + domain: 'viem.sh', + message, + signature, + }), + ).toBeFalsy() +}) + +test('behavior: invalid message', async () => { + const message = 'foobarbaz' + const signature = await signMessage({ + message, + privateKey: account.privateKey, + }) + expect( + await verifySiweMessage(mainnetClient, { + message, + signature, + }), + ).toBeFalsy() +}) diff --git a/src/actions/siwe/verifySiweMessage.ts b/src/actions/siwe/verifySiweMessage.ts new file mode 100644 index 0000000000..6156a7a1a2 --- /dev/null +++ b/src/actions/siwe/verifySiweMessage.ts @@ -0,0 +1,90 @@ +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Chain } from '../../types/chain.js' +import type { Hex } from '../../types/misc.js' +import type { Prettify } from '../../types/utils.js' +import { hashMessage } from '../../utils/signature/hashMessage.js' +import type { HashMessageErrorType } from '../../utils/signature/hashMessage.js' +import { parseSiweMessage } from '../../utils/siwe/parseSiweMessage.js' +import { + type ValidateSiweMessageParameters, + validateSiweMessage, +} from '../../utils/siwe/validateSiweMessage.js' +import { + type VerifyHashErrorType, + type VerifyHashParameters, + verifyHash, +} from '../public/verifyHash.js' + +export type VerifySiweMessageParameters = Prettify< + Pick & + Pick< + ValidateSiweMessageParameters, + 'address' | 'domain' | 'nonce' | 'scheme' | 'time' + > & { + /** + * EIP-4361 formatted message. + */ + message: string + /** + * Signature to check against. + */ + signature: Hex + } +> + +export type VerifySiweMessageReturnType = boolean + +export type VerifySiweMessageErrorType = + | HashMessageErrorType + | VerifyHashErrorType + | ErrorType + +/** + * Verifies [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message was signed. + * + * Compatible with Smart Contract Accounts & Externally Owned Accounts via [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). + * + * - Docs {@link https://viem.sh/docs/siwe/actions/verifySiweMessage} + * + * @param client - Client to use. + * @param parameters - {@link VerifySiweMessageParameters} + * @returns Whether or not the signature is valid. {@link VerifySiweMessageReturnType} + */ +export async function verifySiweMessage( + client: Client, + parameters: VerifySiweMessageParameters, +): Promise { + const { + address, + domain, + message, + nonce, + scheme, + signature, + time = new Date(), + ...callRequest + } = parameters + + const parsed = parseSiweMessage(message) + if (!parsed.address) return false + + const isValid = validateSiweMessage({ + address, + domain, + message: parsed, + nonce, + scheme, + time, + }) + if (!isValid) return false + + const hash = hashMessage(message) + return verifyHash(client, { + address: parsed.address, + hash, + signature, + ...callRequest, + }) +} diff --git a/src/clients/createClient.test.ts b/src/clients/createClient.test.ts index aff6420128..ba1a2e4b95 100644 --- a/src/clients/createClient.test.ts +++ b/src/clients/createClient.test.ts @@ -544,6 +544,7 @@ describe('extends', () => { "type": "base", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchBlockNumber": [Function], diff --git a/src/clients/createPublicClient.test.ts b/src/clients/createPublicClient.test.ts index 865e0ce41c..d5fb8fb454 100644 --- a/src/clients/createPublicClient.test.ts +++ b/src/clients/createPublicClient.test.ts @@ -89,6 +89,7 @@ test('creates', () => { "type": "publicClient", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchBlockNumber": [Function], @@ -226,6 +227,7 @@ describe('transports', () => { "type": "publicClient", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchBlockNumber": [Function], @@ -327,6 +329,7 @@ describe('transports', () => { "type": "publicClient", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchBlockNumber": [Function], @@ -406,6 +409,7 @@ describe('transports', () => { "type": "publicClient", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchBlockNumber": [Function], @@ -549,6 +553,7 @@ test('extend', () => { "type": "publicClient", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchAsset": [Function], diff --git a/src/clients/createTestClient.test.ts b/src/clients/createTestClient.test.ts index ac07fa576a..e9f7829cf3 100644 --- a/src/clients/createTestClient.test.ts +++ b/src/clients/createTestClient.test.ts @@ -413,6 +413,7 @@ test('extend', () => { "type": "testClient", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchAsset": [Function], diff --git a/src/clients/createWalletClient.test.ts b/src/clients/createWalletClient.test.ts index 0925fa145e..8ded4060fb 100644 --- a/src/clients/createWalletClient.test.ts +++ b/src/clients/createWalletClient.test.ts @@ -495,6 +495,7 @@ test('extend', () => { "type": "walletClient", "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchAsset": [Function], diff --git a/src/clients/decorators/public.test.ts b/src/clients/decorators/public.test.ts index 075408ad9c..a8026f1e6f 100644 --- a/src/clients/decorators/public.test.ts +++ b/src/clients/decorators/public.test.ts @@ -11,6 +11,7 @@ import { parseEther } from '../../utils/unit/parseEther.js' import { anvilMainnet } from '../../../test/src/anvil.js' import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { signMessage } from '../../accounts/utils/signMessage.js' import { mine, reset, @@ -18,6 +19,7 @@ import { signTransaction, } from '../../actions/index.js' import { base } from '../../chains/index.js' +import { createSiweMessage } from '../../utils/siwe/createSiweMessage.js' import { wait } from '../../utils/wait.js' import { createPublicClient } from '../createPublicClient.js' import { http } from '../transports/http.js' @@ -68,6 +70,7 @@ test('default', async () => { "simulateContract": [Function], "uninstallFilter": [Function], "verifyMessage": [Function], + "verifySiweMessage": [Function], "verifyTypedData": [Function], "waitForTransactionReceipt": [Function], "watchBlockNumber": [Function], @@ -450,6 +453,29 @@ describe('smoke test', () => { ).toBe(true) }) + test('verifySiweMessage', async () => { + const account = accounts[0] + const message = createSiweMessage({ + address: account.address, + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', + }) + const signature = await signMessage({ + message, + privateKey: account.privateKey, + }) + + expect( + await client.verifySiweMessage({ + message, + signature, + }), + ).toBe(true) + }) + test('verifyTypedData', async () => { expect( await client.verifyTypedData({ diff --git a/src/clients/decorators/public.ts b/src/clients/decorators/public.ts index c72f5f4f2e..f48ec22dc4 100644 --- a/src/clients/decorators/public.ts +++ b/src/clients/decorators/public.ts @@ -220,6 +220,11 @@ import { type WatchPendingTransactionsReturnType, watchPendingTransactions, } from '../../actions/public/watchPendingTransactions.js' +import { + type VerifySiweMessageParameters, + type VerifySiweMessageReturnType, + verifySiweMessage, +} from '../../actions/siwe/verifySiweMessage.js' import { type PrepareTransactionRequestParameters, type PrepareTransactionRequestRequest, @@ -1511,9 +1516,40 @@ export type PublicActions< accountOverride > > + /** + * Verify that a message was signed by the provided address. + * + * Compatible with Smart Contract Accounts & Externally Owned Accounts via [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). + * + * - Docs {@link https://viem.sh/docs/actions/public/verifyMessage} + * + * @param parameters - {@link VerifyMessageParameters} + * @returns Whether or not the signature is valid. {@link VerifyMessageReturnType} + */ verifyMessage: ( args: VerifyMessageParameters, ) => Promise + /** + * Verifies [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message was signed. + * + * Compatible with Smart Contract Accounts & Externally Owned Accounts via [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). + * + * - Docs {@link https://viem.sh/docs/siwe/actions/verifySiweMessage} + * + * @param parameters - {@link VerifySiweMessageParameters} + * @returns Whether or not the signature is valid. {@link VerifySiweMessageReturnType} + */ + verifySiweMessage: ( + args: VerifySiweMessageParameters, + ) => Promise + /** + * Verify that typed data was signed by the provided address. + * + * - Docs {@link https://viem.sh/docs/actions/public/verifyTypedData} + * + * @param parameters - {@link VerifyTypedDataParameters} + * @returns Whether or not the signature is valid. {@link VerifyTypedDataReturnType} + */ verifyTypedData: ( args: VerifyTypedDataParameters, ) => Promise @@ -1807,6 +1843,7 @@ export function publicActions< sendRawTransaction: (args) => sendRawTransaction(client, args), simulateContract: (args) => simulateContract(client, args), verifyMessage: (args) => verifyMessage(client, args), + verifySiweMessage: (args) => verifySiweMessage(client, args), verifyTypedData: (args) => verifyTypedData(client, args), uninstallFilter: (args) => uninstallFilter(client, args), waitForTransactionReceipt: (args) => diff --git a/src/errors/siwe.test.ts b/src/errors/siwe.test.ts new file mode 100644 index 0000000000..147278629e --- /dev/null +++ b/src/errors/siwe.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vitest' + +import { SiweInvalidMessageFieldError } from './siwe.js' + +test('SiweInvalidMessageFieldError', () => { + expect( + new SiweInvalidMessageFieldError({ + field: 'nonce', + metaMessages: [ + '- Nonce must be at least 8 characters.', + '- Nonce must be alphanumeric.', + '', + 'Provided value: foobarbaz$', + ], + }), + ).toMatchInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "nonce". + + - Nonce must be at least 8 characters. + - Nonce must be alphanumeric. + + Provided value: foobarbaz$ + + Version: viem@1.0.2] + `) +}) diff --git a/src/errors/siwe.ts b/src/errors/siwe.ts new file mode 100644 index 0000000000..c274b25f4d --- /dev/null +++ b/src/errors/siwe.ts @@ -0,0 +1,20 @@ +import { BaseError } from './base.js' + +export type SiweInvalidMessageFieldErrorType = SiweInvalidMessageFieldError & { + name: 'SiweInvalidMessageFieldError' +} +export class SiweInvalidMessageFieldError extends BaseError { + override name = 'SiweInvalidMessageFieldError' + constructor(parameters: { + docsPath?: string | undefined + field: string + metaMessages?: string[] | undefined + }) { + const { docsPath, field, metaMessages } = parameters + super(`Invalid Sign-In with Ethereum message field "${field}".`, { + docsPath, + docsSlug: 'TODO', + metaMessages, + }) + } +} diff --git a/src/jsr.json b/src/jsr.json index 0a7c04c8e3..1d5029956e 100644 --- a/src/jsr.json +++ b/src/jsr.json @@ -16,7 +16,7 @@ "./zksync": "./zksync/index.ts" }, "publish": { - "include": ["LICENSE", "README.md", "**/*.ts"], + "include": ["LICENSE", "README.md", "CHANGELOG.md", "**/*.ts"], "exclude": ["**/*.bench.ts", "**/*.test.ts", "**/*.test-d.ts"] } } diff --git a/src/package.json b/src/package.json index 1ee6696c47..ec6ad22f80 100644 --- a/src/package.json +++ b/src/package.json @@ -69,6 +69,11 @@ "import": "./_esm/op-stack/index.js", "default": "./_cjs/op-stack/index.js" }, + "./siwe": { + "types": "./_types/siwe/index.d.ts", + "import": "./_esm/siwe/index.js", + "default": "./_cjs/siwe/index.js" + }, "./utils": { "types": "./_types/utils/index.d.ts", "import": "./_esm/utils/index.js", @@ -97,6 +102,7 @@ "experimental": ["./_types/experimental/index.d.ts"], "node": ["./_types/node/index.d.ts"], "op-stack": ["./_types/op-stack/index.d.ts"], + "siwe": ["./_types/siwe/index.d.ts"], "utils": ["./_types/utils/index.d.ts"], "window": ["./_types/window/index.d.ts"], "zksync": ["./_types/zksync/index.d.ts"] diff --git a/src/siwe/index.ts b/src/siwe/index.ts new file mode 100644 index 0000000000..cd9ac6bbfe --- /dev/null +++ b/src/siwe/index.ts @@ -0,0 +1,29 @@ +export { + verifySiweMessage, + type VerifySiweMessageParameters, + type VerifySiweMessageReturnType, + type VerifySiweMessageErrorType, +} from '../actions/siwe/verifySiweMessage.js' + +export { + createSiweMessage, + type CreateSiweMessageParameters, + type CreateSiweMessageReturnType, + type CreateSiweMessageErrorType, +} from '../utils/siwe/createSiweMessage.js' + +export { generateSiweNonce } from '../utils/siwe/generateSiweNonce.js' +export { parseSiweMessage } from '../utils/siwe/parseSiweMessage.js' + +export { + validateSiweMessage, + type ValidateSiweMessageParameters, + type ValidateSiweMessageReturnType, +} from '../utils/siwe/validateSiweMessage.js' + +export type { SiweMessage } from '../utils/siwe/types.js' + +export { + type SiweInvalidMessageFieldErrorType, + SiweInvalidMessageFieldError, +} from '../errors/siwe.js' diff --git a/src/siwe/package.json b/src/siwe/package.json new file mode 100644 index 0000000000..c5a1c05489 --- /dev/null +++ b/src/siwe/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "types": "../_types/siwe/index.d.ts", + "module": "../_esm/siwe/index.js", + "main": "../_cjs/siwe/index.js" +} diff --git a/src/utils/siwe/createSiweMessage.test.ts b/src/utils/siwe/createSiweMessage.test.ts new file mode 100644 index 0000000000..9012a4be95 --- /dev/null +++ b/src/utils/siwe/createSiweMessage.test.ts @@ -0,0 +1,334 @@ +import { expect, test, vi } from 'vitest' + +import { mainnet } from '../../chains/definitions/mainnet.js' +import { createSiweMessage } from './createSiweMessage.js' +import type { SiweMessage } from './types.js' + +const message = { + address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + chainId: mainnet.id, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', +} satisfies SiweMessage + +test('default', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect(createSiweMessage(message)).toMatchInlineSnapshot(` + "example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2023-02-01T00:00:00.000Z" + `) + + vi.useRealTimers() +}) + +test('parameters: scheme', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + createSiweMessage({ + ...message, + scheme: 'https', + }), + ).toMatchInlineSnapshot(` + "https://example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2023-02-01T00:00:00.000Z" + `) + + vi.useRealTimers() +}) + +test('parameters: statement', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + createSiweMessage({ + ...message, + statement: + 'I accept the ExampleOrg Terms of Service: https://example.com/tos', + }), + ).toMatchInlineSnapshot(` + "example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + I accept the ExampleOrg Terms of Service: https://example.com/tos + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2023-02-01T00:00:00.000Z" + `) + + vi.useRealTimers() +}) + +test('parameters: issuedAt', () => { + const issuedAt = new Date(Date.UTC(2022, 1, 4)) + expect(createSiweMessage({ ...message, issuedAt })).toMatchInlineSnapshot(` + "example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2022-02-04T00:00:00.000Z" + `) +}) + +test('parameters: expirationTime', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + createSiweMessage({ + ...message, + expirationTime: new Date(Date.UTC(2022, 1, 4)), + }), + ).toMatchInlineSnapshot(` + "example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2023-02-01T00:00:00.000Z + Expiration Time: 2022-02-04T00:00:00.000Z" + `) + + vi.useRealTimers() +}) + +test('parameters: notBefore', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + createSiweMessage({ + ...message, + notBefore: new Date(Date.UTC(2022, 1, 4)), + }), + ).toMatchInlineSnapshot(` + "example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2023-02-01T00:00:00.000Z + Not Before: 2022-02-04T00:00:00.000Z" + `) + + vi.useRealTimers() +}) + +test('parameters: requestId', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + createSiweMessage({ + ...message, + requestId: '123e4567-e89b-12d3-a456-426614174000', + }), + ).toMatchInlineSnapshot(` + "example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2023-02-01T00:00:00.000Z + Request ID: 123e4567-e89b-12d3-a456-426614174000" + `) + + vi.useRealTimers() +}) + +test('parameters: resources', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + createSiweMessage({ + ...message, + resources: [ + 'https://example.com/foo', + 'https://example.com/bar', + 'https://example.com/baz', + ], + }), + ).toMatchInlineSnapshot(` + "example.com wants you to sign in with your Ethereum account: + 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + + URI: https://example.com/path + Version: 1 + Chain ID: 1 + Nonce: foobarbaz + Issued At: 2023-02-01T00:00:00.000Z + Resources: + - https://example.com/foo + - https://example.com/bar + - https://example.com/baz" + `) + + vi.useRealTimers() +}) + +test('behavior: invalid address', () => { + expect(() => + createSiweMessage({ ...message, address: '0xfoobarbaz' }), + ).toThrowErrorMatchingInlineSnapshot(` + [InvalidAddressError: Address "0xfoobarbaz" is invalid. + + - Address must be a hex value of 20 bytes (40 hex characters). + - Address must match its checksum counterpart. + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid chainId', () => { + expect(() => + createSiweMessage({ ...message, chainId: 1.1 }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "chainId". + + - Chain ID must be a EIP-155 chain ID. + - See https://eips.ethereum.org/EIPS/eip-155 + + Provided value: 1.1 + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid domain', () => { + expect(() => + createSiweMessage({ ...message, domain: '#foo' }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "domain". + + - Domain must be an RFC 3986 authority. + - See https://www.rfc-editor.org/rfc/rfc3986 + + Provided value: #foo + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid nonce', () => { + expect(() => + createSiweMessage({ ...message, nonce: '#foo' }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "nonce". + + - Nonce must be at least 8 characters. + - Nonce must be alphanumeric. + + Provided value: #foo + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid uri', () => { + expect(() => + createSiweMessage({ ...message, uri: '#foo' }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "uri". + + - URI must be a RFC 3986 URI referring to the resource that is the subject of the signing. + - See https://www.rfc-editor.org/rfc/rfc3986 + + Provided value: #foo + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid version', () => { + expect(() => + // @ts-expect-error + createSiweMessage({ ...message, version: '2' }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "version". + + - Version must be '1'. + + Provided value: 2 + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid scheme', () => { + expect(() => + createSiweMessage({ ...message, scheme: 'foo_bar' }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "scheme". + + - Scheme must be an RFC 3986 URI scheme. + - See https://www.rfc-editor.org/rfc/rfc3986#section-3.1 + + Provided value: foo_bar + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid statement', () => { + expect(() => + createSiweMessage({ ...message, statement: 'foo\nbar' }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "statement". + + - Statement must not include '\\n'. + + Provided value: foo + bar + + Version: viem@1.0.2] + `) +}) + +test('behavior: invalid resources', () => { + expect(() => + createSiweMessage({ + ...message, + resources: ['https://example.com', 'foo'], + }), + ).toThrowErrorMatchingInlineSnapshot(` + [SiweInvalidMessageFieldError: Invalid Sign-In with Ethereum message field "resources". + + - Every resource must be a RFC 3986 URI. + - See https://www.rfc-editor.org/rfc/rfc3986 + + Provided value: foo + + Version: viem@1.0.2] + `) +}) diff --git a/src/utils/siwe/createSiweMessage.ts b/src/utils/siwe/createSiweMessage.ts new file mode 100644 index 0000000000..1ebb729138 --- /dev/null +++ b/src/utils/siwe/createSiweMessage.ts @@ -0,0 +1,168 @@ +import { + SiweInvalidMessageFieldError, + type SiweInvalidMessageFieldErrorType, +} from '../../errors/siwe.js' +import type { ErrorType } from '../../errors/utils.js' +import { type GetAddressErrorType, getAddress } from '../address/getAddress.js' +import type { SiweMessage } from './types.js' +import { isUri } from './utils.js' + +export type CreateSiweMessageParameters = SiweMessage + +export type CreateSiweMessageReturnType = string + +export type CreateSiweMessageErrorType = + | GetAddressErrorType + | SiweInvalidMessageFieldErrorType + | ErrorType + +/** + * @description Creates EIP-4361 formatted message. + * + * @example + * const message = createMessage({ + * address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * chainId: 1, + * domain: 'example.com', + * nonce: 'foobarbaz', + * uri: 'https://example.com/path', + * version: '1', + * }) + * + * @see https://eips.ethereum.org/EIPS/eip-4361 + */ +export function createSiweMessage( + parameters: CreateSiweMessageParameters, +): CreateSiweMessageReturnType { + const { + chainId, + domain, + expirationTime, + issuedAt = new Date(), + nonce, + notBefore, + requestId, + resources, + scheme, + uri, + version, + } = parameters + + // Validate fields + { + // Required fields + if (chainId !== Math.floor(chainId)) + throw new SiweInvalidMessageFieldError({ + field: 'chainId', + metaMessages: [ + '- Chain ID must be a EIP-155 chain ID.', + '- See https://eips.ethereum.org/EIPS/eip-155', + '', + `Provided value: ${chainId}`, + ], + }) + if (!domainRegex.test(domain)) + throw new SiweInvalidMessageFieldError({ + field: 'domain', + metaMessages: [ + '- Domain must be an RFC 3986 authority.', + '- See https://www.rfc-editor.org/rfc/rfc3986', + '', + `Provided value: ${domain}`, + ], + }) + if (!nonceRegex.test(nonce)) + throw new SiweInvalidMessageFieldError({ + field: 'nonce', + metaMessages: [ + '- Nonce must be at least 8 characters.', + '- Nonce must be alphanumeric.', + '', + `Provided value: ${nonce}`, + ], + }) + if (!isUri(uri)) + throw new SiweInvalidMessageFieldError({ + field: 'uri', + metaMessages: [ + '- URI must be a RFC 3986 URI referring to the resource that is the subject of the signing.', + '- See https://www.rfc-editor.org/rfc/rfc3986', + '', + `Provided value: ${uri}`, + ], + }) + if (version !== '1') + throw new SiweInvalidMessageFieldError({ + field: 'version', + metaMessages: [ + "- Version must be '1'.", + '', + `Provided value: ${version}`, + ], + }) + + // Optional fields + if (scheme && !schemeRegex.test(scheme)) + throw new SiweInvalidMessageFieldError({ + field: 'scheme', + metaMessages: [ + '- Scheme must be an RFC 3986 URI scheme.', + '- See https://www.rfc-editor.org/rfc/rfc3986#section-3.1', + '', + `Provided value: ${scheme}`, + ], + }) + const statement = parameters.statement + if (statement?.includes('\n')) + throw new SiweInvalidMessageFieldError({ + field: 'statement', + metaMessages: [ + "- Statement must not include '\\n'.", + '', + `Provided value: ${statement}`, + ], + }) + } + + // Construct message + const address = getAddress(parameters.address) + const origin = (() => { + if (scheme) return `${scheme}://${domain}` + return domain + })() + const statement = (() => { + if (!parameters.statement) return '' + return `\n${parameters.statement}\n` + })() + const prefix = `${origin} wants you to sign in with your Ethereum account:\n${address}\n${statement}` + + let suffix = `URI: ${uri}\nVersion: ${version}\nChain ID: ${chainId}\nNonce: ${nonce}\nIssued At: ${issuedAt.toISOString()}` + + if (expirationTime) + suffix += `\nExpiration Time: ${expirationTime.toISOString()}` + if (notBefore) suffix += `\nNot Before: ${notBefore.toISOString()}` + if (requestId) suffix += `\nRequest ID: ${requestId}` + if (resources) { + let content = '\nResources:' + for (const resource of resources) { + if (!isUri(resource)) + throw new SiweInvalidMessageFieldError({ + field: 'resources', + metaMessages: [ + '- Every resource must be a RFC 3986 URI.', + '- See https://www.rfc-editor.org/rfc/rfc3986', + '', + `Provided value: ${resource}`, + ], + }) + content += `\n- ${resource}` + } + suffix += content + } + + return `${prefix}\n${suffix}` +} + +const domainRegex = /^(?:(?:(?!-)[a-zA-Z0-9-]{1,63}(? { + const nonce = generateSiweNonce() + expect(nonce.length).toMatchInlineSnapshot('96') +}) diff --git a/src/utils/siwe/generateSiweNonce.ts b/src/utils/siwe/generateSiweNonce.ts new file mode 100644 index 0000000000..967c86eb3b --- /dev/null +++ b/src/utils/siwe/generateSiweNonce.ts @@ -0,0 +1,15 @@ +import { uid } from '../../utils/uid.js' + +/** + * @description Generates random EIP-4361 nonce. + * + * @example + * const nonce = generateNonce() + * + * @see https://eips.ethereum.org/EIPS/eip-4361 + * + * @returns A randomly generated EIP-4361 nonce. + */ +export function generateSiweNonce(): string { + return uid(96) +} diff --git a/src/utils/siwe/parseSiweMessage.test.ts b/src/utils/siwe/parseSiweMessage.test.ts new file mode 100644 index 0000000000..e3a3a8cb7a --- /dev/null +++ b/src/utils/siwe/parseSiweMessage.test.ts @@ -0,0 +1,169 @@ +import { expect, test } from 'vitest' + +import { parseSiweMessage } from './parseSiweMessage.js' + +test('default', () => { + const message = `example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +I accept the ExampleOrg Terms of Service: https://example.com/tos + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z` + const parsed = parseSiweMessage(message) + expect(parsed).toMatchInlineSnapshot(` + { + "address": "0xA0Cf798816D4b9b9866b5330EEa46a18382f251e", + "chainId": 1, + "domain": "example.com", + "issuedAt": 2023-02-01T00:00:00.000Z, + "nonce": "foobarbaz", + "statement": "I accept the ExampleOrg Terms of Service: https://example.com/tos", + "uri": "https://example.com/path", + "version": "1", + } + `) +}) + +test('behavior: with scheme', () => { + const message = `https://example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z` + const parsed = parseSiweMessage(message) + expect(parsed.scheme).toMatchInlineSnapshot(`"https"`) +}) + +test('behavior: with statement', () => { + const message = `example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +I accept the ExampleOrg Terms of Service: https://example.com/tos + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z` + const parsed = parseSiweMessage(message) + expect(parsed.statement).toMatchInlineSnapshot( + `"I accept the ExampleOrg Terms of Service: https://example.com/tos"`, + ) +}) + +test('behavior: with expirationTime', () => { + const message = `https://example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z +Expiration Time: 2022-02-04T00:00:00.000Z` + const parsed = parseSiweMessage(message) + expect(parsed.expirationTime).toMatchInlineSnapshot( + '2022-02-04T00:00:00.000Z', + ) +}) + +test('behavior: with notBefore', () => { + const message = `https://example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z +Not Before: 2022-02-04T00:00:00.000Z` + const parsed = parseSiweMessage(message) + expect(parsed.notBefore).toMatchInlineSnapshot('2022-02-04T00:00:00.000Z') +}) + +test('behavior: with requestId', () => { + const message = `https://example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z +Request ID: 123e4567-e89b-12d3-a456-426614174000` + const parsed = parseSiweMessage(message) + expect(parsed.requestId).toMatchInlineSnapshot( + `"123e4567-e89b-12d3-a456-426614174000"`, + ) +}) + +test('behavior: with resources', () => { + const message = `https://example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z +Resources: +- https://example.com/foo +- https://example.com/bar +- https://example.com/baz` + const parsed = parseSiweMessage(message) + expect(parsed.resources).toMatchInlineSnapshot(` + [ + "https://example.com/foo", + "https://example.com/bar", + "https://example.com/baz", + ] + `) +}) + +test('behavior: no suffix', () => { + const message = `https://example.com wants you to sign in with your Ethereum account: +0xA0Cf798816D4b9b9866b5330EEa46a18382f251e + +` + const parsed = parseSiweMessage(message) + expect(parsed).toMatchInlineSnapshot(` + { + "address": "0xA0Cf798816D4b9b9866b5330EEa46a18382f251e", + "domain": "example.com", + "scheme": "https", + } + `) +}) + +test('behavior: no prefix', () => { + const message = `URI: https://example.com/path +Version: 1 +Chain ID: 1 +Nonce: foobarbaz +Issued At: 2023-02-01T00:00:00.000Z +Request ID: 123e4567-e89b-12d3-a456-426614174000` + const parsed = parseSiweMessage(message) + expect(parsed).toMatchInlineSnapshot(` + { + "chainId": 1, + "issuedAt": 2023-02-01T00:00:00.000Z, + "nonce": "foobarbaz", + "requestId": "123e4567-e89b-12d3-a456-426614174000", + "uri": "https://example.com/path", + "version": "1", + } + `) +}) + +test('behavior: bogus message', () => { + const message = 'foobarbaz' + const parsed = parseSiweMessage(message) + expect(parsed).toMatchInlineSnapshot('{}') +}) diff --git a/src/utils/siwe/parseSiweMessage.ts b/src/utils/siwe/parseSiweMessage.ts new file mode 100644 index 0000000000..08504219e0 --- /dev/null +++ b/src/utils/siwe/parseSiweMessage.ts @@ -0,0 +1,55 @@ +import type { Address } from 'abitype' + +import type { ExactPartial, Prettify } from '../../types/utils.js' +import type { SiweMessage } from './types.js' + +/** + * @description Parses EIP-4361 formatted message into message fields object. + * + * @see https://eips.ethereum.org/EIPS/eip-4361 + * + * @returns EIP-4361 fields object + */ +export function parseSiweMessage( + message: string, +): Prettify> { + const { scheme, statement, ...prefix } = (message.match(prefixRegex) + ?.groups ?? {}) as { + address: Address + domain: string + scheme?: string + statement?: string + } + const { chainId, expirationTime, issuedAt, notBefore, requestId, ...suffix } = + (message.match(suffixRegex)?.groups ?? {}) as { + chainId: string + expirationTime?: string + issuedAt?: string + nonce: string + notBefore?: string + requestId?: string + uri: string + version: '1' + } + const resources = message.split('Resources:')[1]?.split('\n- ').slice(1) + return { + ...prefix, + ...suffix, + ...(chainId ? { chainId: Number(chainId) } : {}), + ...(expirationTime ? { expirationTime: new Date(expirationTime) } : {}), + ...(issuedAt ? { issuedAt: new Date(issuedAt) } : {}), + ...(notBefore ? { notBefore: new Date(notBefore) } : {}), + ...(requestId ? { requestId } : {}), + ...(resources ? { resources } : {}), + ...(scheme ? { scheme } : {}), + ...(statement ? { statement } : {}), + } +} + +// https://regexr.com/80gdj +const prefixRegex = + /^(?:(?[a-zA-Z][a-zA-Z0-9+-.]*):\/\/)?(?[a-zA-Z0-9+-.]*) (?:wants you to sign in with your Ethereum account:\n)(?
0x[a-fA-F0-9]{40})\n\n(?:(?.*)\n\n)?/ + +// https://regexr.com/80gf9 +const suffixRegex = + /(?:URI: (?.+))\n(?:Version: (?.+))\n(?:Chain ID: (?\d+))\n(?:Nonce: (?[a-zA-Z0-9]+))\n(?:Issued At: (?.+))(?:\nExpiration Time: (?.+))?(?:\nNot Before: (?.+))?(?:\nRequest ID: (?.+))?/ diff --git a/src/utils/siwe/types.ts b/src/utils/siwe/types.ts new file mode 100644 index 0000000000..ead665e157 --- /dev/null +++ b/src/utils/siwe/types.ts @@ -0,0 +1,61 @@ +import type { Address } from 'abitype' + +/** + * @description EIP-4361 message fields + * + * @see https://eips.ethereum.org/EIPS/eip-4361 + */ +export type SiweMessage = { + /** + * The Ethereum address performing the signing. + */ + address: Address + /** + * The [EIP-155](https://eips.ethereum.org/EIPS/eip-155) Chain ID to which the session is bound, + */ + chainId: number + /** + * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) authority that is requesting the signing. + */ + domain: string + /** + * Time when the signed authentication message is no longer valid. + */ + expirationTime?: Date | undefined + /** + * Time when the message was generated, typically the current time. + */ + issuedAt?: Date | undefined + /** + * A random string typically chosen by the relying party and used to prevent replay attacks. + */ + nonce: string + /** + * Time when the signed authentication message will become valid. + */ + notBefore?: Date | undefined + /** + * A system-specific identifier that may be used to uniquely refer to the sign-in request. + */ + requestId?: string | undefined + /** + * A list of information or references to information the user wishes to have resolved as part of authentication by the relying party. + */ + resources?: string[] | undefined + /** + * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) URI scheme of the origin of the request. + */ + scheme?: string | undefined + /** + * A human-readable ASCII assertion that the user will sign. + */ + statement?: string | undefined + /** + * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) URI referring to the resource that is the subject of the signing (as in the subject of a claim). + */ + uri: string + /** + * The current version of the SIWE Message. + */ + version: '1' +} diff --git a/src/utils/siwe/utils.test.ts b/src/utils/siwe/utils.test.ts new file mode 100644 index 0000000000..5179dea79c --- /dev/null +++ b/src/utils/siwe/utils.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'vitest' + +import { isUri } from './utils.js' + +test('isUri - default', () => { + expect(isUri('https://example.com/foo')).toMatchInlineSnapshot( + `"https://example.com/foo"`, + ) +}) + +test('isUri - behavior: check for illegal characters', () => { + expect(isUri('^')).toBeFalsy() +}) + +test('isUri - incomplete hex escapes', () => { + expect(isUri('%$#')).toBeFalsy() + expect(isUri('%0:#')).toBeFalsy() +}) + +test('isUri - missing scheme', () => { + expect(isUri('example.com/foo')).toBeFalsy() +}) + +test('isUri - authority with missing path', () => { + expect(isUri('1http:////foo.html')).toBeFalsy() +}) + +test('isUri - scheme begins with letter', () => { + expect(isUri('$https://example.com/foo')).toBeFalsy() +}) + +test('isUri - query', () => { + expect(isUri('https://example.com/foo?bar')).toMatchInlineSnapshot( + `"https://example.com/foo?bar"`, + ) +}) + +test('isUri - fragment', () => { + expect(isUri('https://example.com/foo#bar')).toMatchInlineSnapshot( + `"https://example.com/foo#bar"`, + ) +}) diff --git a/src/utils/siwe/utils.ts b/src/utils/siwe/utils.ts new file mode 100644 index 0000000000..240d980031 --- /dev/null +++ b/src/utils/siwe/utils.ts @@ -0,0 +1,51 @@ +export function isUri(value: string) { + // based on https://github.com/ogt/valid-url + + // check for illegal characters + if (/[^a-z0-9\:\/\?\#\[\]\@\!\$\&\'\(\)\*\+\,\;\=\.\-\_\~\%]/i.test(value)) + return false + + // check for hex escapes that aren't complete + if (/%[^0-9a-f]/i.test(value)) return false + if (/%[0-9a-f](:?[^0-9a-f]|$)/i.test(value)) return false + + // from RFC 3986 + const splitted = splitUri(value) + const scheme = splitted[1] + const authority = splitted[2] + const path = splitted[3] + const query = splitted[4] + const fragment = splitted[5] + + // scheme and path are required, though the path can be empty + if (!(scheme?.length && path.length >= 0)) return false + + // if authority is present, the path must be empty or begin with a / + if (authority?.length) { + if (!(path.length === 0 || /^\//.test(path))) return false + } else { + // if authority is not present, the path must not start with // + if (/^\/\//.test(path)) return false + } + + // scheme must begin with a letter, then consist of letters, digits, +, ., or - + if (!/^[a-z][a-z0-9\+\-\.]*$/.test(scheme.toLowerCase())) return false + + let out = '' + // re-assemble the URL per section 5.3 in RFC 3986 + out += `${scheme}:` + if (authority?.length) out += `//${authority}` + + out += path + + if (query?.length) out += `?${query}` + if (fragment?.length) out += `#${fragment}` + + return out +} + +function splitUri(value: string) { + return value.match( + /(?:([^:\/?#]+):)?(?:\/\/([^\/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?/, + )! +} diff --git a/src/utils/siwe/validateSiweMessage.test.ts b/src/utils/siwe/validateSiweMessage.test.ts new file mode 100644 index 0000000000..e49683747d --- /dev/null +++ b/src/utils/siwe/validateSiweMessage.test.ts @@ -0,0 +1,109 @@ +import { expect, test, vi } from 'vitest' + +import type { SiweMessage } from './types.js' +import { validateSiweMessage } from './validateSiweMessage.js' + +const message = { + address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + chainId: 1, + domain: 'example.com', + nonce: 'foobarbaz', + uri: 'https://example.com/path', + version: '1', +} satisfies SiweMessage + +test('default', () => { + expect( + validateSiweMessage({ + message, + }), + ).toBeTruthy() +}) + +test('behavior: invalid address', () => { + expect( + validateSiweMessage({ + message: { + ...message, + address: undefined, + }, + }), + ).toBeFalsy() +}) + +test('behavior: address mismatch', () => { + expect( + validateSiweMessage({ + address: '0xd2135CfB216b74109775236E36d4b433F1DF507B', + message, + }), + ).toBeFalsy() +}) + +test('behavior: invalid address', () => { + expect( + validateSiweMessage({ + address: '0xfoobarbaz', + message, + }), + ).toBeFalsy() +}) + +test('behavior: domain mismatch', () => { + expect( + validateSiweMessage({ + domain: 'viem.sh', + message, + }), + ).toBeFalsy() +}) + +test('behavior: nonce mismatch', () => { + expect( + validateSiweMessage({ + nonce: 'f0obarbaz', + message, + }), + ).toBeFalsy() +}) + +test('behavior: scheme mismatch', () => { + expect( + validateSiweMessage({ + scheme: 'http', + message: { + ...message, + scheme: 'https', + }, + }), + ).toBeFalsy() +}) + +test('behavior: time is after expirationTime', () => { + expect( + validateSiweMessage({ + message: { + ...message, + expirationTime: new Date(Date.UTC(2024, 1, 1)), + }, + time: new Date(Date.UTC(2025, 1, 1)), + }), + ).toBeFalsy() +}) + +test('behavior: time is before notBefore', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + validateSiweMessage({ + message: { + ...message, + notBefore: new Date(Date.UTC(2024, 1, 1)), + }, + time: new Date(Date.UTC(2023, 1, 1)), + }), + ).toBeFalsy() + + vi.useRealTimers() +}) diff --git a/src/utils/siwe/validateSiweMessage.ts b/src/utils/siwe/validateSiweMessage.ts new file mode 100644 index 0000000000..2147a2f145 --- /dev/null +++ b/src/utils/siwe/validateSiweMessage.ts @@ -0,0 +1,70 @@ +import type { Address } from 'abitype' + +import type { ExactPartial } from '../../types/utils.js' +import { isAddressEqual } from '../address/isAddressEqual.js' +import type { SiweMessage } from './types.js' + +export type ValidateSiweMessageParameters = { + /** + * Ethereum address to check against. + */ + address?: Address | undefined + /** + * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) authority to check against. + */ + domain?: string | undefined + /** + * EIP-4361 message fields. + */ + message: ExactPartial + /** + * Random string to check against. + */ + nonce?: string | undefined + /** + * [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-3.1) URI scheme to check against. + */ + scheme?: string | undefined + /** + * Current time to check optional `expirationTime` and `notBefore` fields. + * + * @default new Date() + */ + time?: Date | undefined +} + +export type ValidateSiweMessageReturnType = boolean + +/** + * @description Validates EIP-4361 message. + * + * @see https://eips.ethereum.org/EIPS/eip-4361 + */ +export function validateSiweMessage( + parameters: ValidateSiweMessageParameters, +): ValidateSiweMessageReturnType { + const { + address, + domain, + message, + nonce, + scheme, + time = new Date(), + } = parameters + + if (domain && message.domain !== domain) return false + if (nonce && message.nonce !== nonce) return false + if (scheme && message.scheme !== scheme) return false + + if (message.expirationTime && time >= message.expirationTime) return false + if (message.notBefore && time < message.notBefore) return false + + try { + if (!message.address) return false + if (address && !isAddressEqual(message.address, address)) return false + } catch { + return false + } + + return true +}