OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / seed-vault.cjs

80 lines · javascript ref:
// seed-vault.cjs — SEC0: encrypt the BIP-39 root entropy AT REST. The seed is
// the keys to the user's identity + (future) funds; today it sits plaintext in
// identity.json. This wraps it in argon2id (sodium.crypto_pwhash) → authenticated
// secretbox (xsalsa20poly1305). Resolves the "which KDF under Bare" question with
// NO new deps and NO native addons (keytar/OS-keychain don't load under Bare):
// sodium-universal is already in the graph and ships argon2id + secretbox.
//
// On-disk vault (replaces the plaintext { entropy } in identity.json):
//   { v: 2, enc: 'argon2id-secretbox',
//     kdf: { salt: hex, opslimit, memlimit, alg: 'argon2id13' },
//     nonce: hex, cipher: hex }            // cipher = secretbox(entropy)
// Fail CLOSED: a wrong passphrase or any tamper makes secretbox_open fail.

const sodium = require('sodium-universal')
const b4a = require('b4a')

const VAULT_VERSION = 2
const ENC = 'argon2id-secretbox'

function deriveKey (passphrase, salt, opslimit, memlimit) {
  const key = b4a.alloc(sodium.crypto_secretbox_KEYBYTES)
  sodium.crypto_pwhash(key, b4a.from(String(passphrase), 'utf8'), salt, opslimit, memlimit, sodium.crypto_pwhash_ALG_ARGON2ID13)
  return key
}

// Encrypt raw entropy (16 or 32 bytes) under a passphrase → the vault object.
function encryptEntropy (entropy, passphrase) {
  if (!b4a.isBuffer(entropy) && !(entropy instanceof Uint8Array)) throw new Error('entropy must be a Buffer')
  if (entropy.length !== 16 && entropy.length !== 32) throw new Error('entropy must be 16 or 32 bytes')
  if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('passphrase must be a non-empty string')

  const salt = b4a.alloc(sodium.crypto_pwhash_SALTBYTES); sodium.randombytes_buf(salt)
  const opslimit = sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE
  const memlimit = sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE
  const key = deriveKey(passphrase, salt, opslimit, memlimit)

  const nonce = b4a.alloc(sodium.crypto_secretbox_NONCEBYTES); sodium.randombytes_buf(nonce)
  const cipher = b4a.alloc(entropy.length + sodium.crypto_secretbox_MACBYTES)
  sodium.crypto_secretbox_easy(cipher, entropy, nonce, key)

  return {
    v: VAULT_VERSION,
    enc: ENC,
    kdf: { salt: b4a.toString(salt, 'hex'), opslimit, memlimit, alg: 'argon2id13' },
    nonce: b4a.toString(nonce, 'hex'),
    cipher: b4a.toString(cipher, 'hex'),
  }
}

function isEncryptedVault (obj) {
  return !!(obj && obj.v === VAULT_VERSION && obj.enc === ENC && obj.kdf &&
    typeof obj.kdf.salt === 'string' && Number.isInteger(obj.kdf.opslimit) &&
    Number.isInteger(obj.kdf.memlimit) && typeof obj.nonce === 'string' && typeof obj.cipher === 'string')
}

// Decrypt a vault → the entropy Buffer. Throws (fail-closed) on a wrong passphrase,
// a malformed vault, or any tampering.
function decryptEntropy (vault, passphrase) {
  if (!isEncryptedVault(vault)) throw new Error('not an encrypted seed vault')
  if (typeof passphrase !== 'string' || passphrase.length === 0) throw new Error('passphrase must be a non-empty string')
  let salt, nonce, cipher
  try {
    salt = b4a.from(vault.kdf.salt, 'hex')
    nonce = b4a.from(vault.nonce, 'hex')
    cipher = b4a.from(vault.cipher, 'hex')
  } catch { throw new Error('malformed vault encoding') }
  if (salt.length !== sodium.crypto_pwhash_SALTBYTES) throw new Error('bad salt length')
  if (nonce.length !== sodium.crypto_secretbox_NONCEBYTES) throw new Error('bad nonce length')
  if (cipher.length <= sodium.crypto_secretbox_MACBYTES) throw new Error('bad cipher length')

  const key = deriveKey(passphrase, salt, vault.kdf.opslimit, vault.kdf.memlimit)
  const out = b4a.alloc(cipher.length - sodium.crypto_secretbox_MACBYTES)
  if (!sodium.crypto_secretbox_open_easy(out, cipher, nonce, key)) {
    throw new Error('wrong passphrase or corrupt vault') // FAIL CLOSED
  }
  return out
}

module.exports = { encryptEntropy, decryptEntropy, isEncryptedVault, VAULT_VERSION }