UNPKG

ox

Version:

Ethereum Standard Library

513 lines (475 loc) 15.2 kB
import * as Address from './Address.js' import * as Errors from './Errors.js' import type { ExactPartial } from './internal/types.js' import { uid } from './internal/uid.js' export const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(:[0-9]{1,5})?$/ export 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})?$/ export const localhostRegex = /^localhost(:[0-9]{1,5})?$/ export const nonceRegex = /^[a-zA-Z0-9]{8,}$/ export const schemeRegex = /^([a-zA-Z][a-zA-Z0-9+-.]*)$/ // https://regexr.com/80gdj export const prefixRegex = /^(?:(?<scheme>[a-zA-Z][a-zA-Z0-9+-.]*):\/\/)?(?<domain>[a-zA-Z0-9+-.]*(?::[0-9]{1,5})?) (?:wants you to sign in with your Ethereum account:\n)(?<address>0x[a-fA-F0-9]{40})\n\n(?:(?<statement>.*)\n\n)?/ // https://regexr.com/80gf9 export const suffixRegex = /(?:URI: (?<uri>.+))\n(?:Version: (?<version>.+))\n(?:Chain ID: (?<chainId>\d+))\n(?:Nonce: (?<nonce>[a-zA-Z0-9]+))\n(?:Issued At: (?<issuedAt>.+))(?:\nExpiration Time: (?<expirationTime>.+))?(?:\nNot Before: (?<notBefore>.+))?(?:\nRequest ID: (?<requestId>.+))?/ /** [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) message fields. */ export type Message = { /** * The Ethereum address performing the signing. */ address: 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' } /** * Creates [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message. * * @example * ```ts twoslash * import { Siwe } from 'ox' * * Siwe.createMessage({ * address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', * chainId: 1, * domain: 'example.com', * nonce: 'foobarbaz', * uri: 'https://example.com/path', * version: '1', * }) * // @log: "example.com wants you to sign in with your Ethereum account: * // @log: 0xA0Cf798816D4b9b9866b5330EEa46a18382f251e * // @log: * // @log: * // @log: URI: https://example.com/path * // @log: Version: 1 * // @log: Chain ID: 1 * // @log: Nonce: foobarbaz * // @log: Issued At: 2023-02-01T00:00:00.000Z" * ``` * * @param value - Values to use when creating EIP-4361 formatted message. * @returns EIP-4361 formatted message. */ export function createMessage(value: Message): string { 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 InvalidMessageFieldError({ 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 InvalidMessageFieldError({ 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 InvalidMessageFieldError({ field: 'nonce', metaMessages: [ '- Nonce must be at least 8 characters.', '- Nonce must be alphanumeric.', '', `Provided value: ${nonce}`, ], }) if (!isUri(uri)) throw new InvalidMessageFieldError({ 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 InvalidMessageFieldError({ field: 'version', metaMessages: [ "- Version must be '1'.", '', `Provided value: ${version}`, ], }) // Optional fields if (scheme && !schemeRegex.test(scheme)) throw new InvalidMessageFieldError({ 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 InvalidMessageFieldError({ field: 'statement', metaMessages: [ "- Statement must not include '\\n'.", '', `Provided value: ${statement}`, ], }) } // Construct message const address = Address.from(value.address, { checksum: true }) 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 InvalidMessageFieldError({ 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 createMessage { type ErrorType = | Address.from.ErrorType | InvalidMessageFieldError | Errors.GlobalErrorType } /** * Generates random [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) nonce. * * @example * ```ts twoslash * import { Siwe } from 'ox' * * Siwe.generateNonce() * // @log: '65ed4681d4efe0270b923ff5f4b097b1c95974dc33aeebecd5724c42fd86dfd25dc70b27ef836b2aa22e68f19ebcccc1' * ``` * * @returns Random nonce. */ export function generateNonce(): string { return uid(96) } /** * Check if the given URI is a valid [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986) URI. * * @example * ```ts twoslash * import { Siwe } from 'ox' * * Siwe.isUri('https://example.com/foo') * // @log: true * ``` * * @param value - Value to check. * @returns `false` if invalid, otherwise the valid URI. */ // based on https://github.com/ogt/valid-url export function isUri(value: string): false | string { // 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( /(?:([^:\/?#]+):)?(?:\/\/([^\/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?/, )! } /** * [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message into message fields object. * * @example * ```ts twoslash * 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`) * // @log: { * // @log: address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', * // @log: chainId: 1, * // @log: domain: 'example.com', * // @log: issuedAt: '2023-02-01T00:00:00.000Z', * // @log: nonce: 'foobarbaz', * // @log: statement: 'I accept the ExampleOrg Terms of Service: https://example.com/tos', * // @log: uri: 'https://example.com/path', * // @log: version: '1', * // @log: } * ``` * * @param message - [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message. * @returns Message fields object. */ export function parseMessage(message: string): ExactPartial<Message> { const { scheme, statement, ...prefix } = (message.match(prefixRegex) ?.groups ?? {}) as { address: 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 } : {}), } } /** * Validates [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) message. * * @example * ```ts twoslash * import { Siwe } from 'ox' * * Siwe.validateMessage({ * address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', * domain: 'example.com', * message: { * address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', * chainId: 1, * domain: 'example.com', * nonce: 'foobarbaz', * uri: 'https://example.com/path', * version: '1', * }, * nonce: 'foobarbaz', * }) * // @log: true * ``` * * @param value - Values to use when validating EIP-4361 formatted message. * @returns Whether the message is valid. */ export function validateMessage(value: validateMessage.Value): boolean { 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 && !Address.isEqual(message.address, address)) return false } catch { return false } return true } export declare namespace validateMessage { interface Value { /** * Ethereum address to check against. */ address?: 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<Message> /** * 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 } } /** * Thrown when a field in a SIWE Message is invalid. * * @example * ```ts twoslash * import { Siwe } from 'ox' * * Siwe.createMessage({ * address: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', * chainId: 1.1, * domain: 'example.com', * nonce: 'foobarbaz', * uri: 'https://example.com/path', * version: '1', * }) * // @error: Siwe.InvalidMessageFieldError: Invalid Sign-In with Ethereum message field "chainId". * // @error: - Chain ID must be a EIP-155 chain ID. * // @error: - See https://eips.ethereum.org/EIPS/eip-155 * // @error: Provided value: 1.1 * ``` */ export class InvalidMessageFieldError extends Errors.BaseError { override readonly name = 'Siwe.InvalidMessageFieldError' constructor(parameters: { field: string metaMessages?: string[] | undefined }) { const { field, metaMessages } = parameters super(`Invalid Sign-In with Ethereum message field "${field}".`, { metaMessages, }) } }