Source: parse-siwe.js

/**
 * @file src/parse-siwe.js
 * @author Lowell D. Thomas <ldt@sabnf.com>
 */
import { Parser } from './parser.js';
import { default as Grammar } from './grammar.js';
import { cb } from './callbacks.js';
import { isERC55, toERC55 } from './keccak256.js';
export { parseSiweMessage, isUri, siweObjectToString };

/**
 * Parses an [ERC-4361: Sign-In with Ethereum](https://eips.ethereum.org/EIPS/eip-4361) 
 * message to an object with the message components.
 *
 * @param {string} msg the ERC-4361 message
 * @param {string} erc55 controls [ERC-55](https://eips.ethereum.org/EIPS/eip-55) processing of the message address<br>
 *   - 'validate' - (default) parser fails if address is not in ERC-55 encoding
 *   - 'convert'  - parser will convert the address to ERC-55 encoding
 *   - 'other'    - (actually, any value other than 'validate', 'convert' or undefined) parser ignores ERC-55 encoding
 * @returns An siwe message object or throws exception with instructive message on format error.<br>
 * e.g. message
 * ````
example.com:80 wants you to sign in with your Ethereum account:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2

Valid statement

URI: https://example.com/login
Version: 1
Chain ID: 123456789
Nonce: 32891756
Issued At: 2021-09-30T16:25:24Z
Request ID: someRequestId
Resources:
- ftp://myftpsite.com/
- https://example.com/my-web2-claim.json
 * ````
* returns object, obj:
 * ````
  obj.scheme = undefined
  obj.domain = 'example.com:80'
  obj.address = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'
  obj.statement = 'Valid statement'
  obj.uri = 'https://example.com/login'
  obj.version = 1
  obj.chainId = 123456789
  obj.nonce = '32891756'
  obj.issuedAt = '2021-09-30T16:25:24Z'
  obj.expirationTime = undefined
  obj.notBefore = undefined
  obj.requestId = 'someRequestId'
  obj.resources = [ 'ftp://myftpsite.com/', 'https://example.com/my-web2-claim.json' ]
* ````
 */
function parseSiweMessage(msg, erc55 = 'validate') {
  const fname = 'parseSiweMessage: ';
  if (typeof msg !== 'string') {
    throw new Error(`${fname} invalid input msg: message must be of type string`);
  }
  const p = new Parser();
  const g = new Grammar();

  // set the parser's callback functions
  p.callbacks['ffscheme'] = cb.ffscheme;
  p.callbacks['fdomain'] = cb.fdomain;
  p.callbacks['faddress'] = cb.faddress;
  p.callbacks['fstatement'] = cb.fstatement;
  p.callbacks['furi'] = cb.furi;
  p.callbacks['fnonce'] = cb.fnonce;
  p.callbacks['fversion'] = cb.fversion;
  p.callbacks['fchain-id'] = cb.fchainId;
  p.callbacks['fissued-at'] = cb.fissuedAt;
  p.callbacks['fexpiration-time'] = cb.fexpirationTime;
  p.callbacks['fnot-before'] = cb.fnotBefore;
  p.callbacks['frequest-id'] = cb.frequestId;
  p.callbacks['fresources'] = cb.fresources;
  p.callbacks['fresource'] = cb.fresource;
  p.callbacks['empty-statement'] = cb.emptyStatement;
  p.callbacks['no-statement'] = cb.noStatement;
  p.callbacks['actual-statement'] = cb.actualStatment;
  p.callbacks['pre-uri'] = cb.preUri;
  p.callbacks['pre-version'] = cb.preVersion;
  p.callbacks['pre-chain-id'] = cb.preChainId;
  p.callbacks['pre-nonce'] = cb.preNonce;
  p.callbacks['pre-issued-at'] = cb.preIssuedAt;
  // URI callbacks
  p.callbacks['uri'] = cb.URI;
  p.callbacks['scheme'] = cb.scheme;
  p.callbacks['userinfo-at'] = cb.userinfo;
  p.callbacks['host'] = cb.host;
  p.callbacks['IP-literal'] = cb.ipLiteral;
  p.callbacks['port'] = cb.port;
  p.callbacks['path-abempty'] = cb.pathAbempty;
  p.callbacks['path-absolute'] = cb.pathAbsolute;
  p.callbacks['path-rootless'] = cb.pathRootless;
  p.callbacks['path-empty'] = cb.pathEmpty;
  p.callbacks['query'] = cb.query;
  p.callbacks['fragment'] = cb.fragment;
  p.callbacks['IPv4address'] = cb.ipv4;
  p.callbacks['nodcolon'] = cb.nodcolon;
  p.callbacks['dcolon'] = cb.dcolon;
  p.callbacks['h16'] = cb.h16;
  p.callbacks['h16c'] = cb.h16;
  p.callbacks['h16n'] = cb.h16;
  p.callbacks['h16cn'] = cb.h16;
  p.callbacks['dec-octet'] = cb.decOctet;
  p.callbacks['dec-digit'] = cb.decDigit;

  // BEGIN: some parser helper functions
  function validateDateTime(time, name = 'unknown') {
    const ret = p.parse(g, 'date-time', time);
    if (!ret.success || isNaN(Date.parse(time))) {
      throw new Error(`${fname}invalid date time: ${name} not date time string format: ${time}`);
    }
  }
  function validInt(i) {
    const valid = parseInt(i, 10);
    if (isNaN(valid)) {
      throw new Error(`${fname}invalid integer: not a number: ${i}`);
    } else if (valid === Infinity) {
      throw new Error(`${fname}invalid integer: Infinity: ${i}`);
    }
    return valid;
  }

  // Validates an RFC 3986 URI.
  // returns true if the URI is valid, false otherwise
  function isUri(URI) {
    ret = p.parse(g, 'uri', URI, {});
    return ret.success;
  }
  // END: some parser helper functions

  // first pass parse of the message
  // capture all the message parts, then validate them one-by-one later
  let data = { error: false };
  let ret;
  ret = p.parse(g, 'siwe-first-pass', msg, data);
  if (data.error) {
    throw new Error(`${fname}invalid siwe message: ${data.error}`);
  }
  if (!ret.success) {
    throw new Error(
      `${fname}invalid siwe message: carefully check message syntax, especially after required "Issued At: "\n${JSON.stringify(
        ret
      )}`
    );
  }

  if (data.fscheme) {
    // validate the scheme
    ret = p.parse(g, 'scheme', data.fscheme, {});
    if (!ret.success) {
      throw new Error(`${fname}invalid scheme: ${data.fscheme}`);
    }
  }

  // validate the domain
  ret = p.parse(g, 'authority', data.fdomain, {});
  if (!ret.success) {
    throw new Error(`${fname}invalid domain: ${data.fdomain}`);
  }

  // validate the address
  ret = p.parse(g, 'address', data.faddress);
  if (!ret.success) {
    throw new Error(`${fname}invalid address: ${data.faddress}`);
  }
  if (erc55 == 'validate') {
    // address must be ERC55 format
    if (!isERC55(data.faddress)) {
      throw new Error(
        `${fname}invalid ERC-55 format address: 'validate' specified, MUST be ERC55-conformant: ${data.faddress}`
      );
    }
  } else if (erc55 === 'convert') {
    // convert address to ERC55 format
    data.faddress = toERC55(data.faddress);
  }
  // else for any other value of erc55 no further action on the address is taken

  // validate the statement
  if (data.fstatement !== undefined && data.fstatement !== '') {
    ret = p.parse(g, 'statement', data.fstatement);
    if (!ret.success) {
      throw new Error(`${fname}invalid statement: ${data.fstatement}`);
    }
  }

  // validate the URI
  if (!isUri(data.furi)) {
    throw new Error(`${fname}invalid URI: ${data.furi}`);
  }

  // validate the version
  ret = p.parse(g, 'version', data.fversion);
  if (!ret.success) {
    throw new Error(`${fname}invalid Version: ${data.fversion}`);
  }
  data.fversion = validInt(data.fversion);

  // validate the chain-id
  ret = p.parse(g, 'chain-id', data.fchainId);
  if (!ret.success) {
    throw new Error(`${fname}invalid Chain ID: ${data.fchainId}`);
  }
  data.fchainId = validInt(data.fchainId);

  // validate nonce
  ret = p.parse(g, 'nonce', data.fnonce);
  if (!ret.success) {
    throw new Error(`${fname}invalid Nonce: ${data.fnonce}`);
  }

  // validate the date times
  validateDateTime(data.fissuedAt, 'Issued At');
  if (data.fexpirationTime) {
    validateDateTime(data.fexpirationTime, 'Expiration Time');
  }
  if (data.fnotBefore) {
    validateDateTime(data.fnotBefore, 'Not Before');
  }

  // validate request-id
  if (data.frequestId !== undefined && data.frequestId !== '') {
    ret = p.parse(g, 'request-id', data.frequestId);
    if (!ret.success) {
      throw new Error(`${fname}invalid Request ID: i${data.frequestId}`);
    }
  }

  // validate all resource URIs, if any
  if (data.fresources && data.fresources.length) {
    for (let i = 0; i < data.fresources.length; i++) {
      if (!isUri(data.fresources[i])) {
        throw new Error(`${fname}invalid resource URI [${i}]: ${data.fresources[i]}`);
      }
    }
  }

  // by now all first-pass values (e.g. fscheme) have been validated
  const o = {};
  o.scheme = data.fscheme;
  o.domain = data.fdomain;
  o.address = data.faddress;
  o.statement = data.fstatement;
  o.uri = data.furi;
  o.version = data.fversion;
  o.chainId = data.fchainId;
  o.nonce = data.fnonce;
  o.issuedAt = data.fissuedAt;
  o.expirationTime = data.fexpirationTime;
  o.notBefore = data.fnotBefore;
  o.requestId = data.frequestId;
  o.resources = undefined;
  if (data.fresources) {
    o.resources = [];
    for (let i = 0; i < data.fresources.length; i++) {
      o.resources.push(data.fresources[i]);
    }
  }
  return o;
}

/**
 * Parses a Uniform Resource Identifier (URI) defined in [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986).
 *
 * @param {string} URI the URI to parse
 * @returns `false` if the URI is invalid, otherwise a URI object. e.g.<br>
 * ````
 * const obj = isUri('uri://user:pass@example.com:123/one/two.three?q1=a1&q2=a2#body');
 * ````
 * returns:
 * ````
 * obj.scheme = 'uri'
 * obj.userinfo = 'user:pass'
 * obj.host = 'example.com',
 * obj.port = 123,
 * obj.path = '/one/two.three',
 * obj.query = 'q1=a1&q2=a2',
 * obj.fragment = 'body'
 * ````
 */
function isUri(URI) {
  const p = new Parser();
  const g = new Grammar();
  const uriData = {};
  p.callbacks['uri'] = cb.URI;
  p.callbacks['scheme'] = cb.scheme;
  p.callbacks['userinfo-at'] = cb.userinfo;
  p.callbacks['host'] = cb.host;
  p.callbacks['IP-literal'] = cb.ipLiteral;
  p.callbacks['port'] = cb.port;
  p.callbacks['path-abempty'] = cb.pathAbempty;
  p.callbacks['path-absolute'] = cb.pathAbsolute;
  p.callbacks['path-rootless'] = cb.pathRootless;
  p.callbacks['path-empty'] = cb.pathEmpty;
  p.callbacks['query'] = cb.query;
  p.callbacks['fragment'] = cb.fragment;
  p.callbacks['IPv4address'] = cb.ipv4;
  p.callbacks['nodcolon'] = cb.nodcolon;
  p.callbacks['dcolon'] = cb.dcolon;
  p.callbacks['h16'] = cb.h16;
  p.callbacks['h16c'] = cb.h16;
  p.callbacks['h16n'] = cb.h16;
  p.callbacks['h16cn'] = cb.h16;
  p.callbacks['dec-octet'] = cb.decOctet;
  p.callbacks['dec-digit'] = cb.decDigit;
  const ret = p.parse(g, 'uri', URI, uriData);
  if (!ret.success) {
    return false;
  }
  const uriObject = {
    uri: uriData['uri'],
    scheme: uriData['scheme'],
    userinfo: uriData['userinfo'],
    host: uriData['host'],
    port: uriData['port'],
    path: uriData['path'],
    query: uriData['query'],
    fragment: uriData['fragment'],
  };
  return uriObject;
}

/**
 * Stringify an [ERC-4361: Sign-In with Ethereum](https://eips.ethereum.org/EIPS/eip-4361) (siwe) object.
 * Note that this function does no validation of the input object.
 * It simply returns a string that includes the valid parts of the object, if any.
 * For validation, use {@link parseSiweMessage}.
 *
 * @param {object} o an siwe message object (see {@link parseSiweMessage} )
 * @returns A stringified version of the object suitable as input to {@link parseSiweMessage}.
 *
 * For example, to validate an siwe object <br>*(ignore backslash, JSDoc can't handle closed bracket character without it)*:
 * ````
 * try{
 *  parseSiweMessage(siweObjectToString(siweObject));
 *  console.log('siweObject is valid');
 * \}catch(e){
 *  console.log('siweObject is not valid: ' + e.message);
 * \}
 * ````
 */
function siweObjectToString(o) {
  let str = '';
  if (typeof o !== 'object') {
    return str;
  }
  if (o.scheme && o.scheme !== '') {
    str += `${o.scheme}://`;
  }
  if (o.domain && o.domain !== '') {
    str += o.domain;
  }
  str += ' wants you to sign in with your Ethereum account:\n';
  if (o.address && o.address !== '') {
    str += `${o.address}\n`;
  }
  str += '\n';
  if (o.statement !== undefined) {
    str += `${o.statement}\n`;
  }
  str += '\n';
  if (o.uri && o.uri !== '') {
    str += `URI: ${o.uri}\n`;
  }
  if (o['version'] && o['version'] !== '') {
    str += `Version: ${o['version']}\n`;
  }
  if (o['chainId'] && o['chainId'] !== '') {
    str += `Chain ID: ${o['chainId']}\n`;
  }
  if (o['nonce'] && o['nonce'] !== '') {
    str += `Nonce: ${o['nonce']}\n`;
  }
  if (o['issuedAt'] && o['issuedAt'] !== '') {
    str += `Issued At: ${o['issuedAt']}`;
  }
  if (o['expirationTime'] && o['expirationTime'] !== '') {
    str += `\nExpiration Time: ${o['expirationTime']}`;
  }
  if (o['notBefore'] && o['notBefore'] !== '') {
    str += `\nNot Before: ${o['notBefore']}`;
  }
  if (o['requestId'] !== undefined) {
    str += `\nRequest ID: ${o['requestId']}`;
  }
  if (o['resources']) {
    str += `\nResources:`;
    if (Array.isArray(o.resources)) {
      for (let i = 0; i < o.resources.length; i++) {
        str += `\n- ${o.resources[i]}`;
      }
    }
  }
  return str;
}