cyb/src/utils/mnemonicCrypto.ts

// Format versions: v1 = 600k iterations (legacy), v2 = 1M iterations
const PBKDF2_ITERATIONS_V1 = 600_000;
const PBKDF2_ITERATIONS_V2 = 1_000_000;
const CURRENT_VERSION = 2;
const SALT_BYTES = 16;
const IV_BYTES = 12;

async function deriveKey(
  password: string,
  salt: Uint8Array,
  iterations: number
): Promise<CryptoKey> {
  const encoder = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    encoder.encode(password),
    'PBKDF2',
    false,
    ['deriveKey']
  );

  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

export async function encryptMnemonic(mnemonic: string, password: string): Promise<string> {
  const encoder = new TextEncoder();
  const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const key = await deriveKey(password, salt, PBKDF2_ITERATIONS_V2);

  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    encoder.encode(mnemonic)
  );

  // Pack: version(1) + salt(16) + iv(12) + ciphertext โ†’ base64
  const ct = new Uint8Array(ciphertext);
  const packed = new Uint8Array(1 + SALT_BYTES + IV_BYTES + ct.length);
  packed[0] = CURRENT_VERSION;
  packed.set(salt, 1);
  packed.set(iv, 1 + SALT_BYTES);
  packed.set(ct, 1 + SALT_BYTES + IV_BYTES);

  return btoa(Array.from(packed, (b) => String.fromCharCode(b)).join(''));
}

async function tryDecrypt(
  packed: Uint8Array,
  password: string,
  versioned: boolean
): Promise<string> {
  const offset = versioned ? 1 : 0;
  const minLength = offset + SALT_BYTES + IV_BYTES + 1;
  if (packed.length < minLength) {
    throw new RangeError(`Encrypted blob too short: ${packed.length} < ${minLength}`);
  }

  const iterations = versioned && packed[0] === 2
    ? PBKDF2_ITERATIONS_V2
    : PBKDF2_ITERATIONS_V1;

  const salt = packed.slice(offset, offset + SALT_BYTES);
  const iv = packed.slice(offset + SALT_BYTES, offset + SALT_BYTES + IV_BYTES);
  const ciphertext = packed.slice(offset + SALT_BYTES + IV_BYTES);

  const key = await deriveKey(password, salt, iterations);
  const plaintext = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    key,
    ciphertext
  );
  return new TextDecoder().decode(plaintext);
}

/**
 * Tauri device key: a random 32-byte key stored in localStorage.
 * Used as the encryption password for Tauri's password-less flow.
 * Not perfect security (key is in localStorage alongside the blob),
 * but ensures mnemonics are never stored as readable plaintext.
 */
const DEVICE_KEY_STORAGE = 'cyb:device-key';

export function getTauriDeviceKey(): string {
  let key = localStorage.getItem(DEVICE_KEY_STORAGE);
  if (!key) {
    const bytes = crypto.getRandomValues(new Uint8Array(32));
    key = btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(''));
    localStorage.setItem(DEVICE_KEY_STORAGE, key);
  }
  return key;
}

export async function decryptMnemonic(encrypted: string, password: string): Promise<string> {
  let packed: Uint8Array;
  try {
    packed = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
  } catch {
    throw new Error('Invalid encrypted mnemonic data');
  }

  // Try versioned format first (v2 = 1M iterations), fall back to legacy v1 (600k)
  const isVersioned = packed[0] === 1 || packed[0] === 2;

  if (isVersioned) {
    try {
      return await tryDecrypt(packed, password, true);
    } catch (err) {
      // ~0.8% chance legacy salt starts with 0x01/0x02 โ€” retry as unversioned.
      // AES-GCM decryption failure throws DOMException (OperationError).
      if (err instanceof DOMException) {
        return tryDecrypt(packed, password, false);
      }
      throw err;
    }
  }

  return tryDecrypt(packed, password, false);
}

Neighbours