diff --git a/src/Siwe.ts b/src/Siwe.ts new file mode 100644 index 00000000..94ec54ce --- /dev/null +++ b/src/Siwe.ts @@ -0,0 +1,19 @@ +export { + createSiweMessage as createMessage, + createSiweMessage, +} from './internal/siwe/createSiweMessage.js' + +export { + generateSiweNonce as generateNonce, + generateSiweNonce, +} from './internal/siwe/generateSiweNonce.js' + +export { + parseSiweMessage as parseMessage, + parseSiweMessage, +} from './internal/siwe/parseSiweMessage.js' + +export { + validateSiweMessage as validateMessage, + validateSiweMessage, +} from './internal/siwe/validateSiweMessage.js' diff --git a/src/index.ts b/src/index.ts index 0bbf6ddd..0ebed53f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * as Hex from './Hex.js' export * as Secp256k1 from './Secp256k1.js' export * as Signature from './Signature.js' export * as Rlp from './Rlp.js' +export * as Siwe from './Siwe.js' export * as TypedData from './TypedData.js' export * as Types from './Types.js' export * as Value from './Value.js' diff --git a/src/internal/errors/siwe.test.ts b/src/internal/errors/siwe.test.ts new file mode 100644 index 00000000..93ae59ab --- /dev/null +++ b/src/internal/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$ + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) diff --git a/src/internal/errors/siwe.ts b/src/internal/errors/siwe.ts new file mode 100644 index 00000000..3162e2b5 --- /dev/null +++ b/src/internal/errors/siwe.ts @@ -0,0 +1,16 @@ +import { BaseError } from './base.js' + +export class SiweInvalidMessageFieldError extends BaseError { + override readonly name = 'SiweInvalidMessageFieldError' + + constructor(parameters: { + field: string + metaMessages?: string[] | undefined + }) { + const { field, metaMessages } = parameters + super(`Invalid Sign-In with Ethereum message field "${field}".`, { + docsPath: '/errors#siweinvalidmessagefielderror', + metaMessages, + }) + } +} diff --git a/src/internal/isUri.test.ts b/src/internal/isUri.test.ts new file mode 100644 index 00000000..c9c59ccf --- /dev/null +++ b/src/internal/isUri.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'vitest' + +import { isUri } from './isUri.js' + +test('default', () => { + expect(isUri('https://example.com/foo')).toMatchInlineSnapshot( + `"https://example.com/foo"`, + ) +}) + +test('behavior: check for illegal characters', () => { + expect(isUri('^')).toBeFalsy() +}) + +test('incomplete hex escapes', () => { + expect(isUri('%$#')).toBeFalsy() + expect(isUri('%0:#')).toBeFalsy() +}) + +test('missing scheme', () => { + expect(isUri('example.com/foo')).toBeFalsy() +}) + +test('authority with missing path', () => { + expect(isUri('1http:////foo.html')).toBeFalsy() +}) + +test('scheme begins with letter', () => { + expect(isUri('$https://example.com/foo')).toBeFalsy() +}) + +test('query', () => { + expect(isUri('https://example.com/foo?bar')).toMatchInlineSnapshot( + `"https://example.com/foo?bar"`, + ) +}) + +test('fragment', () => { + expect(isUri('https://example.com/foo#bar')).toMatchInlineSnapshot( + `"https://example.com/foo#bar"`, + ) +}) diff --git a/src/internal/isUri.ts b/src/internal/isUri.ts new file mode 100644 index 00000000..4792d2e4 --- /dev/null +++ b/src/internal/isUri.ts @@ -0,0 +1,52 @@ +/** @internal */ +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 && 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/internal/siwe/createSiweMessage.test.ts b/src/internal/siwe/createSiweMessage.test.ts new file mode 100644 index 00000000..f638d7a9 --- /dev/null +++ b/src/internal/siwe/createSiweMessage.test.ts @@ -0,0 +1,413 @@ +import { expect, test, vi } from 'vitest' + +import type { SiweMessage } from '../types/siwe.js' +import { createSiweMessage } from './createSiweMessage.js' + +const message = { + address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + chainId: 1, + 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: domain', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(Date.UTC(2023, 1, 1))) + + expect( + createSiweMessage({ + ...message, + domain: 'foo.example.com', + }), + ).toMatchInlineSnapshot(` + "foo.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" + `) + + expect( + createSiweMessage({ + ...message, + domain: 'example.co.uk', + }), + ).toMatchInlineSnapshot(` + "example.co.uk 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. + + Details: Address is not a 20 byte (40 hexadecimal character) value. + See: https://oxlib.sh/errors#invalidaddresserror] + `) +}) + +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 + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +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 + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +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 + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +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 + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +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 + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +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 + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +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 + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +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: https://example.com + + See: https://oxlib.sh/errors#siweinvalidmessagefielderror] + `) +}) + +test.each([ + 'example.com', + 'localhost', + '127.0.0.1', + 'example.com:3000', + 'localhost:3000', + '127.0.0.1:3000', +])('valid domain `%s`', (domain) => { + expect( + createSiweMessage({ + ...message, + domain, + }), + ).toBeTypeOf('string') +}) + +test.each([ + 'http://example.com', + 'http://localhost', + 'http://127.0.0.1', + 'http://example.com:3000', + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'foobarbaz', + '-example.com', +])('invalid domain `%s`', (domain) => { + expect(() => + createSiweMessage({ + ...message, + domain, + }), + ).toThrowError() +}) diff --git a/src/internal/siwe/createSiweMessage.ts b/src/internal/siwe/createSiweMessage.ts new file mode 100644 index 00000000..98b2ceb9 --- /dev/null +++ b/src/internal/siwe/createSiweMessage.ts @@ -0,0 +1,192 @@ +import type { GlobalErrorType } from 'src/Errors.js' +import { toAddress } from '../address/from.js' +import { SiweInvalidMessageFieldError } from '../errors/siwe.js' +import { isUri } from '../isUri.js' +import type { SiweMessage } from '../types/siwe.js' + +/** + * Creates EIP-4361 formatted message. + * + * - Docs: https://oxlib.sh/api/siwe/createMessage + * - Spec: https://eips.ethereum.org/EIPS/eip-4361 + * + * @example + * import { Siwe } from 'ox' + * + * Siwe.createMessage({ + * address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * chainId: 1, + * domain: 'example.com', + * nonce: 'foobarbaz', + * uri: 'https://example.com/path', + * version: '1', + * }) + * // "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" + */ +export function createSiweMessage( + value: createSiweMessage.Value, +): createSiweMessage.ReturnType { + const { + chainId, + domain, + expirationTime, + issuedAt = new Date(), + nonce, + notBefore, + requestId, + resources, + scheme, + uri, + version, + } = value + + // 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) || + ipRegex.test(domain) || + localhostRegex.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 = value.statement + if (statement?.includes('\n')) + throw new SiweInvalidMessageFieldError({ + field: 'statement', + metaMessages: [ + "- Statement must not include '\\n'.", + '', + `Provided value: ${statement}`, + ], + }) + } + + // Construct message + const address = toAddress(value.address) + const origin = (() => { + if (scheme) return `${scheme}://${domain}` + return domain + })() + const statement = (() => { + if (!value.statement) return '' + return `${value.statement}\n` + })() + const prefix = `${origin} wants you to sign in with your Ethereum account:\n${address}\n\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}` +} + +export declare namespace createSiweMessage { + type Value = SiweMessage + + type ReturnType = string + + type ErrorType = + | toAddress.ErrorType + | SiweInvalidMessageFieldError + | GlobalErrorType +} + +createSiweMessage.parseError = (error: unknown) => + error as createSiweMessage.ErrorType + +const domainRegex = + /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(:[0-9]{1,5})?$/ +const ipRegex = + /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(:[0-9]{1,5})?$/ +const localhostRegex = /^localhost(:[0-9]{1,5})?$/ +const nonceRegex = /^[a-zA-Z0-9]{8,}$/ +const schemeRegex = /^([a-zA-Z][a-zA-Z0-9+-.]*)$/ diff --git a/src/internal/siwe/generateSiweNonce.test.ts b/src/internal/siwe/generateSiweNonce.test.ts new file mode 100644 index 00000000..3072065a --- /dev/null +++ b/src/internal/siwe/generateSiweNonce.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from 'vitest' + +import { generateSiweNonce } from './generateSiweNonce.js' + +test('default', () => { + const nonce = generateSiweNonce() + expect(nonce.length).toMatchInlineSnapshot('96') +}) diff --git a/src/internal/siwe/generateSiweNonce.ts b/src/internal/siwe/generateSiweNonce.ts new file mode 100644 index 00000000..c51f43fc --- /dev/null +++ b/src/internal/siwe/generateSiweNonce.ts @@ -0,0 +1,17 @@ +import { uid } from '../uid.js' + +/** + * Generates random EIP-4361 nonce. + * + * - Docs: https://oxlib.sh/api/siwe/generateNonce + * - Spec: https://eips.ethereum.org/EIPS/eip-4361 + * + * @example + * import { Siwe } from 'ox' + * + * Siwe.generateNonce() + * // '65ed4681d4efe0270b923ff5f4b097b1c95974dc33aeebecd5724c42fd86dfd25dc70b27ef836b2aa22e68f19ebcccc1' + */ +export function generateSiweNonce(): string { + return uid(96) +} diff --git a/src/internal/siwe/parseSiweMessage.test.ts b/src/internal/siwe/parseSiweMessage.test.ts new file mode 100644 index 00000000..28192c02 --- /dev/null +++ b/src/internal/siwe/parseSiweMessage.test.ts @@ -0,0 +1,182 @@ +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: domain with port', () => { + const message = `example.com:8080 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.domain).toMatchInlineSnapshot(`"example.com:8080"`) +}) + +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/internal/siwe/parseSiweMessage.ts b/src/internal/siwe/parseSiweMessage.ts new file mode 100644 index 00000000..dcb1c55f --- /dev/null +++ b/src/internal/siwe/parseSiweMessage.ts @@ -0,0 +1,79 @@ +import type { Address } from 'abitype' +import type { SiweMessage } from '../types/siwe.js' +import type { Compute, ExactPartial } from '../types/utils.js' + +/** + * EIP-4361 formatted message into message fields object. + * + * - Docs: https://oxlib.sh/api/siwe/parseMessage + * - Spec: https://eips.ethereum.org/EIPS/eip-4361 + * + * @example + * import { Siwe } from 'ox' + * + * Siwe.parseMessage(`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`) + * // { + * // 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', + * // } + */ +export function parseSiweMessage(message: string): parseSiweMessage.ReturnType { + 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 } : {}), + } +} + +export declare namespace parseSiweMessage { + type ReturnType = Compute> +} + +// https://regexr.com/80gdj +const prefixRegex = + /^(?:(?[a-zA-Z][a-zA-Z0-9+-.]*):\/\/)?(?[a-zA-Z0-9+-.]*(?::[0-9]{1,5})?) (?: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/internal/siwe/validateSiweMessage.test.ts b/src/internal/siwe/validateSiweMessage.test.ts new file mode 100644 index 00000000..5f792217 --- /dev/null +++ b/src/internal/siwe/validateSiweMessage.test.ts @@ -0,0 +1,109 @@ +import { expect, test, vi } from 'vitest' + +import type { SiweMessage } from '../types/siwe.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/internal/siwe/validateSiweMessage.ts b/src/internal/siwe/validateSiweMessage.ts new file mode 100644 index 00000000..a2103ab4 --- /dev/null +++ b/src/internal/siwe/validateSiweMessage.ts @@ -0,0 +1,79 @@ +import type { Address } from 'abitype' + +import { isAddressEqual } from '../address/isEqual.js' +import type { SiweMessage } from '../types/siwe.js' +import type { ExactPartial } from '../types/utils.js' + +/** + * Validates EIP-4361 message. + * + * - Docs: https://oxlib.sh/api/siwe/validateMessage + * - Spec: https://eips.ethereum.org/EIPS/eip-4361 + * + * @example + * import { Siwe } from 'ox' + * + * Siwe.validateMessage({ + * address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * chainId: 1, + * domain: 'example.com', + * nonce: 'foobarbaz', + * uri: 'https://example.com/path', + * version: '1', + * }) + * // true + */ +export function validateSiweMessage( + value: validateSiweMessage.Value, +): validateSiweMessage.ReturnType { + const { address, domain, message, nonce, scheme, time = new Date() } = value + + 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 +} + +export declare namespace validateSiweMessage { + type Value = { + /** + * 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 + } + + type ReturnType = boolean +} diff --git a/src/internal/typedData/domainSeparator.ts b/src/internal/typedData/domainSeparator.ts index 781fdf7d..b08494e5 100644 --- a/src/internal/typedData/domainSeparator.ts +++ b/src/internal/typedData/domainSeparator.ts @@ -9,6 +9,7 @@ import { hashDomain } from './hashDomain.js' * Creates [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) domainSeparator for the provided domain. * * - Docs: https://oxlib.sh/api/typedData/domainSeparator + * - Spec: https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator * * @example * import { TypedData } from 'ox' diff --git a/src/internal/typedData/encodeType.ts b/src/internal/typedData/encodeType.ts index 80e44127..5d84a1a1 100644 --- a/src/internal/typedData/encodeType.ts +++ b/src/internal/typedData/encodeType.ts @@ -7,6 +7,7 @@ import type { GlobalErrorType } from '../errors/error.js' * Encodes [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) schema for the provided primaryType. * * - Docs: https://oxlib.sh/api/typedData/encodeType + * - Spec: https://eips.ethereum.org/EIPS/eip-712#definition-of-encodetype * * @example * import { TypedData } from 'ox' diff --git a/src/internal/typedData/hashStruct.ts b/src/internal/typedData/hashStruct.ts index 6fff0895..7b8873a1 100644 --- a/src/internal/typedData/hashStruct.ts +++ b/src/internal/typedData/hashStruct.ts @@ -14,6 +14,7 @@ import { encodeType } from './encodeType.js' * Hashes [EIP-712 Typed Data](https://eips.ethereum.org/EIPS/eip-712) struct. * * - Docs: https://oxlib.sh/api/typedData/hashStruct + * - Spec: https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct * * @example * import { TypedData } from 'ox' diff --git a/src/internal/types/siwe.ts b/src/internal/types/siwe.ts new file mode 100644 index 00000000..11427446 --- /dev/null +++ b/src/internal/types/siwe.ts @@ -0,0 +1,61 @@ +import type { Address } from 'abitype' + +/** + * 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/internal/uid.test.ts b/src/internal/uid.test.ts new file mode 100644 index 00000000..37c03a6d --- /dev/null +++ b/src/internal/uid.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from 'vitest' + +import { uid } from './uid.js' + +test('default', () => { + expect(uid()).toBeTypeOf('string') +}) diff --git a/src/internal/uid.ts b/src/internal/uid.ts new file mode 100644 index 00000000..1744b166 --- /dev/null +++ b/src/internal/uid.ts @@ -0,0 +1,15 @@ +const size = 256 +let index = size +let buffer: string + +/** @internal */ +export function uid(length = 11) { + if (!buffer || index + length > size * 2) { + buffer = '' + index = 0 + for (let i = 0; i < size; i++) { + buffer += ((256 + Math.random() * 256) | 0).toString(16).substring(1) + } + } + return buffer.substring(index, index++ + length) +}