All files / evm-wallet-experiment/src/lib mnemonic-crypto.ts

97.43% Statements 38/39
83.33% Branches 5/6
85.71% Functions 6/7
100% Lines 37/37

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159          2x   2x                                     36x 36x 957x   36x                   48x 48x 1589x   48x                     4x 4x 4x 4x 4x                     16x 16x 16x 16x                                               16x 16x       16x         16x 16x 16x 16x   16x                                                   8x 8x 8x 8x   8x         8x   8x 8x   3x     5x    
import { gcm } from '@noble/ciphers/aes';
import { pbkdf2 } from '@noble/hashes/pbkdf2';
import { sha256 } from '@noble/hashes/sha2';
import { keccak_256 as keccak256 } from '@noble/hashes/sha3';
 
const harden = globalThis.harden ?? (<T>(value: T): T => value);
 
const DEFAULT_PBKDF2_ITERATIONS = 600_000;
 
/**
 * Encrypted mnemonic envelope persisted in baggage.
 */
export type EncryptedMnemonicData = {
  encrypted: true;
  ciphertext: string;
  nonce: string;
  salt: string;
};
 
/**
 * Convert a hex-encoded string (no 0x prefix) to Uint8Array.
 *
 * @param encoded - Hex-encoded string without 0x prefix.
 * @returns The byte array.
 */
function hexToBytes(encoded: string): Uint8Array {
  const bytes = new Uint8Array(encoded.length / 2);
  for (let i = 0; i < encoded.length; i += 2) {
    bytes[i / 2] = parseInt(encoded.slice(i, i + 2), 16);
  }
  return bytes;
}
 
/**
 * Convert a Uint8Array to a hex-encoded string (no 0x prefix).
 *
 * @param bytes - The byte array.
 * @returns Hex-encoded string without 0x prefix.
 */
function bytesToHex(bytes: Uint8Array): string {
  let result = '';
  for (const byte of bytes) {
    result += byte.toString(16).padStart(2, '0');
  }
  return result;
}
 
/**
 * Derive a 16-byte salt from the password using keccak256 with a domain separator.
 * Used as fallback when no external salt is provided.
 *
 * @param passwordBytes - The UTF-8 encoded password.
 * @returns A 16-byte salt.
 */
function deriveSalt(passwordBytes: Uint8Array): Uint8Array {
  const domainSeparator = new TextEncoder().encode('ocap-keyring-salt');
  const input = new Uint8Array(domainSeparator.length + passwordBytes.length);
  input.set(domainSeparator);
  input.set(passwordBytes, domainSeparator.length);
  return keccak256(input).slice(0, 16);
}
 
/**
 * Derive a 12-byte nonce from salt and derived key using keccak256.
 *
 * @param salt - The salt bytes.
 * @param key - The derived AES key bytes.
 * @returns A 12-byte nonce for AES-GCM.
 */
function deriveNonce(salt: Uint8Array, key: Uint8Array): Uint8Array {
  const input = new Uint8Array(salt.length + key.length);
  input.set(salt);
  input.set(key, salt.length);
  return keccak256(input).slice(0, 12);
}
 
/**
 * Encrypt a mnemonic with a password using AES-256-GCM + PBKDF2.
 *
 * @param options - Encryption options.
 * @param options.mnemonic - The mnemonic to encrypt.
 * @param options.password - The password for key derivation.
 * @param options.salt - Optional hex-encoded salt (16 bytes). If omitted, derived from password.
 * @param options.pbkdf2Iterations - Optional PBKDF2 iteration count. Defaults to 600,000.
 * @returns The encrypted mnemonic envelope.
 */
export function encryptMnemonic({
  mnemonic,
  password,
  salt: saltEncoded,
  pbkdf2Iterations = DEFAULT_PBKDF2_ITERATIONS,
}: {
  mnemonic: string;
  password: string;
  salt?: string;
  pbkdf2Iterations?: number;
}): EncryptedMnemonicData {
  const passwordBytes = new TextEncoder().encode(password);
  const salt = saltEncoded
    ? hexToBytes(saltEncoded)
    : deriveSalt(passwordBytes);
 
  const key = pbkdf2(sha256, passwordBytes, salt, {
    c: pbkdf2Iterations,
    dkLen: 32,
  });
 
  const nonce = deriveNonce(salt, key);
  const cipher = gcm(key, nonce);
  const plaintext = new TextEncoder().encode(mnemonic);
  const ciphertext = cipher.encrypt(plaintext);
 
  return harden({
    encrypted: true as const,
    ciphertext: bytesToHex(ciphertext),
    nonce: bytesToHex(nonce),
    salt: bytesToHex(salt),
  });
}
 
/**
 * Decrypt a mnemonic from an encrypted envelope.
 *
 * @param options - Decryption options.
 * @param options.data - The encrypted mnemonic envelope.
 * @param options.password - The password used during encryption.
 * @param options.pbkdf2Iterations - Optional PBKDF2 iteration count. Must match the value used during encryption.
 * @returns The decrypted mnemonic string.
 */
export function decryptMnemonic({
  data,
  password,
  pbkdf2Iterations = DEFAULT_PBKDF2_ITERATIONS,
}: {
  data: EncryptedMnemonicData;
  password: string;
  pbkdf2Iterations?: number;
}): string {
  const passwordBytes = new TextEncoder().encode(password);
  const salt = hexToBytes(data.salt);
  const nonce = hexToBytes(data.nonce);
  const ciphertext = hexToBytes(data.ciphertext);
 
  const key = pbkdf2(sha256, passwordBytes, salt, {
    c: pbkdf2Iterations,
    dkLen: 32,
  });
 
  const cipher = gcm(key, nonce);
  let plaintext: Uint8Array;
  try {
    plaintext = cipher.decrypt(ciphertext);
  } catch {
    throw new Error('Decryption failed — check that the password is correct');
  }
 
  return new TextDecoder().decode(plaintext);
}