UNPKG

lit-siwe

Version:
342 lines (313 loc) 11.2 kB
import { randomStringForEntropy } from "@stablelib/random"; // TODO: Figure out how to get types from this lib: // import { Contract, ethers, utils } from 'ethers'; import { Contract } from "@ethersproject/contracts"; import { verifyMessage } from "@ethersproject/wallet"; import { Web3Provider, JsonRpcSigner, JsonRpcProvider, } from "@ethersproject/providers"; import { hashMessage as ethersHashMessage } from "@ethersproject/hash"; import { ParsedMessage, ParsedMessageRegExp } from "@spruceid/siwe-parser"; /** * Possible message error types. */ export enum ErrorTypes { /**Thrown when the `validate()` function can verify the message. */ INVALID_SIGNATURE = "Invalid signature.", /**Thrown when the `expirationTime` is present and in the past. */ EXPIRED_MESSAGE = "Expired message.", /**Thrown when some required field is missing. */ MALFORMED_SESSION = "Malformed session.", } /**@deprecated * Possible signature types that this library supports. * * This enum will be removed in future releases. And signature type will be * inferred from version. */ export enum SignatureType { /**EIP-191 signature scheme */ PERSONAL_SIGNATURE = "Personal signature", } export class SiweMessage { /**RFC 4501 dns authority that is requesting the signing. */ domain: string; /**Ethereum address performing the signing conformant to capitalization * encoded checksum specified in EIP-55 where applicable. */ address: string; /**Human-readable ASCII assertion that the user will sign, and it must not * contain `\n`. */ statement?: string; /**RFC 3986 URI referring to the resource that is the subject of the signing * (as in the __subject__ of a claim). */ uri: string; /**Current version of the message. */ version: string; /**EIP-155 Chain ID to which the session is bound, and the network where * Contract Accounts must be resolved. */ chainId: number; /**Randomized token used to prevent replay attacks, at least 8 alphanumeric * characters. */ nonce: string; /**ISO 8601 datetime string of the current time. */ issuedAt: string; /**ISO 8601 datetime string that, if present, indicates when the signed * authentication message is no longer valid. */ expirationTime?: string; /**ISO 8601 datetime string that, if present, indicates when the signed * authentication message will become valid. */ notBefore?: string; /**System-specific identifier that may be used to uniquely refer to the * sign-in request. */ requestId?: string; /**List of information or references to information the user wishes to have * resolved as part of authentication by the relying party. They are * expressed as RFC 3986 URIs separated by `\n- `. */ resources?: Array<string>; /**@deprecated * Signature of the message signed by the wallet. * * This field will be removed in future releases, an additional parameter * was added to the validate function were the signature goes to validate * the message. */ signature?: string; /**@deprecated Type of sign message to be generated. * * This field will be removed in future releases and will rely on the * message version */ type?: SignatureType; /** * Creates a parsed Sign-In with Ethereum Message (EIP-4361) object from a * string or an object. If a string is used an ABNF parser is called to * validate the parameter, otherwise the fields are attributed. * @param param {string | SiweMessage} Sign message as a string or an object. */ constructor(param: string | Partial<SiweMessage>) { if (typeof param === "string") { const parsedMessage = new ParsedMessage(param); this.domain = parsedMessage.domain; this.address = parsedMessage.address; this.statement = parsedMessage.statement; this.uri = parsedMessage.uri; this.version = parsedMessage.version; this.nonce = parsedMessage.nonce; this.issuedAt = parsedMessage.issuedAt; this.expirationTime = parsedMessage.expirationTime; this.notBefore = parsedMessage.notBefore; this.requestId = parsedMessage.requestId; this.chainId = parsedMessage.chainId; this.resources = parsedMessage.resources; } else { Object.assign(this, param); if (typeof this.chainId === "string") { this.chainId = parseInt(this.chainId); } } } /** * Given a sign message (EIP-4361) returns the correct matching groups. * @param message {string} * @returns {RegExpExecArray} The matching groups for the message */ regexFromMessage(message: string): RegExpExecArray { const parsedMessage = new ParsedMessageRegExp(message); return parsedMessage.match; } /** * This function can be used to retrieve an EIP-4361 formated message for * signature, although you can call it directly it's advised to use * [signMessage()] instead which will resolve to the correct method based * on the [type] attribute of this object, in case of other formats being * implemented. * @returns {string} EIP-4361 formated message, ready for EIP-191 signing. */ toMessage(): string { const header = `${this.domain} wants you to sign in with your Ethereum account:`; const uriField = `URI: ${this.uri}`; let prefix = [header, this.address].join("\n"); const versionField = `Version: ${this.version}`; if (!this.nonce) { this.nonce = generateNonce(); } const chainField = `Chain ID: ` + this.chainId || "1"; const nonceField = `Nonce: ${this.nonce}`; const suffixArray = [uriField, versionField, chainField, nonceField]; if (this.issuedAt) { Date.parse(this.issuedAt); } this.issuedAt = this.issuedAt ? this.issuedAt : new Date().toISOString(); suffixArray.push(`Issued At: ${this.issuedAt}`); if (this.expirationTime) { const expiryField = `Expiration Time: ${this.expirationTime}`; suffixArray.push(expiryField); } if (this.notBefore) { suffixArray.push(`Not Before: ${this.notBefore}`); } if (this.requestId) { suffixArray.push(`Request ID: ${this.requestId}`); } if (this.resources) { suffixArray.push( [`Resources:`, ...this.resources.map((x) => `- ${x}`)].join("\n") ); } let suffix = suffixArray.join("\n"); prefix = [prefix, this.statement].join("\n\n"); if (this.statement) { prefix += "\n"; } return [prefix, suffix].join("\n"); } /** @deprecated * signMessage method is deprecated, use prepareMessage instead. * * This method parses all the fields in the object and creates a sign * message according with the type defined. * @returns {string} Returns a message ready to be signed according with the * type defined in the object. */ signMessage(): string { console && console.warn && console.warn( "signMessage method is deprecated, use prepareMessage instead." ); return this.prepareMessage(); } /** * This method parses all the fields in the object and creates a sign * message according with the type defined. * @returns {string} Returns a message ready to be signed according with the * type defined in the object. */ prepareMessage(): string { let message: string; switch (this.version) { case "1": { message = this.toMessage(); break; } default: { message = this.toMessage(); break; } } return message; } /** * Validates the integrity of the fields of this objects by matching it's * signature. * @param provider A Web3 provider able to perform a contract check, this is * required if support for Smart Contract Wallets that implement EIP-1271 is * needed. * @returns {Promise<SiweMessage>} This object if valid. */ async validate( signature: string = this.signature, provider?: Web3Provider | any ): Promise<SiweMessage> { return new Promise<SiweMessage>(async (resolve, reject) => { const message = this.prepareMessage(); try { let missing: Array<string> = []; if (!message) { missing.push("`message`"); } if (!signature) { missing.push("`signature`"); } if (!this.address) { missing.push("`address`"); } if (missing.length > 0) { throw new Error( `${ErrorTypes.MALFORMED_SESSION} missing: ${missing.join(", ")}.` ); } let addr; try { addr = verifyMessage(message, signature); } catch (_) { } finally { if (addr !== this.address) { try { //EIP-1271 const isValidSignature = await checkContractWalletSignature( this, signature, provider ); if (!isValidSignature) { throw new Error( `${ErrorTypes.INVALID_SIGNATURE}: ${addr} !== ${this.address}` ); } } catch (e) { throw e; } } } const parsedMessage = new SiweMessage(message); if (parsedMessage.expirationTime) { const exp = new Date(parsedMessage.expirationTime).getTime(); if (isNaN(exp)) { throw new Error( `${ErrorTypes.MALFORMED_SESSION} invalid expiration date.` ); } if (new Date().getTime() >= exp) { throw new Error(ErrorTypes.EXPIRED_MESSAGE); } } resolve(parsedMessage); } catch (e) { reject(e); } }); } } /** * This method calls the EIP-1271 method for Smart Contract wallets * @param message The EIP-4361 parsed message * @param provider Web3 provider able to perform a contract check (Web3/EthersJS). * @returns {Promise<boolean>} Checks for the smart contract (if it exists) if * the signature is valid for given address. */ export const checkContractWalletSignature = async ( message: SiweMessage, signature: string, provider?: any ): Promise<boolean> => { if (!provider) { return false; } const abi = [ "function isValidSignature(bytes32 _message, bytes _signature) public view returns (bool)", ]; try { const walletContract = new Contract(message.address, abi, provider); const hashMessage = ethersHashMessage(message.signMessage()); return await walletContract.isValidSignature(hashMessage, signature); } catch (e) { throw e; } }; /** * This method leverages a native CSPRNG with support for both browser and Node.js * environments in order generate a cryptographically secure nonce for use in the * SiweMessage in order to prevent replay attacks. * * 96 bits has been chosen as a number to sufficiently balance size and security considerations * relative to the lifespan of it's usage. * * @returns cryptographically generated random nonce with 96 bits of entropy encoded with * an alphanumeric character set. */ export const generateNonce = (): string => { return randomStringForEntropy(96); };