diff --git a/src/index.js b/src/index.js index 6bb1a53..77b446d 100644 --- a/src/index.js +++ b/src/index.js @@ -41,7 +41,7 @@ export * from './persistent-wallet.js'; export * from './consensus.js'; // Post-quantum wallet encryption -export { encryptWalletJSON, decryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js'; +export { encryptWalletJSON, decryptWalletJSON, reEncryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js'; // Validation exports export * from './validation.js'; diff --git a/src/wallet-encryption.js b/src/wallet-encryption.js index f4020bc..e7c1a0e 100644 --- a/src/wallet-encryption.js +++ b/src/wallet-encryption.js @@ -192,6 +192,24 @@ export function decryptWalletJSON(envelope, password) { 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 diff --git a/src/wallet-store.js b/src/wallet-store.js index 7d97829..ff24e34 100644 --- a/src/wallet-store.js +++ b/src/wallet-store.js @@ -652,6 +652,16 @@ export class MemoryStorage extends WalletStorage { for (const ki of data.spentKeyImages) this._spentKeyImages.add(ki); } + // Reconcile: ensure outputs whose key images are in _spentKeyImages + // have isSpent=true. This fixes stale caches where sweep() marked KIs + // as spent but output objects weren't updated before serialization. + for (const ki of this._spentKeyImages) { + const output = this._outputs.get(ki); + if (output && !output.isSpent) { + output.isSpent = true; + } + } + if (data.blockHashes) { // Only load the most recent block hashes (prune old ones on load) const entries = Object.entries(data.blockHashes).map(([h, hash]) => [parseInt(h), hash]); diff --git a/src/wallet.js b/src/wallet.js index 1f9b2c8..11552b9 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -51,7 +51,7 @@ import { } from './wallet/transfer.js'; // Post-quantum wallet encryption -import { encryptWalletJSON, decryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js'; +import { encryptWalletJSON, decryptWalletJSON, reEncryptWalletJSON, isEncryptedWallet } from './wallet-encryption.js'; // ============================================================================ // IMPORTS FROM WALLET SUBMODULES @@ -2243,6 +2243,22 @@ export class Wallet { return isEncryptedWallet(json); } + /** + * Re-encrypt an encrypted wallet envelope with a new password. + * Generates fresh salts and a 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 + * @returns {Object} Newly encrypted wallet envelope + * @throws {Error} On wrong old password + */ + static changePassword(envelope, oldPassword, newPassword, options = {}) { + return reEncryptWalletJSON(envelope, oldPassword, newPassword, options); + } + // =========================================================================== // SERIALIZATION // =========================================================================== diff --git a/test/run-all.sh b/test/run-all.sh new file mode 100755 index 0000000..e6b6f5a --- /dev/null +++ b/test/run-all.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Run all tests: unit tests first, then integration tests +# +# Usage: +# ./test/run-all.sh # Unit + integration tests +# ./test/run-all.sh --unit # Unit tests only +# ./test/run-all.sh --integration [phase] # Integration tests only +set -euo pipefail + +cd "$(dirname "$0")/.." + +MODE="${1:---both}" + +case "$MODE" in + --unit) + bash test/run-unit.sh + ;; + --integration) + shift + bash test/run-integration.sh "${1:-all}" + ;; + --both|*) + echo "============================================================" + echo " salvium-js Full Test Suite" + echo "============================================================" + echo "" + + echo ">>> Unit Tests" + echo "" + if bash test/run-unit.sh; then + echo "" + echo ">>> Unit tests PASSED, running integration tests..." + echo "" + bash test/run-integration.sh "${2:-all}" + else + echo "" + echo ">>> Unit tests FAILED, skipping integration tests" + exit 1 + fi + ;; +esac diff --git a/test/run-integration.sh b/test/run-integration.sh new file mode 100755 index 0000000..837d560 --- /dev/null +++ b/test/run-integration.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Run integration tests (requires live testnet daemon) +# +# Usage: +# ./test/run-integration.sh # Run all integration tests +# ./test/run-integration.sh burn-in # Run burn-in test (full: CN + CARROT) +# ./test/run-integration.sh burn-in-cn # Run burn-in CN phase only +# ./test/run-integration.sh burn-in-carrot # Run burn-in CARROT phase only +# ./test/run-integration.sh sync # Sync-only test +# ./test/run-integration.sh sweep # Sweep test +# ./test/run-integration.sh transfer # Integration transfer test +# ./test/run-integration.sh stress # Stress micro-transfer test +set -euo pipefail + +cd "$(dirname "$0")/.." + +DAEMON_URL="${DAEMON_URL:-http://web.whiskymine.io:29081}" + +echo "==========================================" +echo " salvium-js Integration Tests" +echo "==========================================" +echo " Daemon: $DAEMON_URL" + +# Quick daemon check +if ! bun -e " +import { DaemonRPC } from './src/rpc/daemon.js'; +const d = new DaemonRPC({ url: '$DAEMON_URL' }); +const i = await d.getInfo(); +console.log(' Height: ' + (i.result?.height || 'unknown')); +console.log(' Testnet: ' + (i.result?.testnet || 'unknown')); +" 2>/dev/null; then + echo " ERROR: Cannot reach daemon at $DAEMON_URL" + exit 1 +fi +echo "" + +PHASE="${1:-all}" + +case "$PHASE" in + burn-in) + echo "--- Burn-in Test (full) ---" + bun test/burn-in.test.js + ;; + burn-in-cn) + echo "--- Burn-in Test (CN phase only) ---" + bun test/burn-in.test.js --phase cn + ;; + burn-in-carrot) + echo "--- Burn-in Test (CARROT phase only) ---" + bun test/burn-in.test.js --phase carrot + ;; + sync) + echo "--- Sync-Only Test ---" + bun test/sync-only.js + ;; + sweep) + echo "--- Sweep Test ---" + bun test/sweep-test.js + ;; + transfer) + echo "--- Integration Transfer Test ---" + bun test/integration-transfer.test.js + ;; + stress) + echo "--- Stress Micro-Transfer Test ---" + bun test/stress-micro.test.js + ;; + all) + echo "--- Running all integration tests ---" + echo "" + PASSED=0 + FAILED=0 + + for test_info in \ + "Sync only:test/sync-only.js" \ + "Integration transfer:test/integration-transfer.test.js" \ + "Burn-in (full):test/burn-in.test.js" \ + ; do + name="${test_info%%:*}" + file="${test_info##*:}" + echo "=== $name ===" + if bun "$file" 2>&1; then + PASSED=$((PASSED + 1)) + else + FAILED=$((FAILED + 1)) + echo " FAILED: $name" + fi + echo "" + done + + echo "==========================================" + echo " Results: $PASSED passed, $FAILED failed" + echo "==========================================" + [ $FAILED -gt 0 ] && exit 1 + ;; + *) + echo "Unknown phase: $PHASE" + echo "Usage: $0 [burn-in|burn-in-cn|burn-in-carrot|sync|sweep|transfer|stress|all]" + exit 1 + ;; +esac diff --git a/test/run-unit.sh b/test/run-unit.sh new file mode 100755 index 0000000..d4ee8b7 --- /dev/null +++ b/test/run-unit.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Run all offline unit tests (no daemon required) +# +# Two test styles: +# - Custom runner tests: run with `bun ` +# - Bun test runner tests (describe/test): run with `bun test ` +# +# Note: Some tests that import from src/index.js (barrel export) fail due to +# a pre-existing hashToPoint re-export issue in bulletproofs_plus.js. +# Tests that import directly from submodules are unaffected. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==========================================" +echo " salvium-js Unit Tests" +echo "==========================================" +echo "" + +PASSED=0 +FAILED=0 +SKIPPED=0 +ERRORS="" + +# Run a test file with `bun ` +run_test() { + local name="$1" + local file="$2" + printf " %-40s" "$name" + if output=$(bun "$file" 2>&1); then + echo "OK" + PASSED=$((PASSED + 1)) + else + echo "FAIL" + FAILED=$((FAILED + 1)) + ERRORS="$ERRORS\n--- $name ---\n$output\n" + fi +} + +# Run a test file with `bun test ` (for describe/test style) +run_bun_test() { + local name="$1" + local file="$2" + printf " %-40s" "$name" + if output=$(bun test "$file" 2>&1); then + echo "OK" + PASSED=$((PASSED + 1)) + else + echo "FAIL" + FAILED=$((FAILED + 1)) + ERRORS="$ERRORS\n--- $name ---\n$output\n" + fi +} + +skip_test() { + local name="$1" + local reason="$2" + printf " %-40s" "$name" + echo "SKIP ($reason)" + SKIPPED=$((SKIPPED + 1)) +} + +echo "--- Core tests (direct imports) ---" +run_test "Transaction (parsing, signing)" test/transaction.test.js +run_test "Wallet encryption (PQ hybrid)" test/wallet-encryption.test.js +run_test "Wallet class" test/wallet-class.test.js +run_test "Wallet store" test/wallet-store.test.js +run_test "UTXO selection" test/utxo-selection.test.js +run_test "CARROT enote types" test/carrot-enote-type.test.js +run_test "Wallet sync" test/wallet-sync.test.js + +echo "" +echo "--- Tests using barrel export (src/index.js) ---" +run_test "Core (base58, keccak, address)" test/run.js +run_test "Keys (derivation)" test/keys.test.js +run_test "Mnemonic (seed words)" test/mnemonic.test.js +run_test "Address (encode/decode)" test/address.test.js +run_test "Subaddress" test/subaddress.test.js +run_test "Key image" test/keyimage.test.js +run_test "Scanning" test/scanning.test.js +run_test "Bulletproofs+" test/bulletproofs_plus.test.js +run_test "Blake2b" test/blake2b.test.js + +echo "" +echo "--- Bun test runner tests ---" +run_bun_test "Validation" test/validation.test.js +run_bun_test "Consensus helpers" test/consensus-helpers.test.js +run_bun_test "Cross-fork TX" test/cross-fork-tx.test.js +run_bun_test "Wallet reorg" test/wallet-reorg.test.js +run_bun_test "Stake transaction" test/stake-transaction.test.js +run_bun_test "Burn transaction" test/burn-transaction.test.js +run_bun_test "Convert transaction" test/convert-transaction.test.js +run_bun_test "Audit transaction" test/audit-transaction.test.js +run_bun_test "Oracle" test/oracle.test.js +run_bun_test "Dynamic block size" test/dynamic-block-size.test.js +run_bun_test "Blockchain" test/blockchain.test.js + +echo "" +echo "==========================================" +echo " Results: $PASSED passed, $FAILED failed, $SKIPPED skipped" +echo "==========================================" + +if [ $FAILED -gt 0 ]; then + echo "" + echo "Failures:" + echo -e "$ERRORS" + exit 1 +fi diff --git a/test/test-helpers.js b/test/test-helpers.js index 5c1a776..552c9f8 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -56,11 +56,22 @@ export function short(addr) { /** * Load a wallet from a JSON file on disk. + * Supports both plain and encrypted wallet files. + * For encrypted files, reads the PIN from a sibling .pin file + * (e.g. wallet-a.json → wallet-a.pin) or accepts an explicit password. * @param {string} path - Absolute path to wallet JSON file * @param {string} [network='testnet'] - Network override + * @param {string} [password] - Explicit password (if omitted, reads .pin file) * @returns {Promise} */ -export async function loadWalletFromFile(path, network = 'testnet') { +export async function loadWalletFromFile(path, network = 'testnet', password) { const data = JSON.parse(await Bun.file(path).text()); + if (Wallet.isEncrypted(data)) { + if (!password) { + const pinPath = path.replace(/\.json$/, '.pin'); + password = (await Bun.file(pinPath).text()).trim(); + } + return Wallet.fromEncryptedJSON(data, password, { network }); + } return Wallet.fromJSON(data, { network }); } diff --git a/test/wallet-encryption.test.js b/test/wallet-encryption.test.js index ddfbb07..df94d69 100644 --- a/test/wallet-encryption.test.js +++ b/test/wallet-encryption.test.js @@ -10,6 +10,7 @@ import { Wallet } from '../src/wallet.js'; import { encryptWalletJSON, decryptWalletJSON, + reEncryptWalletJSON, isEncryptedWallet, ENCRYPTION_VERSION, } from '../src/wallet-encryption.js'; @@ -158,6 +159,56 @@ console.log('Test 10: Different passwords produce different output'); assert(enc1.encryption.kdfSalt !== enc2.encryption.kdfSalt, 'different salts'); } +// Test 11: reEncryptWalletJSON changes password +console.log('Test 11: Password change via reEncryptWalletJSON'); +{ + const enc = encryptWalletJSON(plainJSON, 'oldpass', FAST_PARAMS); + const reEnc = reEncryptWalletJSON(enc, 'oldpass', 'newpass', FAST_PARAMS); + + // Old password no longer works + let threw = false; + try { decryptWalletJSON(reEnc, 'oldpass'); } catch { threw = true; } + assert(threw, 'old password rejected after change'); + + // New password works + const dec = decryptWalletJSON(reEnc, 'newpass'); + assert(dec.seed === plainJSON.seed, 'seed intact after password change'); + assert(dec.mnemonic === plainJSON.mnemonic, 'mnemonic intact after password change'); + assert(dec.spendSecretKey === plainJSON.spendSecretKey, 'spendSecretKey intact'); + assert(dec.viewSecretKey === plainJSON.viewSecretKey, 'viewSecretKey intact'); + assert(dec.carrotKeys?.masterSecret === plainJSON.carrotKeys?.masterSecret, 'CARROT masterSecret intact'); +} + +// Test 12: Wallet.changePassword static method +console.log('Test 12: Wallet.changePassword'); +{ + const enc = wallet.toEncryptedJSON('pin123', FAST_PARAMS); + const reEnc = Wallet.changePassword(enc, 'pin123', 'pin456', FAST_PARAMS); + + assert(Wallet.isEncrypted(reEnc), 're-encrypted envelope is encrypted'); + assert(reEnc.address === enc.address, 'public address unchanged'); + assert(reEnc.carrotAddress === enc.carrotAddress, 'CARROT address unchanged'); + + // Fresh salts (not reusing old crypto material) + assert(reEnc.encryption.kdfSalt !== enc.encryption.kdfSalt, 'fresh kdfSalt'); + assert(reEnc.encryption.kemSalt !== enc.encryption.kemSalt, 'fresh kemSalt'); + + // Restore with new password + const restored = Wallet.fromEncryptedJSON(reEnc, 'pin456'); + assert(restored.getLegacyAddress() === wallet.getLegacyAddress(), 'address matches after change'); + assert(restored.getCarrotAddress() === wallet.getCarrotAddress(), 'CARROT address matches after change'); + assert(restored.canSign(), 'can sign after password change'); +} + +// Test 13: Wrong old password in changePassword throws +console.log('Test 13: changePassword with wrong old password throws'); +{ + const enc = encryptWalletJSON(plainJSON, 'correct', FAST_PARAMS); + let threw = false; + try { reEncryptWalletJSON(enc, 'wrong', 'newpass', FAST_PARAMS); } catch { threw = true; } + assert(threw, 'wrong old password throws on re-encrypt'); +} + console.log(`\nPassed: ${passed}`); console.log(`Failed: ${failed}`); console.log(`Total: ${passed + failed}`);