chaingate
Version:
Multi-chain cryptocurrency SDK for TypeScript — unified API for Bitcoin, Ethereum, Litecoin, Dogecoin, Bitcoin Cash, Polygon, Arbitrum, and any EVM-compatible chain. Create wallets, query balances, send transactions, and manage tokens and NFTs across UTXO
204 lines (203 loc) • 8.75 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Secret = void 0;
const errors_1 = require("./errors");
const utils_1 = require("../utils");
/**
* AES-256-GCM encryption with PBKDF2 key derivation.
*
* The password is run through PBKDF2 (600 000 iterations, SHA-256) to derive
* the AES-256 key. This provides brute-force resistance when the encrypted
* payload is exported/persisted.
*
* To avoid running PBKDF2 on every encrypt/decrypt cycle (withDecrypted),
* the derived CryptoKey is cached in memory after the first successful
* derivation. Only the derivedKey is kept — the plaintext password is
* NEVER stored. The cache is cleared on a wrong-password attempt so the
* next try re-derives from scratch.
*
* On export (getEncryptedExport / serialize) the derivedKey is NOT included;
* only ciphertext + iv + salt are persisted. When the wallet is later
* deserialized, the first withDecrypted() call will ask for the password,
* run PBKDF2 once, and cache the result for all subsequent in-memory calls.
*/
const IV_LENGTH = 12;
const SALT_LENGTH = 16;
const PBKDF2_ITERATIONS = 600000;
// ---------------------------------------------------------------------------
// Key derivation: PBKDF2 (password + salt) -> AES-256-GCM CryptoKey
// ---------------------------------------------------------------------------
async function deriveKey(password, salt) {
// Import the password as raw key material for PBKDF2
const baseKey = await globalThis.crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
// Derive a 256-bit AES-GCM key using PBKDF2 with the given salt
return globalThis.crypto.subtle.deriveKey({
name: 'PBKDF2',
salt: salt,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
}, baseKey, { name: 'AES-GCM', length: 256 }, false, // non-extractable — the derived key cannot be read back
['encrypt', 'decrypt']);
}
// ---------------------------------------------------------------------------
// AES-256-GCM encrypt / decrypt helpers
// ---------------------------------------------------------------------------
async function aesEncrypt(data, key, iv) {
const ciphertext = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, data);
return new Uint8Array(ciphertext);
}
async function aesDecrypt(data, key, iv) {
const plaintext = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, data);
return new Uint8Array(plaintext);
}
// ---------------------------------------------------------------------------
// Secret — abstract base that wraps encrypted byte data
// ---------------------------------------------------------------------------
/**
* Base class for encryptable secret data ({@link Phrase}, {@link Seed}, {@link Xpriv}, {@link PrivateKey}).
*
* Supports password-based encryption, temporary decryption via {@link withDecrypted},
* and memory cleanup via {@link zeroize}.
*/
class Secret {
/** @internal */
constructor(dataOrEncrypted) {
this.encryption = null;
if (dataOrEncrypted instanceof Uint8Array) {
this._data = dataOrEncrypted;
}
else {
// Restoring from serialized encrypted payload — no derived key yet.
// The first withDecrypted() call will ask for the password and run
// PBKDF2 once; subsequent calls reuse the cached derived key.
this._data = dataOrEncrypted.ciphertext;
this.encryption = {
iv: dataOrEncrypted.iv,
salt: dataOrEncrypted.salt,
cache: null,
askForPassword: dataOrEncrypted.askForPassword,
};
}
}
/** @internal */
static resolveInput(source, fromString) {
if (source instanceof Uint8Array)
return source;
if (typeof source === 'object')
return source;
return fromString(source);
}
/** @internal */
get data() {
if (this.encryption)
throw new errors_1.EncryptedAccessError();
return this._data;
}
/** Zeros out the secret data in memory. Call when you no longer need this secret. */
zeroize() {
this._data.fill(0);
}
/** Whether this secret is currently encrypted. */
get encrypted() {
return this.encryption !== null;
}
/**
* Returns the encrypted data as hex strings, for serialization.
*
* @throws {@link NotEncryptedError} if not encrypted.
*/
getEncryptedExport() {
if (!this.encryption)
throw new errors_1.NotEncryptedError();
// Only ciphertext, iv, and salt are exported — the derivedKey stays in memory
return {
ciphertext: (0, utils_1.bytesToHex)(this._data),
iv: (0, utils_1.bytesToHex)(this.encryption.iv),
salt: (0, utils_1.bytesToHex)(this.encryption.salt),
};
}
/**
* Encrypts this secret with a password. The plaintext is zeroed after encryption.
*
* @param password - The encryption password.
* @param askForPassword - Callback for future decryption prompts.
* @throws {@link AlreadyEncryptedError} if already encrypted.
*/
async encrypt(password, askForPassword) {
if (this.encryption)
throw new errors_1.AlreadyEncryptedError();
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const salt = globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
// Run PBKDF2 once during encryption; the derived key is cached for the
// lifetime of this in-memory object so subsequent operations are fast.
const key = await deriveKey(password, salt);
const plaintext = this._data;
this._data = await aesEncrypt(plaintext, key, iv);
plaintext.fill(0);
// Store the derived key — NOT the password — in the cache
this.encryption = { iv, salt, cache: { key }, askForPassword };
}
/**
* Temporarily decrypts the secret, runs `fn`, then re-encrypts automatically.
*
* If not encrypted, `fn` runs immediately. If a wrong password is entered,
* the user is prompted again until correct or cancelled.
*
* @param fn - Callback with access to the decrypted data.
* @throws {@link DecryptionCancelledError} if the user cancels the password prompt.
*
* @example
* ```ts
* const hex = await secret.withDecrypted(() => secret.hex);
* ```
*/
async withDecrypted(fn) {
if (!this.encryption)
return fn();
while (true) {
let key;
if (this.encryption.cache) {
// Fast path: reuse the PBKDF2-derived key already in memory.
// No password is needed and no KDF work is performed.
key = this.encryption.cache.key;
}
else {
// Slow path: no cached key (first access after deserialization, or
// cache was cleared after a wrong-password attempt). Ask the user
// for the password and run PBKDF2 once.
const password = await this.encryption.askForPassword();
if (password === null)
throw new errors_1.DecryptionCancelledError();
key = await deriveKey(password, this.encryption.salt);
}
let decrypted;
try {
decrypted = await aesDecrypt(this._data, key, this.encryption.iv);
}
catch {
// Wrong key — clear the cache so the next iteration asks for the
// password again and re-derives via PBKDF2.
this.encryption.cache = null;
continue;
}
// Cache the successful derived key for future calls (password is NOT kept)
this.encryption.cache = { key };
// Temporarily expose plaintext: set aside encryption so `this.data` getter works
const savedEncryption = this.encryption;
this.encryption = null;
this._data = decrypted;
try {
return fn();
}
finally {
// Re-encrypt with a fresh IV (same salt + derived key)
const newIv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
this._data = await aesEncrypt(decrypted, key, newIv);
savedEncryption.iv = newIv;
this.encryption = savedEncryption;
decrypted.fill(0);
}
}
}
}
exports.Secret = Secret;