Files
salvium-rs/src/wallet-encryption.js
T
Matt Hess 530b3ad3c9 Wire Argon2id through crypto provider, rebuild WASM with Rust binding
wallet-encryption.js was importing argon2id directly from @noble/hashes,
  bypassing the crypto provider entirely and always using the slow pure-JS
  implementation. Now routes through the provider so WASM (Rust) and JSI
  (native) backends are used when available, with Noble as a last-resort
  JS fallback that emits a console.warn.
2026-02-10 19:28:12 +00:00

261 lines
9.3 KiB
JavaScript

/**
* Post-Quantum Wallet Encryption
*
* Hybrid encryption for wallet data at rest:
* 1. Argon2id(password, salt1) → classicalKey (32 bytes)
* 2. Argon2id(password, salt2) → 64-byte seed → ML-KEM-768 keypair
* encapsulate(pk) → (kyberCT, quantumKey 32 bytes)
* 3. HKDF-SHA256(classicalKey || quantumKey) → encryptionKey (32 bytes)
* 4. AES-256-GCM(encryptionKey, iv, secrets) → ciphertext
*
* The Kyber keypair is derived deterministically from the password (not the
* wallet seed) so no extra key material needs to be managed, and a seed
* compromise alone does not break encryption.
*
* @module wallet-encryption
*/
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
import { gcm } from '@noble/ciphers/aes.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { argon2id } from './crypto/index.js';
import { randomBytes, concatBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { bytesToHex, hexToBytes } from './address.js';
export const ENCRYPTION_VERSION = 1;
// Default Argon2id parameters (OWASP minimums)
const ARGON2_DEFAULTS = { t: 3, m: 65536, p: 4 };
// Domain separation
const KEM_DOMAIN = utf8ToBytes('salvium-wallet-kem-v1');
const HKDF_INFO = utf8ToBytes('salvium-wallet-encryption-key-v1');
// Fields that contain secrets and must be encrypted
const SENSITIVE_KEYS = ['seed', 'mnemonic', 'spendSecretKey', 'viewSecretKey', 'dataKey'];
// CARROT public-only keys (stay plaintext for wallet identification)
const CARROT_PUBLIC_KEYS = ['accountSpendPubkey', 'primaryAddressViewPubkey', 'accountViewPubkey'];
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function deriveClassicalKey(password, salt, params) {
return argon2id(utf8ToBytes(password), salt, {
t: params.t, m: params.m, p: params.p, dkLen: 32,
});
}
function deriveKyberKeypair(password, kemSalt, params) {
const seed = argon2id(utf8ToBytes(password), concatBytes(kemSalt, KEM_DOMAIN), {
t: params.t, m: params.m, p: params.p, dkLen: 64,
});
return ml_kem768.keygen(seed);
}
function combineKeys(classicalKey, quantumKey) {
return hkdf(sha256, concatBytes(classicalKey, quantumKey), undefined, HKDF_INFO, 32);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Encrypt sensitive wallet fields with hybrid PQ encryption.
*
* @param {Object} walletJSON - Plain wallet JSON (from wallet.toJSON(true))
* @param {string} password - User-chosen password
* @param {Object} [options]
* @param {Object} [options.argon2] - Override { t, m, p }
* @returns {Object} Encrypted wallet envelope (JSON-serializable)
*/
export function encryptWalletJSON(walletJSON, password, options = {}) {
const params = { ...ARGON2_DEFAULTS, ...options.argon2 };
// 1. Collect sensitive fields
const secrets = {};
for (const key of SENSITIVE_KEYS) {
if (walletJSON[key] !== undefined) secrets[key] = walletJSON[key];
}
if (walletJSON.carrotKeys) {
const carrotSecrets = {};
for (const [k, v] of Object.entries(walletJSON.carrotKeys)) {
if (!CARROT_PUBLIC_KEYS.includes(k)) carrotSecrets[k] = v;
}
if (Object.keys(carrotSecrets).length > 0) secrets.carrotSecrets = carrotSecrets;
}
const plaintext = utf8ToBytes(JSON.stringify(secrets));
// 2. Random salts
const kdfSalt = randomBytes(32);
const kemSalt = randomBytes(32);
// 3. Classical key from password
const classicalKey = deriveClassicalKey(password, kdfSalt, params);
// 4. ML-KEM-768 encapsulation
const { publicKey: kyberPK } = deriveKyberKeypair(password, kemSalt, params);
const { cipherText: kyberCT, sharedSecret: quantumKey } = ml_kem768.encapsulate(kyberPK);
// 5. Combined encryption key
const encryptionKey = combineKeys(classicalKey, quantumKey);
// 6. AES-256-GCM encrypt
const iv = randomBytes(12);
const ciphertext = gcm(encryptionKey, iv).encrypt(plaintext);
// 7. Build public (unencrypted) portion
const publicData = {};
for (const k of ['version', 'type', 'network', 'spendPublicKey', 'viewPublicKey',
'address', 'carrotAddress', 'syncHeight', 'accounts', 'nextSubaddressIndex']) {
if (walletJSON[k] !== undefined) publicData[k] = walletJSON[k];
}
if (walletJSON.carrotKeys) {
publicData.carrotKeys = {};
for (const k of CARROT_PUBLIC_KEYS) {
if (walletJSON.carrotKeys[k]) publicData.carrotKeys[k] = walletJSON.carrotKeys[k];
}
}
return {
encrypted: true,
encryptionVersion: ENCRYPTION_VERSION,
...publicData,
encryption: {
kdfSalt: bytesToHex(kdfSalt),
kemSalt: bytesToHex(kemSalt),
kyberCiphertext: bytesToHex(kyberCT),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
argon2: { t: params.t, m: params.m, p: params.p },
},
};
}
/**
* Decrypt an encrypted wallet envelope.
*
* @param {Object} envelope - Encrypted wallet JSON
* @param {string} password - User password
* @returns {Object} Plain wallet JSON (same format as toJSON)
* @throws {Error} On wrong password or corrupted data
*/
export function decryptWalletJSON(envelope, password) {
if (!envelope.encrypted) return envelope;
const enc = envelope.encryption;
if (!enc) throw new Error('Missing encryption metadata');
const params = enc.argon2 || ARGON2_DEFAULTS;
// 1. Derive classical key
const classicalKey = deriveClassicalKey(password, hexToBytes(enc.kdfSalt), params);
// 2. Derive Kyber keypair & decapsulate
const { secretKey: kyberSK } = deriveKyberKeypair(password, hexToBytes(enc.kemSalt), params);
const quantumKey = ml_kem768.decapsulate(hexToBytes(enc.kyberCiphertext), kyberSK);
// 3. Combined key
const encryptionKey = combineKeys(classicalKey, quantumKey);
// 4. AES-256-GCM decrypt (throws on auth failure = wrong password)
let plaintext;
try {
plaintext = gcm(encryptionKey, hexToBytes(enc.iv)).decrypt(hexToBytes(enc.ciphertext));
} catch (_e) {
throw new Error('Decryption failed: incorrect password or corrupted data');
}
// 5. Parse secrets and merge with public data
const secrets = JSON.parse(new TextDecoder().decode(plaintext));
const walletJSON = {};
for (const k of ['version', 'type', 'network', 'spendPublicKey', 'viewPublicKey',
'address', 'carrotAddress', 'syncHeight', 'accounts', 'nextSubaddressIndex']) {
if (envelope[k] !== undefined) walletJSON[k] = envelope[k];
}
Object.assign(walletJSON, secrets);
// Merge CARROT public + secret keys
if (envelope.carrotKeys || secrets.carrotSecrets) {
walletJSON.carrotKeys = {
...(envelope.carrotKeys || {}),
...(secrets.carrotSecrets || {}),
};
delete walletJSON.carrotSecrets;
}
return walletJSON;
}
/**
* Re-encrypt a wallet envelope with a new password.
* Decrypts with the old password, then encrypts with the new one
* (fresh salts, fresh KEM keypair).
*
* @param {Object} envelope - Encrypted wallet JSON
* @param {string} oldPassword - Current password
* @param {string} newPassword - New password
* @param {Object} [options]
* @param {Object} [options.argon2] - Override Argon2 params for new encryption
* @returns {Object} Newly encrypted wallet envelope
* @throws {Error} On wrong old password or corrupted data
*/
export function reEncryptWalletJSON(envelope, oldPassword, newPassword, options = {}) {
const plain = decryptWalletJSON(envelope, oldPassword);
return encryptWalletJSON(plain, newPassword, options);
}
/**
* Check if a wallet JSON object is encrypted.
* @param {Object} json
* @returns {boolean}
*/
export function isEncryptedWallet(json) {
return json?.encrypted === true && json?.encryption != null;
}
// ---------------------------------------------------------------------------
// Fast data encryption (for sync cache, using pre-derived data key)
// ---------------------------------------------------------------------------
/**
* Encrypt arbitrary data with a 32-byte key using AES-256-GCM.
* Used for sync cache and other at-rest data where the key is already
* in memory (no Argon2id / ML-KEM ceremony needed).
*
* @param {Uint8Array} key - 32-byte encryption key (the wallet's dataKey)
* @param {string|Uint8Array} plaintext - Data to encrypt (string will be UTF-8 encoded)
* @returns {Object} { encrypted: true, iv: hex, ciphertext: hex }
*/
export function encryptData(key, plaintext) {
const data = typeof plaintext === 'string' ? utf8ToBytes(plaintext) : plaintext;
const iv = randomBytes(12);
const ciphertext = gcm(key, iv).encrypt(data);
return {
encrypted: true,
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
};
}
/**
* Decrypt data encrypted by encryptData().
*
* @param {Uint8Array} key - 32-byte encryption key (the wallet's dataKey)
* @param {Object} envelope - { iv: hex, ciphertext: hex }
* @returns {Uint8Array} Decrypted data
* @throws {Error} On wrong key or corrupted data
*/
export function decryptData(key, envelope) {
try {
return gcm(key, hexToBytes(envelope.iv)).decrypt(hexToBytes(envelope.ciphertext));
} catch (_e) {
throw new Error('Data decryption failed: wrong key or corrupted data');
}
}