UNPKG

airwallet-api

Version:

Metaphi Airwallet API to add whitelabel, non-custodial wallets to dApps

676 lines (586 loc) 20.7 kB
/** * Metaphi Airwallet API. * * Use this library to integrate the Metaphi Airwallet into your dApp. * This library allows you to connect to Metaphi Wallet APIs, to recover or create new wallets. * The KMS systems allows users to create wallets locally and backup encrypted shares * to Metaphi and the dApp. * * To learn more about the KMS architecture, visit: https://docs.metaphi.xyz/kms-whitepaper. * */ "use strict"; // Device const store = require("store"); const Cookies = require("js-cookie"); // Cryptography const sss = require("shamirs-secret-sharing"); const crypto = require("crypto"); // Wallets & Transactions const EthereumWallet = require("ethereumjs-wallet"); import Common, { Chain } from "@ethereumjs/common"; import { Transaction } from "@ethereumjs/tx"; // Generic const axios = require("axios"); // Initialization const common = new Common({ chain: Chain.Mainnet }); class MetaphiWalletApi { /* Static properties */ // Endpoint for wallets. _METAPHI_WALLET_API = "https://api-staging.metaphi.xyz/v1/wallets"; // Endpoint for wallet verification. _METAPHI_WALLET_VERIFY_API = "https://api-staging.metaphi.xyz/v1/wallets/verify"; // Endpoint that exposes Metaphi Secret API. _METAPHI_WALLET_SECRET_API = "https://api-staging.metaphi.xyz/v1/wallets/secret"; // Endpoint that exposes the dApp Secret API. _DAPP_WALLET_SECRET_API = null; // ClientId of the dApp. _clientId = null; // API key of the dApp. _clientApiKey = null; // Wallet Public Address _publicAddress = null; // Wallet Private Key _privateKey = null; // Additional features. _logger = console.log; _userPinFunction = this.defaultUserPinFunction; constructor(options) { const { accountConfig, custom } = options; // Throw error, when accountConfig is missing. if (!accountConfig || !accountConfig.clientId || !accountConfig.apiKey) { throw new Error("Error initializing wallet: Missing clientId or apiKey"); } // Account Config. this._clientId = accountConfig.clientId; this._clientApiKey = accountConfig.apiKey; this._DAPP_WALLET_SECRET_API = "https://api-staging.metaphi.xyz/v1/dapp/secret"; // TODO: Should be initialized by dApp. // Custom functions. this._logger = custom?.logger || console.log; this._userPinFunction = custom?.userPinFunction || this.defaultUserPinFunction; // User Id this._userId = null; } /* Public methods */ // Login Metaphi wallet login = async (userId) => { // Persist user id. this._userId = userId; this._logger(`Logging in: ${userId}.`); // Check if returning user. this._logger("Authenticate returning user."); let jwt = this._getAuthenticatedJwt(); if (jwt) { this._logger(`User is already logged in`); } else { const response = await this._triggerManualAuthentication(userId); if (!response) { this._logger("Error authenticating user.", "red"); return; } } // Extract public address. this._publicAddress = this._getCachedPublicAddress(); this._logger(`Wallet Authenticated: ${this._publicAddress}`); // Connect wallet. this._logger("Connecting wallet."); try { await this._connectWallet(userId); if (this._publicAddress && this._privateKey) { this._logger("Wallet reconstruction successful. Wallet connected."); } else { this._logger(`Error connecting wallet.`); } } catch (ex) { this._logger(`Error connecting wallet: ${ex.toString()}`); } }; // Get public address of wallet. getAddress = () => { console.log(this); this._logger(`Connected Wallet Address: ${this._publicAddress}`); return this._publicAddress; }; createNewWallet = async () => { // If the public address does not exist or minimum shares are not met // a. Generate the wallet key and address locally. // b. Break it up into three parts, encrypt using symmetric key. // c. Store one piece locally, store one piece on the dApp and the final // piece on Metaphi. const userCreds = await this._getUserCreds(this._userId, true); const wallet = await this._createNewWallet(userCreds); this._logger(`Created New Wallet: ${wallet.address}`, "green"); // Setup wallet. this._setupWallet(wallet); }; // Sign a message. signTransaction = (transaction) => { if (!this._privateKey) { this._logger("Error signing transaction: Private key missing", "red"); } try { const txParams = { ...transaction, }; const tx = Transaction.fromTxData(txParams, { common }); const privateKey = new Buffer.from(this._privateKey.substr(2), "hex"); const signedTx = tx.sign(privateKey); const serializedTx = signedTx.serialize(); return serializedTx; } catch (ex) { this._logger(`Error signing transaction: ${ex.toString()}`); } }; // Disconnect disconnect = () => { this._reset(); }; /* Factory methods */ // Recovers a wallet using the secret shares stored in Metaphi and the dApp. recoverOrCreateWallet = async (userCreds) => { // Public Address & Email of the user const { publicAddress, userEmail } = userCreds; let wallet = { address: publicAddress, privateKey: null }; // If the wallet secret and public address exist: // a. Retrieve 2/3 shares from local device, dApp and/or Metaphi // b. Decrypt it using userPin // c. Persist reconstructed privateKey in Wallet scope if (publicAddress !== null && userEmail != null) { this._logger(`Attempting to reconstruct key for ${userEmail}`); /** * List of shares, to reconstruct the secret key * These shares will be retrieved in the following order * 1. Device Share * 2. dApp Share * 3. Metaphi Share * * We need 2/3 shares to reconstruct the wallet. Incase, this criteria is not met, * we will recreate the wallet and shared secrets */ let shares = []; // Retrieve share from local device. let localShare = this._getShareFromDevice(userEmail); if (localShare) { this._logger(`Retrieved share from device.`); shares.push(localShare); } else { this._logger(`Share on device: Not found.`); } // Retrieve share from Metaphi. let metaphiShare = await this._getShareFromMetaphi(userCreds); if (metaphiShare) { shares.push(metaphiShare); } // Retrieve backup dApp share. // This happens when either the local share or dApp share is missing if (shares.length < 2) { let dAppShare = await this._getShareFromdApp(userCreds); if (dAppShare) { this._logger(`Retrieved share from dApp.`); shares.push(dAppShare); } } // Required number of shares exist. // Reconstruct private key. if (shares.length == 2) { const symmetric_key = this._generateSymmetricKey(userCreds); const privateKey = this._reconstructWalletFromSecret( symmetric_key, shares[0], shares[1] ); wallet.privateKey = privateKey; } else { this._logger( "Error fetching minimum number of shares. Please contact Metaphi or create a new wallet.", "red" ); // TODO: Give the user an option to recreate wallet. } } else { this._logger("Error recovering wallet. Doesnot exist"); } // Return reconstructed wallet. return wallet; }; // Default function to retrieve user credential. // Override, with dApp functionality. defaultUserPinFunction = async () => { const userPin = prompt("Please enter your secret pin", "1234"); return userPin; }; /* Private methods */ _reset = async () => { /** Empty Caches. */ // Authentication this._resetAuthenticatedJwt(); // Cached address. this._resetCachedPublicAddress(); /** Reset statics. */ // Wallet Public Address this._publicAddress = null; // Wallet Private Key this._privateKey = null; // User ID this._userId = null; this._logger("Wallet disconnected."); }; _triggerManualAuthentication = async (userId) => { this._logger("User is not logged in. Triggering authentication flow"); let myHeaders = new Headers(); myHeaders.append("X-Metaphi-Api-Key", this._clientApiKey); myHeaders.append("x-metaphi-account-id", this._clientId); myHeaders.append("Content-Type", "application/json"); let jwt, authenticated; // Wallet Authentication Flow. // TODO: Switch to axios. try { let raw = JSON.stringify({ email: userId, }); var requestOptions = { method: "POST", headers: myHeaders, body: raw, redirect: "follow", }; const URL = this._METAPHI_WALLET_API; const response = await fetch(URL, requestOptions); const wallet = await response.json(); this._logger("Retrieved wallet."); jwt = wallet.jwt; } catch (ex) { this._logger("Error recovering wallet.", "red"); } // Verification Flow. // This is triggered when there is no oauth flow setup for this dApp. if (!jwt) { this._logger("Triggering verification flow."); try { var verificationCode = prompt("Enter your verification code", "123456"); this._logger(`Entered Verification Code: ${verificationCode}.`); // Verify code. // TODO: Switch to axios. const URL = this._METAPHI_WALLET_VERIFY_API; let raw = JSON.stringify({ email: userId, verification_code: verificationCode, }); var requestOptions = { method: "POST", headers: myHeaders, body: raw, redirect: "follow", }; const response = await fetch(URL, requestOptions); const wallet = await response.json(); jwt = wallet.jwt; const { address, wallet_id } = wallet.wallet; // Save wallet address in cache. this._setCachedPublicAddress(address); // TODO: Cache wallet_id instead. authenticated = 1; } catch (ex) { console.log(ex); this._logger(`Error verifying wallet.`); return; } } // Set jwt authorization this._setAuthenticatedJwt(jwt); return authenticated; }; // Connect Metaphi Wallet _connectWallet = async (userId) => { const userCreds = await this._getUserCreds(userId); const wallet = await this.recoverOrCreateWallet(userCreds); // Setup wallet. this._setupWallet(wallet); }; _getUserCreds = async (userId, isNewWallet) => { // Get user pin. // Prompt the user for a pin and generate a symmetric key. // This should be protected using faceid, webauthn, etc. // Key must be 32 bytes for aes256. this._logger("Retrieving user credential."); const userPin = await this._userPinFunction(); // Retrieve reconstructed wallet. this._logger("Reconstructing wallet."); const authorizedJwt = this._getAuthenticatedJwt(); const userCreds = { userPin, userEmail: userId, authorizedJwt: authorizedJwt, publicAddress: isNewWallet ? null : this._publicAddress, }; return userCreds; }; _setupWallet = (wallet) => { // If the wallet address has changed, update user if (wallet.address !== this._publicAddress) { // TODO: Handle this case, NS to comment this._setCachedPublicAddress(wallet.address); this._logger( `Public Address changed from ${this._publicAddress} to ${wallet.address}`, "blue" ); console.warn( `Public Address changed from ${this._publicAddress} to ${wallet.address}` ); } // Persist public key. this._publicAddress = wallet.address; // Persist private key. this._privateKey = wallet.privateKey; }; _getAuthenticatedJwt = () => { const ID = this._userId; const cookieName = `${ID}-jwt`; return Cookies.get(cookieName); }; _setAuthenticatedJwt = (jwt) => { const ID = this._userId; const cookieName = `${ID}-jwt`; Cookies.set(cookieName, jwt, { expires: 1, path: "" }); // Expires in 1 day }; _resetAuthenticatedJwt = () => { const ID = this._userId; const cookieName = `${ID}-jwt`; Cookies.remove(cookieName, { path: "" }); }; // Set public address. _getCachedPublicAddress = () => { const ID = this._userId; return store.get(`${ID}-wallet`); }; // Get cached public address. _setCachedPublicAddress = (address) => { const ID = this._userId; store.set(`${ID}-wallet`, address); }; _resetCachedPublicAddress = () => { const ID = this._userId; store.remove(`${ID}-wallet`); }; // Get share from device. _getShareFromDevice = (userEmail) => { let share = store.get(`${userEmail}-key-share`); return share; }; // Retrive share from dApp. _getShareFromdApp = async (userCreds) => { this._logger(`Fetch share from dApp: ${this._DAPP_WALLET_SECRET_API}`); try { const response = await axios.get(this._DAPP_WALLET_SECRET_API, { api_key: { api_key: this._clientApiKey }, params: { wallet_address: this._publicAddress, }, headers: { Authorization: `Bearer ${userCreds.authorizedJwt}`, "Content-Type": "application/json", "X-Metaphi-Api-Key": this._clientApiKey, "x-metaphi-account-id": this._clientId, }, }); if (response.data.key_share.length) this._logger(`Fetched share from dApp.`); else this._logger(`Fetched empty share from dApp.`, "red"); return response.data.key_share; } catch (ex) { this._logger(`Error fetching share from dApp ${ex.toString()}`); } }; // Retrieve share from Metaphi. _getShareFromMetaphi = async (userCreds) => { this._logger( `Fetch share from Metaphi: ${this._METAPHI_WALLET_SECRET_API}` ); try { const response = await axios.get(this._METAPHI_WALLET_SECRET_API, { headers: { Authorization: `Bearer ${userCreds.authorizedJwt}`, "Content-Type": "application/json", "X-Metaphi-Api-Key": this._clientApiKey, "x-metaphi-account-id": this._clientId, }, }); if (response.data.key_share.length) this._logger(`Fetched share from Metaphi.`); else this._logger(`Fetched empty share from Metaphi.`, "red"); return response.data.key_share; } catch (ex) { this._logger(`Error fetching share from Metaphi: ${ex.toString()}`); } }; // Set share on device. _uploadToDevice = (userCreds, share) => { const key = `${userCreds.userEmail}-key-share`; store.set(key, share); this._logger(`Saved local share on device.`); }; // Uploads share to Metaphi. _uploadToMetaphi = async (userCreds, address, share) => { try { this._logger( `Uploading share to Metaphi: ${this._METAPHI_WALLET_SECRET_API}` ); var data = { wallet_address: address, key_share: share, }; var config = { method: "post", url: this._METAPHI_WALLET_SECRET_API, headers: { Authorization: `Bearer ${userCreds.authorizedJwt}`, "Content-Type": "application/json", "X-Metaphi-Api-Key": this._clientApiKey, "x-metaphi-account-id": this._clientId, }, data: data, }; const result = await axios(config); this._logger(`Successfully uploaded share to Metaphi`); } catch (ex) { this._logger(`Error uploading share to Metaphi: ${ex.toString()}`); throw ex; } }; // Uploads share to dApp. _uploadTodApp = async (userCreds, address, share) => { // TODO: We assume for now that the dApp access to their // secret share contract is via Metaphi. We will also support // the dApps hosting their own contract gateway in the future. // This is why for now, we pass in the newWalletJwt. try { this._logger( `Uploading local share to dApp: ${this._DAPP_WALLET_SECRET_API}` ); const data = { key_share: share, wallet_address: address, }; const config = { url: this._DAPP_WALLET_SECRET_API, method: "post", headers: { Authorization: `Bearer ${userCreds.authorizedJwt}`, "Content-Type": "application/json", "X-Metaphi-Api-Key": this._clientApiKey, "x-metaphi-account-id": this._clientId, }, data, }; const response = await axios(config); this._logger(`Successfully uploaded share to dApp`); } catch (ex) { this._logger(`Error uploading share to dApp: ${ex.toString()}`); throw ex; } }; // Generates the symmetric key from user credentials. _generateSymmetricKey = (userCreds) => { const seed = userCreds.userEmail + ":" + userCreds.userPin; // Prompt the user for a pin and generate a symmetric key. // This should be protected using faceid, webauthn, etc. // Key must be 32 bytes for aes256. return Buffer.from( crypto.createHash("sha256").update(seed).digest("hex"), "hex" ); }; // Encrypts using an AES256 cipher. _aes256_encrypt = (value, key) => { var ivlength = 16; // AES blocksize var iv = crypto.randomBytes(ivlength); var cipher = crypto.createCipheriv("aes256", key, iv); var encrypted = cipher.update(value, "binary", "binary"); encrypted += cipher.final("binary"); return iv.toString("binary") + ":" + encrypted; }; // Decrypts using an AES256 cipher. _aes256_decrypt = (ciphertext, key) => { var components = ciphertext.split(":"); var iv_from_ciphertext = Buffer.from(components.shift(), "binary"); try { var decipher = crypto.createDecipheriv("aes256", key, iv_from_ciphertext); var deciphered = decipher.update( components.join(":"), "binary", "binary" ); deciphered += decipher.final("binary"); return deciphered; } catch (err) { this._logger("Error: ", err); console.log("IV: ", iv_from_ciphertext); } }; // Creates a new wallet. _createNewWallet = async (userCreds) => { // Generate a wallet const EthWallet = EthereumWallet.default.generate(); const address = EthWallet.getAddressString(); const privateKey = EthWallet.getPrivateKeyString(); // Create secrets from it. const shares = sss.split(privateKey, { shares: 3, threshold: 2 }); // Generate symmetric key var symmetric_key = this._generateSymmetricKey(userCreds); this._logger(`Generated Symmetric Key`); // Encrypt the shares with the generated symmetric key. const encrypted_shares = shares.map((share) => this._aes256_encrypt(share.toString("binary"), symmetric_key) ); this._logger(`Generated Encrypted shares: ${encrypted_shares.length}`); // Upload shares. let uploadedShareCount = 0; try { // Pass on a share to Metaphi. await this._uploadToMetaphi(userCreds, address, encrypted_shares[2]); uploadedShareCount++; // Pass on a share to the dApp. await this._uploadTodApp(userCreds, address, encrypted_shares[1]); uploadedShareCount++; } catch (ex) { // Add log. } // Only store in local, // when other shares are successfully uploaded if (uploadedShareCount === 2) { // Store a share locally. this._uploadToDevice(userCreds, encrypted_shares[0]); } else { throw new Error("Error uploading shares. Please try again.", "red"); } // Wallet object. return { address, privateKey, }; }; // Reconstructs the secret key from the two shares. _reconstructWalletFromSecret = (symmetric_key, keyShare1, keyShare2) => { this._logger( `<br/>Reconstructing secret: <br/>Symmetric Key: ${symmetric_key} <br/>Share1: ${keyShare1} <br/>Share 2: ${keyShare2}` ); // Decrypt shares. const decrypted_shares = [keyShare1, keyShare2].map((share) => { return this._aes256_decrypt(share.toString("binary"), symmetric_key); }); // Reconstruct the private key and return the wallet. const privateKey = sss .combine([ Buffer.from(decrypted_shares[0], "binary"), Buffer.from(decrypted_shares[1], "binary"), ]) .toString(); this._logger(`Succesfully reconstructed secret.`); return privateKey; }; } export default MetaphiWalletApi;