feat: view private key for addresses (closes #19) #32
19
README.md
19
README.md
@@ -835,6 +835,25 @@ Currently supported:
|
||||
- [ ] Multi-currency fiat display (EUR, GBP, etc.)
|
||||
- [ ] 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
|
||||
|
||||
- We don't mention "the other wallet" by name in code or documentation. We're
|
||||
|
||||
@@ -306,6 +306,14 @@
|
||||
+ Token
|
||||
</button>
|
||||
</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 -->
|
||||
<div class="mt-3">
|
||||
@@ -878,6 +886,57 @@
|
||||
</button>
|
||||
</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 ============ -->
|
||||
<div id="view-settings-addtoken" class="view hidden">
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,7 @@ const receive = require("./views/receive");
|
||||
const addToken = require("./views/addToken");
|
||||
const settings = require("./views/settings");
|
||||
const settingsAddToken = require("./views/settingsAddToken");
|
||||
const showPrivateKey = require("./views/showPrivateKey");
|
||||
const approval = require("./views/approval");
|
||||
|
||||
function renderWalletList() {
|
||||
@@ -63,6 +64,7 @@ const ctx = {
|
||||
showTransactionDetail: (tx) => transactionDetail.show(tx),
|
||||
showSettingsView: () => settings.show(),
|
||||
showSettingsAddTokenView: () => settingsAddToken.show(),
|
||||
showPrivateKey: () => showPrivateKey.show(),
|
||||
};
|
||||
|
||||
// Views that can be fully re-rendered from persisted state.
|
||||
@@ -220,6 +222,7 @@ async function init() {
|
||||
addToken.init(ctx);
|
||||
settings.init(ctx);
|
||||
settingsAddToken.init(ctx);
|
||||
showPrivateKey.init(ctx);
|
||||
|
||||
if (!state.hasWallet) {
|
||||
showView("welcome");
|
||||
|
||||
@@ -261,6 +261,10 @@ function init(_ctx) {
|
||||
});
|
||||
|
||||
$("btn-add-token").addEventListener("click", ctx.showAddTokenView);
|
||||
|
||||
$("btn-show-privkey").addEventListener("click", () => {
|
||||
ctx.showPrivateKey();
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 = {
|
||||
generateMnemonic,
|
||||
deriveAddressFromXpub,
|
||||
hdWalletFromMnemonic,
|
||||
addressFromPrivateKey,
|
||||
getSignerForAddress,
|
||||
getPrivateKeyForAddress,
|
||||
isValidMnemonic,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user