feat: view private key for addresses (closes #19)
All checks were successful
check / check (push) Successful in 22s
All checks were successful
check / check (push) Successful in 22s
- Add 'Show Private Key' button to address detail view - Create dedicated password verification modal with warning text - Derive private key from HD wallet mnemonic or use directly for imported keys - Display key in read-only well with copy button - Add getPrivateKeyForAddress() to shared/wallet.js - Never log, cache, or auto-clear the private key - Document clipboard non-clearing policy in README
This commit is contained in:
19
README.md
19
README.md
@@ -835,6 +835,25 @@ Currently supported:
|
|||||||
- [ ] Multi-currency fiat display (EUR, GBP, etc.)
|
- [ ] Multi-currency fiat display (EUR, GBP, etc.)
|
||||||
- [ ] Security audit of key management
|
- [ ] Security audit of key management
|
||||||
|
|
||||||
|
## Private Key Export
|
||||||
|
|
||||||
|
The address detail view includes a "Show Private Key" button. After entering the
|
||||||
|
wallet password, the raw hex private key is displayed and can be copied to the
|
||||||
|
clipboard.
|
||||||
|
|
||||||
|
We intentionally **do not clear the clipboard** after copying a private key:
|
||||||
|
|
||||||
|
1. **User expectations**: Clipboard clearing violates the principle of least
|
||||||
|
surprise. Users expect their clipboard to contain what they last copied until
|
||||||
|
they copy something else.
|
||||||
|
2. **Data safety**: The user may copy something else important moments later. An
|
||||||
|
auto-clear timer could destroy that unrelated clipboard content, causing data
|
||||||
|
loss far worse than the theoretical risk it was meant to mitigate.
|
||||||
|
|
||||||
|
If a user chooses to display their private key, they have already been warned
|
||||||
|
that it controls all funds at the address. Managing sensitive data on their own
|
||||||
|
clipboard is their responsibility.
|
||||||
|
|
||||||
## Policies
|
## Policies
|
||||||
|
|
||||||
- We don't mention "the other wallet" by name in code or documentation. We're
|
- We don't mention "the other wallet" by name in code or documentation. We're
|
||||||
|
|||||||
@@ -306,6 +306,14 @@
|
|||||||
+ Token
|
+ Token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
id="btn-show-privkey"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer flex-1 text-xs"
|
||||||
|
>
|
||||||
|
🔑 Show Private Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- transactions -->
|
<!-- transactions -->
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -878,6 +886,57 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ SHOW PRIVATE KEY ============ -->
|
||||||
|
<div id="view-show-private-key" class="view hidden">
|
||||||
|
<button
|
||||||
|
id="btn-privkey-back"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
|
||||||
|
>
|
||||||
|
< Back
|
||||||
|
</button>
|
||||||
|
<h2 class="font-bold mb-3">Show Private Key</h2>
|
||||||
|
|
||||||
|
<div id="privkey-form">
|
||||||
|
<div
|
||||||
|
class="text-xs mb-3 border border-border border-dashed p-2"
|
||||||
|
>
|
||||||
|
The private key controls all funds at this address.
|
||||||
|
Anyone who has it can spend your tokens. Do not share it
|
||||||
|
with anyone.
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="block mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="privkey-password"
|
||||||
|
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="privkey-error" class="text-xs mb-2 hidden"></div>
|
||||||
|
<button
|
||||||
|
id="btn-privkey-reveal"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
|
>
|
||||||
|
🔒💰 Display Private Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="privkey-result" class="hidden">
|
||||||
|
<div class="text-xs text-muted mb-1">Private Key</div>
|
||||||
|
<div
|
||||||
|
id="privkey-result-key"
|
||||||
|
class="bg-hover rounded-md p-3 text-xs break-all font-mono select-all mb-2"
|
||||||
|
></div>
|
||||||
|
<button
|
||||||
|
id="btn-privkey-copy"
|
||||||
|
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============ SETTINGS: ADD TOKEN ============ -->
|
<!-- ============ SETTINGS: ADD TOKEN ============ -->
|
||||||
<div id="view-settings-addtoken" class="view hidden">
|
<div id="view-settings-addtoken" class="view hidden">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const receive = require("./views/receive");
|
|||||||
const addToken = require("./views/addToken");
|
const addToken = require("./views/addToken");
|
||||||
const settings = require("./views/settings");
|
const settings = require("./views/settings");
|
||||||
const settingsAddToken = require("./views/settingsAddToken");
|
const settingsAddToken = require("./views/settingsAddToken");
|
||||||
|
const showPrivateKey = require("./views/showPrivateKey");
|
||||||
const approval = require("./views/approval");
|
const approval = require("./views/approval");
|
||||||
|
|
||||||
function renderWalletList() {
|
function renderWalletList() {
|
||||||
@@ -63,6 +64,7 @@ const ctx = {
|
|||||||
showTransactionDetail: (tx) => transactionDetail.show(tx),
|
showTransactionDetail: (tx) => transactionDetail.show(tx),
|
||||||
showSettingsView: () => settings.show(),
|
showSettingsView: () => settings.show(),
|
||||||
showSettingsAddTokenView: () => settingsAddToken.show(),
|
showSettingsAddTokenView: () => settingsAddToken.show(),
|
||||||
|
showPrivateKey: () => showPrivateKey.show(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Views that can be fully re-rendered from persisted state.
|
// Views that can be fully re-rendered from persisted state.
|
||||||
@@ -220,6 +222,7 @@ async function init() {
|
|||||||
addToken.init(ctx);
|
addToken.init(ctx);
|
||||||
settings.init(ctx);
|
settings.init(ctx);
|
||||||
settingsAddToken.init(ctx);
|
settingsAddToken.init(ctx);
|
||||||
|
showPrivateKey.init(ctx);
|
||||||
|
|
||||||
if (!state.hasWallet) {
|
if (!state.hasWallet) {
|
||||||
showView("welcome");
|
showView("welcome");
|
||||||
|
|||||||
@@ -261,6 +261,10 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
||||||
|
|
||||||
|
$("btn-show-privkey").addEventListener("click", () => {
|
||||||
|
ctx.showPrivateKey();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show };
|
module.exports = { init, show };
|
||||||
|
|||||||
71
src/popup/views/showPrivateKey.js
Normal file
71
src/popup/views/showPrivateKey.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { $, showView, showFlash } = require("./helpers");
|
||||||
|
const { state } = require("../../shared/state");
|
||||||
|
const { decryptWithPassword } = require("../../shared/vault");
|
||||||
|
const { getPrivateKeyForAddress } = require("../../shared/wallet");
|
||||||
|
|
||||||
|
let ctx = null;
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
$("privkey-password").value = "";
|
||||||
|
$("privkey-error").textContent = "";
|
||||||
|
$("privkey-error").classList.add("hidden");
|
||||||
|
$("privkey-result").classList.add("hidden");
|
||||||
|
$("privkey-result-key").textContent = "";
|
||||||
|
$("privkey-form").classList.remove("hidden");
|
||||||
|
showView("show-private-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(_ctx) {
|
||||||
|
ctx = _ctx;
|
||||||
|
|
||||||
|
$("btn-privkey-back").addEventListener("click", () => {
|
||||||
|
// Clear displayed key on navigation away
|
||||||
|
$("privkey-result-key").textContent = "";
|
||||||
|
ctx.showAddressDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-privkey-reveal").addEventListener("click", async () => {
|
||||||
|
const pw = $("privkey-password").value;
|
||||||
|
if (!pw) {
|
||||||
|
$("privkey-error").textContent = "Please enter your password.";
|
||||||
|
$("privkey-error").classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = state.wallets[state.selectedWallet];
|
||||||
|
const addrIndex = state.selectedAddress;
|
||||||
|
|
||||||
|
let decryptedSecret;
|
||||||
|
try {
|
||||||
|
decryptedSecret = await decryptWithPassword(
|
||||||
|
wallet.encryptedSecret,
|
||||||
|
pw,
|
||||||
|
);
|
||||||
|
} catch (_e) {
|
||||||
|
$("privkey-error").textContent = "Wrong password.";
|
||||||
|
$("privkey-error").classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKey = getPrivateKeyForAddress(
|
||||||
|
wallet,
|
||||||
|
addrIndex,
|
||||||
|
decryptedSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do NOT log the private key
|
||||||
|
$("privkey-form").classList.add("hidden");
|
||||||
|
$("privkey-result-key").textContent = privateKey;
|
||||||
|
$("privkey-result").classList.remove("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-privkey-copy").addEventListener("click", () => {
|
||||||
|
const key = $("privkey-result-key").textContent;
|
||||||
|
if (key) {
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
showFlash("Copied!");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, show };
|
||||||
@@ -45,11 +45,25 @@ function isValidMnemonic(mnemonic) {
|
|||||||
return Mnemonic.isValidMnemonic(mnemonic);
|
return Mnemonic.isValidMnemonic(mnemonic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPrivateKeyForAddress(walletData, addrIndex, decryptedSecret) {
|
||||||
|
if (walletData.type === "hd") {
|
||||||
|
const node = HDNodeWallet.fromPhrase(
|
||||||
|
decryptedSecret,
|
||||||
|
"",
|
||||||
|
BIP44_ETH_PATH,
|
||||||
|
);
|
||||||
|
return node.deriveChild(addrIndex).privateKey;
|
||||||
|
}
|
||||||
|
// For imported wallets, the decrypted secret IS the private key
|
||||||
|
return decryptedSecret;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateMnemonic,
|
generateMnemonic,
|
||||||
deriveAddressFromXpub,
|
deriveAddressFromXpub,
|
||||||
hdWalletFromMnemonic,
|
hdWalletFromMnemonic,
|
||||||
addressFromPrivateKey,
|
addressFromPrivateKey,
|
||||||
getSignerForAddress,
|
getSignerForAddress,
|
||||||
|
getPrivateKeyForAddress,
|
||||||
isValidMnemonic,
|
isValidMnemonic,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user