diff --git a/README.md b/README.md
index 9643791..5b605d2 100644
--- a/README.md
+++ b/README.md
@@ -476,9 +476,9 @@ Everything needed for a minimal working wallet that can send and receive ETH.
### Sending
-- [ ] Encrypt recovery phrase / private key with password via libsodium
+- [x] Encrypt recovery phrase / private key with password via libsodium
(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)
- [ ] Gas estimation and fee display before confirming
- [x] Broadcast transaction via `eth_sendRawTransaction`
diff --git a/src/popup/index.html b/src/popup/index.html
index aec750e..a9077a1 100644
--- a/src/popup/index.html
+++ b/src/popup/index.html
@@ -57,6 +57,26 @@
can access your funds. If you lose them, your wallet cannot
be recovered.
+
+
Choose a password
+
+ This password encrypts your recovery phrase on this
+ device. You will need it to send funds.
+
+
+
+
+ Confirm password
+
+
+
+
Choose a password
+
+ This password encrypts your private key on this device.
+ You will need it to send funds.
+
+
+
+
+ Confirm password
+
+
+
+
Password
+
+ Required to authorize the transaction.
+
+
+
{
+ $("btn-add-wallet-confirm").addEventListener("click", async () => {
const mnemonic = $("wallet-mnemonic").value.trim();
if (!mnemonic) {
showError(
@@ -417,7 +419,6 @@ async function init() {
);
return;
}
- // Validate the mnemonic is real BIP-39
if (!Mnemonic.isValidMnemonic(mnemonic)) {
showError(
"add-wallet-error",
@@ -425,15 +426,33 @@ async function init() {
);
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");
+ const encrypted = await encryptWithPassword(mnemonic, pw);
const { xpub, firstAddress } = hdWalletFromMnemonic(mnemonic);
const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({
type: "hd",
name: "Wallet " + walletNum,
xpub: xpub,
- mnemonic: mnemonic,
+ encryptedSecret: encrypted,
nextIndex: 1,
addresses: [
{ address: firstAddress, balance: "0.0000", tokens: [] },
@@ -445,7 +464,7 @@ async function init() {
$("btn-add-wallet-import-key").addEventListener("click", showImportKeyView);
// -- Import private key --
- $("btn-import-key-confirm").addEventListener("click", () => {
+ $("btn-import-key-confirm").addEventListener("click", async () => {
const key = $("import-private-key").value.trim();
if (!key) {
showError("import-key-error", "Please enter your private key.");
@@ -458,12 +477,30 @@ async function init() {
showError("import-key-error", "Invalid private key.");
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");
+ const encrypted = await encryptWithPassword(key, pw);
const walletNum = state.wallets.length + 1;
addWalletAndGoToMain({
type: "key",
name: "Wallet " + walletNum,
- privateKey: key,
+ encryptedSecret: encrypted,
addresses: [{ address: addr, balance: "0.0000", tokens: [] }],
});
});
@@ -498,6 +535,7 @@ async function init() {
$("btn-send").addEventListener("click", () => {
$("send-to").value = "";
$("send-amount").value = "";
+ $("send-password").value = "";
$("send-fee-estimate").classList.add("hidden");
$("send-status").classList.add("hidden");
showView("send");
@@ -558,11 +596,29 @@ async function init() {
return;
}
}
- const statusEl = $("send-status") || $("send-status");
- statusEl.textContent = "Sending...";
+ const password = $("send-password").value;
+ 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");
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 connectedSigner = signer.connect(provider);
const tx = await connectedSigner.sendTransaction({
diff --git a/src/shared/vault.js b/src/shared/vault.js
new file mode 100644
index 0000000..5e4dd29
--- /dev/null
+++ b/src/shared/vault.js
@@ -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 };