You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
512 lines
15 KiB
512 lines
15 KiB
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,
|
|
})
|
|
}
|
|
}
|