Skip to content

Commit 7762267

Browse files
vveerrggclaude
andcommitted
feat: seed phrase (BIP39) import and export in profile settings
Add UI and handlers for importing a private key from a 24-word seed phrase and exporting an existing key as a mnemonic for paper backup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4b79d87 commit 7762267

2 files changed

Lines changed: 191 additions & 4 deletions

File tree

src/full_settings.html

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,24 @@ <h2 class="section-header">Keys</h2>
131131
</div>
132132
</div>
133133

134-
<!-- Normal save button (hidden when ncryptsec is detected) -->
134+
<!-- Seed phrase import: shows when space-separated words detected -->
135+
<div data-section="seedphrase-import" class="mt-3 p-3 border-2 border-monokai-brown rounded" style="display:none;">
136+
<p class="text-sm font-bold">Seed phrase detected (BIP39 mnemonic).</p>
137+
<p class="text-sm italic mt-1">Import your private key from this 24-word seed phrase.</p>
138+
<div
139+
id="seedphrase-import-error"
140+
class="text-red-500 text-sm font-bold mt-1"
141+
style="display:none;"
142+
></div>
143+
<div class="mt-2">
144+
<button
145+
class="button"
146+
data-action="importSeedPhrase"
147+
>Import from Seed Phrase</button>
148+
</div>
149+
</div>
150+
151+
<!-- Normal save button (hidden when ncryptsec or seed phrase is detected) -->
135152
<div class="mt-3">
136153
<button
137154
class="button"
@@ -225,6 +242,38 @@ <h3 class="subsection-header">Export Encrypted Key (NIP-49)</h3>
225242
</div>
226243
</div>
227244

245+
<!-- Seed phrase export -->
246+
<div class="mt-4" id="seedphrase-export-section" style="display:none;">
247+
<h3 class="subsection-header">Show Seed Phrase (BIP39)</h3>
248+
<p class="text-sm italic mt-1">
249+
Export your private key as a 24-word seed phrase you can write
250+
on paper. Keep it secret — anyone with these words controls your key.
251+
</p>
252+
<div class="mt-2">
253+
<button
254+
class="button"
255+
data-action="revealSeedPhrase"
256+
>Reveal Seed Phrase</button>
257+
</div>
258+
<div
259+
id="seedphrase-export-reveal"
260+
class="mt-2 p-3 border-2 border-red-700 rounded bg-red-50"
261+
style="display:none;"
262+
>
263+
<p class="text-red-800 text-sm font-bold">Secret seed phrase — keep this safe!</p>
264+
<textarea
265+
id="seedphrase-export-text"
266+
class="input w-full font-mono text-xs mt-2"
267+
rows="3"
268+
readonly
269+
></textarea>
270+
<div class="mt-2 flex gap-2">
271+
<button class="button" data-action="copySeedPhrase">Copy</button>
272+
<button class="button" data-action="hideSeedPhrase">Hide</button>
273+
</div>
274+
</div>
275+
</div>
276+
228277
<!-- BUNKER CONNECTION (bunker profiles only) -->
229278
<div class="section" data-section="bunker" style="display:none;">
230279
<h2 class="section-header">Bunker Connection</h2>

src/options.js

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
humanPermission,
2020
validateKey,
2121
isNcryptsec,
22+
looksLikeSeedPhrase,
2223
} from './utilities/utils';
2324
import { api } from './utilities/browser-polyfill';
2425
import QRCode from 'qrcode';
@@ -58,7 +59,15 @@ const state = {
5859
ncryptsecExportError: '',
5960
ncryptsecExportLoading: false,
6061
ncryptsecExportCopied: false,
61-
62+
63+
// Seed phrase state
64+
seedPhraseDetected: false,
65+
seedPhraseError: '',
66+
seedPhraseLoading: false,
67+
seedPhraseRevealed: false,
68+
seedPhraseText: '',
69+
seedPhraseCopied: false,
70+
6271
// Bunker state
6372
profileType: 'local',
6473
bunkerUrl: '',
@@ -125,6 +134,19 @@ function initElements() {
125134
elements.ncryptsecExportResult = $('ncryptsec-export-result');
126135
elements.copyNcryptsecBtn = document.querySelector('[data-action="copyNcryptsecExport"]');
127136

137+
// Seed phrase import
138+
elements.seedPhraseImportSection = document.querySelector('[data-section="seedphrase-import"]');
139+
elements.seedPhraseImportError = $('seedphrase-import-error');
140+
elements.importSeedPhraseBtn = document.querySelector('[data-action="importSeedPhrase"]');
141+
142+
// Seed phrase export
143+
elements.seedPhraseExportSection = $('seedphrase-export-section');
144+
elements.revealSeedPhraseBtn = document.querySelector('[data-action="revealSeedPhrase"]');
145+
elements.seedPhraseRevealBox = $('seedphrase-export-reveal');
146+
elements.seedPhraseText = $('seedphrase-export-text');
147+
elements.copySeedPhraseBtn = document.querySelector('[data-action="copySeedPhrase"]');
148+
elements.hideSeedPhraseBtn = document.querySelector('[data-action="hideSeedPhrase"]');
149+
128150
// Bunker section
129151
elements.bunkerSection = document.querySelector('[data-section="bunker"]');
130152
elements.bunkerProfileNameInput = $('bunker-profile-name');
@@ -254,11 +276,42 @@ function renderLocalProfile() {
254276
if (elements.ncryptsecSection) {
255277
elements.ncryptsecSection.style.display = hasNcryptsec ? 'block' : 'none';
256278
}
279+
280+
// Seed phrase import section
281+
if (elements.seedPhraseImportSection) {
282+
elements.seedPhraseImportSection.style.display = state.seedPhraseDetected && !hasNcryptsec ? 'block' : 'none';
283+
}
284+
if (elements.seedPhraseImportError) {
285+
elements.seedPhraseImportError.textContent = state.seedPhraseError;
286+
elements.seedPhraseImportError.style.display = state.seedPhraseError ? 'block' : 'none';
287+
}
288+
if (elements.importSeedPhraseBtn) {
289+
elements.importSeedPhraseBtn.disabled = state.seedPhraseLoading;
290+
elements.importSeedPhraseBtn.textContent = state.seedPhraseLoading ? 'Importing...' : 'Import from Seed Phrase';
291+
}
292+
257293
if (elements.saveProfileBtn) {
258-
elements.saveProfileBtn.style.display = hasNcryptsec ? 'none' : 'block';
294+
elements.saveProfileBtn.style.display = hasNcryptsec || state.seedPhraseDetected ? 'none' : 'block';
259295
const needsSave = state.privKey !== state.pristinePrivKey || state.profileName !== state.pristineProfileName;
260296
elements.saveProfileBtn.disabled = !needsSave || !validateKey(state.privKey);
261297
}
298+
299+
// Seed phrase export section (only for local profiles with an existing key)
300+
if (elements.seedPhraseExportSection) {
301+
elements.seedPhraseExportSection.style.display = state.pristinePrivKey && !hasNcryptsec ? 'block' : 'none';
302+
}
303+
if (elements.seedPhraseRevealBox) {
304+
elements.seedPhraseRevealBox.style.display = state.seedPhraseRevealed ? 'block' : 'none';
305+
}
306+
if (elements.seedPhraseText) {
307+
elements.seedPhraseText.value = state.seedPhraseText;
308+
}
309+
if (elements.revealSeedPhraseBtn) {
310+
elements.revealSeedPhraseBtn.style.display = state.seedPhraseRevealed ? 'none' : 'inline-block';
311+
}
312+
if (elements.copySeedPhraseBtn) {
313+
elements.copySeedPhraseBtn.textContent = state.seedPhraseCopied ? 'Copied!' : 'Copy';
314+
}
262315

263316
// QR codes
264317
if (elements.npubQrContainer && state.npubQrDataUrl) {
@@ -552,14 +605,20 @@ async function refreshProfile() {
552605
payload: state.profileIndex,
553606
});
554607

555-
// Reset QR and ncryptsec state
608+
// Reset QR, ncryptsec, and seed phrase state
556609
state.npubQrDataUrl = '';
557610
state.nsecQrDataUrl = '';
558611
state.showNsecQr = false;
559612
state.ncryptsecExportResult = '';
560613
state.ncryptsecExportError = '';
561614
state.ncryptsecExportPassword = '';
562615
state.ncryptsecExportConfirm = '';
616+
state.seedPhraseDetected = false;
617+
state.seedPhraseError = '';
618+
state.seedPhraseLoading = false;
619+
state.seedPhraseRevealed = false;
620+
state.seedPhraseText = '';
621+
state.seedPhraseCopied = false;
563622

564623
if (state.profileType === 'local') {
565624
await loadLocalProfile();
@@ -696,6 +755,8 @@ function handleProfileNameInput(e) {
696755

697756
function handlePrivKeyInput(e) {
698757
state.privKey = e.target.value;
758+
state.seedPhraseDetected = looksLikeSeedPhrase(state.privKey);
759+
state.seedPhraseError = '';
699760
render();
700761
}
701762

@@ -803,6 +864,69 @@ async function handleCopyNcryptsecExport() {
803864
}, 1500);
804865
}
805866

867+
async function handleImportSeedPhrase() {
868+
state.seedPhraseError = '';
869+
state.seedPhraseLoading = true;
870+
render();
871+
872+
try {
873+
const result = await api.runtime.sendMessage({
874+
kind: 'seedPhrase.toKey',
875+
payload: state.privKey,
876+
});
877+
878+
if (result.success) {
879+
await savePrivateKey(state.profileIndex, result.hexKey);
880+
await refreshProfile();
881+
} else {
882+
state.seedPhraseError = result.error || 'Invalid seed phrase';
883+
}
884+
} catch (e) {
885+
state.seedPhraseError = e.message || 'Import failed';
886+
}
887+
888+
state.seedPhraseLoading = false;
889+
render();
890+
}
891+
892+
async function handleRevealSeedPhrase() {
893+
try {
894+
const result = await api.runtime.sendMessage({
895+
kind: 'seedPhrase.fromKey',
896+
payload: state.profileIndex,
897+
});
898+
899+
if (result.success) {
900+
state.seedPhraseText = result.seedPhrase;
901+
state.seedPhraseRevealed = true;
902+
} else {
903+
state.seedPhraseText = '';
904+
state.seedPhraseRevealed = false;
905+
alert(result.error || 'Failed to reveal seed phrase');
906+
}
907+
} catch (e) {
908+
alert(e.message || 'Failed to reveal seed phrase');
909+
}
910+
render();
911+
}
912+
913+
function handleHideSeedPhrase() {
914+
state.seedPhraseText = '';
915+
state.seedPhraseRevealed = false;
916+
state.seedPhraseCopied = false;
917+
render();
918+
}
919+
920+
async function handleCopySeedPhrase() {
921+
await navigator.clipboard.writeText(state.seedPhraseText);
922+
state.seedPhraseCopied = true;
923+
render();
924+
setTimeout(() => {
925+
state.seedPhraseCopied = false;
926+
render();
927+
}, 1500);
928+
}
929+
806930
async function handleConnectBunker() {
807931
state.bunkerError = '';
808932
state.bunkerConnecting = true;
@@ -1104,6 +1228,20 @@ function bindEvents() {
11041228
elements.copyNcryptsecBtn.addEventListener('click', handleCopyNcryptsecExport);
11051229
}
11061230

1231+
// Seed phrase
1232+
if (elements.importSeedPhraseBtn) {
1233+
elements.importSeedPhraseBtn.addEventListener('click', handleImportSeedPhrase);
1234+
}
1235+
if (elements.revealSeedPhraseBtn) {
1236+
elements.revealSeedPhraseBtn.addEventListener('click', handleRevealSeedPhrase);
1237+
}
1238+
if (elements.hideSeedPhraseBtn) {
1239+
elements.hideSeedPhraseBtn.addEventListener('click', handleHideSeedPhrase);
1240+
}
1241+
if (elements.copySeedPhraseBtn) {
1242+
elements.copySeedPhraseBtn.addEventListener('click', handleCopySeedPhrase);
1243+
}
1244+
11071245
// Input changes for ncryptsec export
11081246
if (elements.ncryptsecExportPassword) {
11091247
elements.ncryptsecExportPassword.addEventListener('input', (e) => {

0 commit comments

Comments
 (0)