feat: view private key for addresses (closes #19) #32

Closed
clawbot wants to merge 1 commits from feat/view-private-key into main
6 changed files with 170 additions and 0 deletions

View File

@@ -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

View File

@@ -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"
>
&#128273; 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"
>
&lt; 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"
>
&#128274;&#128176; 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

View File

@@ -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");

View File

@@ -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 };

View 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 };

View File

@@ -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,
};