Encrypt secrets with libsodium, password required to send
All checks were successful
check / check (push) Successful in 14s

vault.js: Argon2id key derivation + XSalsa20-Poly1305 encryption
via libsodium-wrappers-sumo. No raw crypto primitives.

Wallet creation now requires a password. The mnemonic or private
key is encrypted before storage — only the ciphertext blob
(salt, nonce, ciphertext) is persisted. The plaintext secret
is never stored.

Sending requires the password to decrypt the secret, derive
the signing key, and construct the transaction. Wrong password
is caught and reported.
This commit is contained in:
2026-02-25 18:23:09 +07:00
parent bfecddf2f7
commit f2e22cadf2
4 changed files with 183 additions and 14 deletions

View File

@@ -476,9 +476,9 @@ Everything needed for a minimal working wallet that can send and receive ETH.
### Sending ### Sending
- [ ] Encrypt recovery phrase / private key with password via libsodium - [x] Encrypt recovery phrase / private key with password via libsodium
(Argon2id + XSalsa20-Poly1305) (Argon2id + XSalsa20-Poly1305)
- [ ] Password prompt on Send (decrypt private key to construct transaction) - [x] Password prompt on Send (decrypt private key to construct transaction)
- [x] Transaction construction via ethers.js (to, value, gasLimit, gasPrice) - [x] Transaction construction via ethers.js (to, value, gasLimit, gasPrice)
- [ ] Gas estimation and fee display before confirming - [ ] Gas estimation and fee display before confirming
- [x] Broadcast transaction via `eth_sendRawTransaction` - [x] Broadcast transaction via `eth_sendRawTransaction`

View File

@@ -57,6 +57,26 @@
can access your funds. If you lose them, your wallet cannot can access your funds. If you lose them, your wallet cannot
be recovered. be recovered.
</div> </div>
<div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your recovery phrase on this
device. You will need it to send funds.
</p>
<input
type="password"
id="add-wallet-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="add-wallet-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="add-wallet-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
id="btn-add-wallet-confirm" id="btn-add-wallet-confirm"
@@ -103,6 +123,26 @@
placeholder="0x..." placeholder="0x..."
/> />
</div> </div>
<div class="mb-2" id="import-key-password-section">
<label class="block mb-1">Choose a password</label>
<p class="text-xs text-muted mb-1">
This password encrypts your private key on this device.
You will need it to send funds.
</p>
<input
type="password"
id="import-key-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="mb-2" id="import-key-password-confirm-section">
<label class="block mb-1">Confirm password</label>
<input
type="password"
id="import-key-password-confirm"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
id="btn-import-key-confirm" id="btn-import-key-confirm"
@@ -259,6 +299,17 @@
id="send-fee-estimate" id="send-fee-estimate"
class="text-xs text-muted mb-2 hidden" class="text-xs text-muted mb-2 hidden"
></div> ></div>
<div class="mb-2">
<label class="block mb-1">Password</label>
<p class="text-xs text-muted mb-1">
Required to authorize the transaction.
</p>
<input
type="password"
id="send-password"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
/>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
id="btn-send-confirm" id="btn-send-confirm"

View File

@@ -8,6 +8,7 @@ const {
parseEther, parseEther,
} = require("ethers"); } = require("ethers");
const { getTopTokenPrices } = require("../shared/tokens"); const { getTopTokenPrices } = require("../shared/tokens");
const { encryptWithPassword, decryptWithPassword } = require("../shared/vault");
const QRCode = require("qrcode"); const QRCode = require("qrcode");
const DEBUG = true; const DEBUG = true;
@@ -147,19 +148,20 @@ function formatUsd(amount) {
); );
} }
// Get an ethers Wallet (signer) for the currently selected address // Get an ethers Wallet (signer) for the currently selected address.
function getSignerForCurrentAddress() { // Requires the decrypted secret (mnemonic or private key).
function getSignerForCurrentAddress(decryptedSecret) {
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
const addrIndex = state.selectedAddress; const addrIndex = state.selectedAddress;
if (wallet.type === "hd") { if (wallet.type === "hd") {
const node = HDNodeWallet.fromPhrase( const node = HDNodeWallet.fromPhrase(
wallet.mnemonic, decryptedSecret,
"", "",
BIP44_ETH_BASE, BIP44_ETH_BASE,
); );
return node.deriveChild(addrIndex); return node.deriveChild(addrIndex);
} else { } else {
return new Wallet(wallet.privateKey); return new Wallet(decryptedSecret);
} }
} }
@@ -398,7 +400,7 @@ async function init() {
$("add-wallet-phrase-warning").classList.remove("hidden"); $("add-wallet-phrase-warning").classList.remove("hidden");
}); });
$("btn-add-wallet-confirm").addEventListener("click", () => { $("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim(); const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) { if (!mnemonic) {
showError( showError(
@@ -417,7 +419,6 @@ async function init() {
); );
return; return;
} }
// Validate the mnemonic is real BIP-39
if (!Mnemonic.isValidMnemonic(mnemonic)) { if (!Mnemonic.isValidMnemonic(mnemonic)) {
showError( showError(
"add-wallet-error", "add-wallet-error",
@@ -425,15 +426,33 @@ async function init() {
); );
return; return;
} }
const pw = $("add-wallet-password").value;
const pw2 = $("add-wallet-password-confirm").value;
if (!pw) {
showError("add-wallet-error", "Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"add-wallet-error",
"Password must be at least 8 characters.",
);
return;
}
if (pw !== pw2) {
showError("add-wallet-error", "Passwords do not match.");
return;
}
hideError("add-wallet-error"); hideError("add-wallet-error");
const encrypted = await encryptWithPassword(mnemonic, pw);
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic); const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const walletNum = state.wallets.length + 1; const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({ addWalletAndGoToMain({
type: "hd", type: "hd",
name: "Wallet " + walletNum, name: "Wallet " + walletNum,
xpub: xpub, xpub: xpub,
mnemonic: mnemonic, encryptedSecret: encrypted,
nextIndex: 1, nextIndex: 1,
addresses: [ addresses: [
{ address: firstAddress, balance: "0.0000", tokens: [] }, { address: firstAddress, balance: "0.0000", tokens: [] },
@@ -445,7 +464,7 @@ async function init() {
$("btn-add-wallet-import-key").addEventListener("click", showImportKeyView); $("btn-add-wallet-import-key").addEventListener("click", showImportKeyView);
// -- Import private key -- // -- Import private key --
$("btn-import-key-confirm").addEventListener("click", () => { $("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim(); const key = $("import-private-key").value.trim();
if (!key) { if (!key) {
showError("import-key-error", "Please enter your private key."); showError("import-key-error", "Please enter your private key.");
@@ -458,12 +477,30 @@ async function init() {
showError("import-key-error", "Invalid private key."); showError("import-key-error", "Invalid private key.");
return; return;
} }
const pw = $("import-key-password").value;
const pw2 = $("import-key-password-confirm").value;
if (!pw) {
showError("import-key-error", "Please choose a password.");
return;
}
if (pw.length < 8) {
showError(
"import-key-error",
"Password must be at least 8 characters.",
);
return;
}
if (pw !== pw2) {
showError("import-key-error", "Passwords do not match.");
return;
}
hideError("import-key-error"); hideError("import-key-error");
const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1; const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({ addWalletAndGoToMain({
type: "key", type: "key",
name: "Wallet " + walletNum, name: "Wallet " + walletNum,
privateKey: key, encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokens: [] }], addresses: [{ address: addr, balance: "0.0000", tokens: [] }],
}); });
}); });
@@ -498,6 +535,7 @@ async function init() {
$("btn-send").addEventListener("click", () => { $("btn-send").addEventListener("click", () => {
$("send-to").value = ""; $("send-to").value = "";
$("send-amount").value = ""; $("send-amount").value = "";
$("send-password").value = "";
$("send-fee-estimate").classList.add("hidden"); $("send-fee-estimate").classList.add("hidden");
$("send-status").classList.add("hidden"); $("send-status").classList.add("hidden");
showView("send"); showView("send");
@@ -558,11 +596,29 @@ async function init() {
return; return;
} }
} }
const statusEl = $("send-status") || $("send-status"); const password = $("send-password").value;
statusEl.textContent = "Sending..."; if (!password) {
showError("send-status", "Please enter your password.");
$("send-status").classList.remove("hidden");
return;
}
const wallet = state.wallets[state.selectedWallet];
let decryptedSecret;
const statusEl = $("send-status");
statusEl.textContent = "Decrypting...";
statusEl.classList.remove("hidden"); statusEl.classList.remove("hidden");
try { try {
const signer = getSignerForCurrentAddress(); decryptedSecret = await decryptWithPassword(
wallet.encryptedSecret,
password,
);
} catch (e) {
showError("send-status", "Wrong password.");
return;
}
statusEl.textContent = "Sending...";
try {
const signer = getSignerForCurrentAddress(decryptedSecret);
const provider = getProvider(); const provider = getProvider();
const connectedSigner = signer.connect(provider); const connectedSigner = signer.connect(provider);
const tx = await connectedSigner.sendTransaction({ const tx = await connectedSigner.sendTransaction({

62
src/shared/vault.js Normal file
View File

@@ -0,0 +1,62 @@
// Vault: password-based encryption of secrets using libsodium.
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
// All crypto operations are delegated to libsodium — no raw primitives.
const sodium = require("libsodium-wrappers-sumo");
let ready = false;
async function ensureReady() {
if (!ready) {
await sodium.ready;
ready = true;
}
}
// Returns { salt, nonce, ciphertext } (all base64-encoded strings).
async function encryptWithPassword(plaintext, password) {
await ensureReady();
const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
password,
salt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_ARGON2ID13,
);
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(
sodium.from_string(plaintext),
nonce,
key,
);
return {
salt: sodium.to_base64(salt),
nonce: sodium.to_base64(nonce),
ciphertext: sodium.to_base64(ciphertext),
};
}
// Returns the plaintext string, or throws on wrong password.
async function decryptWithPassword(encrypted, password) {
await ensureReady();
const salt = sodium.from_base64(encrypted.salt);
const nonce = sodium.from_base64(encrypted.nonce);
const ciphertext = sodium.from_base64(encrypted.ciphertext);
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
password,
salt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_ARGON2ID13,
);
const plaintext = sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
if (!plaintext) {
throw new Error("Decryption failed — wrong password.");
}
return sodium.to_string(plaintext);
}
module.exports = { encryptWithPassword, decryptWithPassword };