Encrypt secrets with libsodium, password required to send
All checks were successful
check / check (push) Successful in 14s
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:
parent
bfecddf2f7
commit
f2e22cadf2
@ -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`
|
||||
|
||||
@ -57,6 +57,26 @@
|
||||
can access your funds. If you lose them, your wallet cannot
|
||||
be recovered.
|
||||
</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">
|
||||
<button
|
||||
id="btn-add-wallet-confirm"
|
||||
@ -103,6 +123,26 @@
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
id="btn-import-key-confirm"
|
||||
@ -259,6 +299,17 @@
|
||||
id="send-fee-estimate"
|
||||
class="text-xs text-muted mb-2 hidden"
|
||||
></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">
|
||||
<button
|
||||
id="btn-send-confirm"
|
||||
|
||||
@ -8,6 +8,7 @@ const {
|
||||
parseEther,
|
||||
} = require("ethers");
|
||||
const { getTopTokenPrices } = require("../shared/tokens");
|
||||
const { encryptWithPassword, decryptWithPassword } = require("../shared/vault");
|
||||
const QRCode = require("qrcode");
|
||||
|
||||
const DEBUG = true;
|
||||
@ -147,19 +148,20 @@ function formatUsd(amount) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get an ethers Wallet (signer) for the currently selected address
|
||||
function getSignerForCurrentAddress() {
|
||||
// Get an ethers Wallet (signer) for the currently selected address.
|
||||
// Requires the decrypted secret (mnemonic or private key).
|
||||
function getSignerForCurrentAddress(decryptedSecret) {
|
||||
const wallet = state.wallets[state.selectedWallet];
|
||||
const addrIndex = state.selectedAddress;
|
||||
if (wallet.type === "hd") {
|
||||
const node = HDNodeWallet.fromPhrase(
|
||||
wallet.mnemonic,
|
||||
decryptedSecret,
|
||||
"",
|
||||
BIP44_ETH_BASE,
|
||||
);
|
||||
return node.deriveChild(addrIndex);
|
||||
} else {
|
||||
return new Wallet(wallet.privateKey);
|
||||
return new Wallet(decryptedSecret);
|
||||
}
|
||||
}
|
||||
|
||||
@ -398,7 +400,7 @@ async function init() {
|
||||
$("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();
|
||||
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({
|
||||
|
||||
62
src/shared/vault.js
Normal file
62
src/shared/vault.js
Normal 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 };
|
||||
Loading…
Reference in New Issue
Block a user