Newer
Older
CrypticOreWallet / src / app / WalletManager.tsx
@Drew Lemmy Drew Lemmy on 5 Sep 2020 3 KB feat: master password dialog
import React, { Component } from "react";

import { toHex } from "@utils";
import { aesGcmEncrypt, aesGcmDecrypt } from "@utils/crypto";

import { MasterPasswordDialog } from "./MasterPasswordDialog";

import Debug from "debug";
const debug = Debug("kristweb:walletManager");

type WalletManagerData = {
  /** Whether or not the user has logged in, either as a guest, or with a
   * master password. */
  isLoggedIn: boolean;

  /** Whether or not the user is browsing KristWeb as a guest. */
  isGuest: boolean;

  /** The master password used to encrypt and decrypt local storage data. */
  masterPassword?: string;
  
  /** Secure random string that is encrypted with the master password to create
   * the "tester" string. */
  salt?: string;
  /** The `salt` encrypted with the master password, to test the password is
   * correct. */
  tester?: string;

  /** Whether or not the user has configured and saved a master password
   * before (whether or not salt+tester are present in local storage). */
  hasMasterPassword: boolean;
}

export class WalletManager extends Component<unknown, WalletManagerData> {
  constructor(props: unknown) {
    super(props);

    // Check current data stored in local storage.
    const salt = localStorage.getItem("salt") || undefined;
    const tester = localStorage.getItem("tester") || undefined;

    this.state = {
      isLoggedIn: false,
      isGuest: true,

      // Salt and tester from local storage (or undefined)
      salt, tester,
      // There is a master password configured if both `salt` and `tester` exist
      hasMasterPassword: !!salt && !!tester
    };

    debug("hasMasterPassword: %b", this.state.hasMasterPassword);
  }

  async setMasterPassword(password: string): Promise<void> {
    if (!password) throw new Error("Password is required.");

    // Generate the salt (to be encrypted with the master password)
    const salt = window.crypto.getRandomValues(new Uint8Array(32));
    const saltHex = toHex(salt);

    // Generate the encryption tester
    const tester = await aesGcmEncrypt(saltHex, password);

    debug("master password salt: %x    tester: %s", salt, tester);

    // Store them in local storage
    localStorage.setItem("salt", saltHex);
    localStorage.setItem("tester", tester);

    // Set the logged in state
    this.setState({
      isLoggedIn: true,
      isGuest: false,
      masterPassword: password
    });
  }

  async testMasterPassword(password: string): Promise<void> {
    if (!password) throw new Error("Password is required.");

    // Get the salt and tester from local storage and ensure they exist
    const { salt, tester } = this.state;
    if (!salt || !tester) throw new Error("Master password has not been set up.");

    try {
      // Attempt to decrypt the tester with the given password
      const testerDec = await aesGcmDecrypt(tester, password);

      // Verify that the decrypted tester is equal to the salt, if not, the
      // provided master password is incorrect.
      if (testerDec !== salt) throw new Error("Incorrect password.");
    } catch (e) {
      // OperationError usually means decryption failure
      if (e.name === "OperationError") throw new Error("Incorrect password.");
      else throw e;
    }

    // Set the logged in state and don't return any errors (login successful)
    this.setState({
      isLoggedIn: true,
      isGuest: false,
      masterPassword: password
    });
  }

  /** Render the master password login/setup dialog */
  render(): JSX.Element | null {
    const { isLoggedIn, hasMasterPassword } = this.state;
    if (isLoggedIn) return null; // Don't show the dialog again

    return (
      <MasterPasswordDialog 
        hasMasterPassword={hasMasterPassword} // Show the setup dialog if needed
        walletManager={this} 
      />
    );
  }
}