Skip to content

Commit ab5c63f

Browse files
committed
feat(vault): encrypted-at-rest key persistence with passphrase unlock
PR #22's hardening stored the private key only in chrome.storage.session (in-memory), so it was wiped on every browser restart — getPublicKey() then threw the non-intuitive "No keypair found" and the user had to re-import their nsec each time. That is "no at-rest persistence", not "encryption at rest". Add a real encrypted-at-rest vault: - src/vault.js: AES-256-GCM ciphertext in chrome.storage.local, key wrapped by scrypt(passphrase) (N=2^16, r=8, p=1; params sealed in the blob for future upgrade). Pure encrypt/decrypt split from the storage I/O so the crypto is unit-tested off-platform. Wrong passphrase / tamper fail closed via the GCM tag. - Session stays the hot cache: on unlock the decrypted key is held in chrome.storage.session for fast signing; a browser restart clears it and the user re-unlocks. The raw key never touches disk. - background.js: generate/import now take a passphrase and seal the vault; new UNLOCK_VAULT / LOCK_VAULT; GET_KEYPAIR_STATUS reports none|locked|unlocked. A locked vault on getPublicKey/sign/nip44 opens the unlock popup and throws a clear "Podkey is locked — unlock and try again" instead of "No keypair found". - popup: set-passphrase step on generate, passphrase fields on import, an unlock screen (with forget-key/start-over), and a Lock action. Pill reads "Unlocked". Tests: 8 vault crypto tests (round-trip, wrong passphrase, tamper, validation, salt/iv freshness). Full suite 141 pass; eslint src/ clean. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent 40fb946 commit ab5c63f

7 files changed

Lines changed: 571 additions & 61 deletions

File tree

popup/popup.css

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,8 @@ label {
371371
margin-bottom: 7px;
372372
}
373373

374-
textarea {
374+
textarea,
375+
.input {
375376
width: 100%;
376377
padding: 11px;
377378
border: 1px solid var(--border-strong);
@@ -385,11 +386,18 @@ textarea {
385386
transition: border-color 0.15s ease, box-shadow 0.15s ease;
386387
}
387388

388-
textarea::placeholder {
389+
.input {
390+
resize: none;
391+
margin-bottom: 8px;
392+
}
393+
394+
textarea::placeholder,
395+
.input::placeholder {
389396
color: var(--text-faint);
390397
}
391398

392-
textarea:focus {
399+
textarea:focus,
400+
.input:focus {
393401
outline: none;
394402
border-color: var(--accent);
395403
box-shadow: 0 0 0 3px var(--accent-ring);

popup/popup.html

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,66 @@ <h1>Welcome to Podkey</h1>
5454
</div>
5555
</div>
5656

57+
<!-- Generate Screen (set an encryption passphrase for a new key) -->
58+
<div id="generateScreen" class="screen" style="display: none">
59+
<div class="header">
60+
<div class="brand-mark" aria-hidden="true">
61+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
62+
<path d="M5 3v4" /><path d="M3 5h4" />
63+
<path d="M19 17v4" /><path d="M17 19h4" />
64+
<path d="m12 3 1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3z" />
65+
</svg>
66+
</div>
67+
<div>
68+
<h2>Set an encryption passphrase</h2>
69+
<p class="subtitle">Encrypts your new key on this device</p>
70+
</div>
71+
</div>
72+
73+
<div class="section">
74+
<label for="generatePassphrase">Passphrase</label>
75+
<input id="generatePassphrase" type="password" class="input" placeholder="At least 8 characters" autocomplete="new-password" />
76+
<input id="generatePassphraseConfirm" type="password" class="input" placeholder="Confirm passphrase" autocomplete="new-password" />
77+
<div class="hint">
78+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
79+
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
80+
<line x1="12" y1="9" x2="12" y2="13" />
81+
<line x1="12" y1="17" x2="12.01" y2="17" />
82+
</svg>
83+
<span>You'll enter this once per browser session. It can't be recovered — store it safely.</span>
84+
</div>
85+
86+
<div class="actions">
87+
<button id="generateCancelBtn" class="btn btn-secondary" type="button">Cancel</button>
88+
<button id="generateConfirmBtn" class="btn btn-primary" type="button">Create key</button>
89+
</div>
90+
</div>
91+
</div>
92+
93+
<!-- Unlock Screen (shown when an encrypted vault exists but is locked) -->
94+
<div id="unlockScreen" class="screen" style="display: none">
95+
<div class="header center">
96+
<div class="brand-mark lg" aria-hidden="true">
97+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
98+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
99+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
100+
</svg>
101+
</div>
102+
<h1>Unlock Podkey</h1>
103+
<p class="subtitle" id="unlockIdentity">Enter your passphrase to unlock your key</p>
104+
</div>
105+
106+
<div class="section">
107+
<label for="unlockPassphrase">Passphrase</label>
108+
<input id="unlockPassphrase" type="password" class="input" placeholder="Your passphrase" autocomplete="current-password" />
109+
<button id="unlockBtn" class="btn btn-primary" type="button">Unlock</button>
110+
</div>
111+
112+
<div class="footer">
113+
<a href="#" id="forgetKeyBtn">Forget key &amp; start over</a>
114+
</div>
115+
</div>
116+
57117
<!-- Import Screen -->
58118
<div id="importScreen" class="screen" style="display: none">
59119
<div class="header">
@@ -88,6 +148,17 @@ <h2>Import private key</h2>
88148
<span>Never share your private key with anyone.</span>
89149
</div>
90150

151+
<label for="importPassphrase">Encryption passphrase</label>
152+
<input id="importPassphrase" type="password" class="input" placeholder="At least 8 characters" autocomplete="new-password" />
153+
<input id="importPassphraseConfirm" type="password" class="input" placeholder="Confirm passphrase" autocomplete="new-password" />
154+
<div class="hint">
155+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
156+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
157+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
158+
</svg>
159+
<span>Encrypts your key on this device. You'll enter it once per browser session.</span>
160+
</div>
161+
91162
<div class="actions">
92163
<button id="importCancelBtn" class="btn btn-secondary" type="button">Cancel</button>
93164
<button id="importConfirmBtn" class="btn btn-primary" type="button">Import</button>
@@ -112,12 +183,12 @@ <h1>Podkey</h1>
112183
<div class="section identity-section">
113184
<div class="section-title">
114185
<span>Your identity</span>
115-
<span class="lock-pill" title="Your key is held in memory for this browser session only.">
186+
<span class="lock-pill" title="Encrypted at rest; unlocked in memory for this browser session.">
116187
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
117188
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
118189
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
119190
</svg>
120-
Session
191+
Unlocked
121192
</span>
122193
</div>
123194

@@ -162,6 +233,8 @@ <h1>Podkey</h1>
162233
</div>
163234

164235
<div class="footer">
236+
<a href="#" id="lockBtn">Lock</a>
237+
<span class="sep"></span>
165238
<a href="#" id="exportBtn">Export key</a>
166239
<span class="sep"></span>
167240
<a href="https://github.com/JavaScriptSolidServer/podkey" target="_blank" rel="noopener">GitHub</a>

popup/popup.js

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ async function checkKeypairStatus() {
2626
const response = await chrome.runtime.sendMessage({ type: 'GET_KEYPAIR_STATUS' });
2727
if (DEBUG) console.log('[Podkey Popup] Keypair status response:', response);
2828

29-
if (response.exists) {
30-
console.log('[Podkey Popup] Keypair exists, showing main screen');
29+
// Three states: 'unlocked' -> main, 'locked' -> unlock, 'none' -> setup.
30+
// (`state` falls back to exists for forward/back compatibility.)
31+
const state = response.state || (response.exists ? 'unlocked' : 'none');
32+
if (state === 'unlocked') {
3133
showMainScreen(response);
34+
} else if (state === 'locked') {
35+
showUnlockScreen(response);
3236
} else {
33-
console.log('[Podkey Popup] No keypair, showing setup screen');
3437
showSetupScreen();
3538
}
3639
} catch (error) {
@@ -52,6 +55,33 @@ function showSetupScreen() {
5255
console.log('[Podkey Popup] Setup screen display set to block');
5356
}
5457

58+
/**
59+
* Show generate screen (set an encryption passphrase for a new key)
60+
*/
61+
function showGenerateScreen() {
62+
hideAllScreens();
63+
document.getElementById('generatePassphrase').value = '';
64+
document.getElementById('generatePassphraseConfirm').value = '';
65+
document.getElementById('generateScreen').style.display = 'block';
66+
currentScreen = 'generate';
67+
document.getElementById('generatePassphrase').focus();
68+
}
69+
70+
/**
71+
* Show unlock screen (encrypted vault present but locked)
72+
*/
73+
function showUnlockScreen(status) {
74+
hideAllScreens();
75+
const pk = status && status.publicKey;
76+
document.getElementById('unlockIdentity').textContent = pk
77+
? `Unlock ${pk.slice(0, 8)}${pk.slice(-4)}`
78+
: 'Enter your passphrase to unlock your key';
79+
document.getElementById('unlockPassphrase').value = '';
80+
document.getElementById('unlockScreen').style.display = 'block';
81+
currentScreen = 'unlock';
82+
document.getElementById('unlockPassphrase').focus();
83+
}
84+
5585
/**
5686
* Show import screen
5787
*/
@@ -97,9 +127,20 @@ function hideAllScreens() {
97127
function setupEventListeners() {
98128
console.log('[Podkey Popup] Setting up event listeners');
99129
// Setup screen
100-
document.getElementById('generateBtn').addEventListener('click', handleGenerate);
130+
document.getElementById('generateBtn').addEventListener('click', () => showGenerateScreen());
101131
document.getElementById('importBtn').addEventListener('click', () => showImportScreen());
102132

133+
// Generate screen
134+
document.getElementById('generateConfirmBtn').addEventListener('click', handleGenerate);
135+
document.getElementById('generateCancelBtn').addEventListener('click', () => showSetupScreen());
136+
137+
// Unlock screen
138+
document.getElementById('unlockBtn').addEventListener('click', handleUnlock);
139+
document.getElementById('unlockPassphrase').addEventListener('keydown', (e) => {
140+
if (e.key === 'Enter') handleUnlock();
141+
});
142+
document.getElementById('forgetKeyBtn').addEventListener('click', handleForgetKey);
143+
103144
// Import screen
104145
document.getElementById('importConfirmBtn').addEventListener('click', handleImport);
105146
document.getElementById('importCancelBtn').addEventListener('click', () => showSetupScreen());
@@ -108,36 +149,117 @@ function setupEventListeners() {
108149
document.getElementById('copyBtn').addEventListener('click', handleCopy);
109150
document.getElementById('autoSignToggle').addEventListener('change', handleAutoSignToggle);
110151
document.getElementById('exportBtn').addEventListener('click', handleExport);
152+
document.getElementById('lockBtn').addEventListener('click', handleLock);
111153
}
112154

113155
/**
114-
* Handle generate new keypair
156+
* Validate a passphrase + confirmation pair. Returns the passphrase or null
157+
* (after alerting) when invalid.
158+
*/
159+
function readPassphrase(passId, confirmId) {
160+
const pass = document.getElementById(passId).value;
161+
const confirm = document.getElementById(confirmId).value;
162+
if (pass.length < 8) {
163+
alert('Passphrase must be at least 8 characters.');
164+
return null;
165+
}
166+
if (pass !== confirm) {
167+
alert('Passphrases do not match.');
168+
return null;
169+
}
170+
return pass;
171+
}
172+
173+
/**
174+
* Handle generate new keypair (encrypts it under the chosen passphrase)
115175
*/
116176
async function handleGenerate() {
117-
const btn = document.getElementById('generateBtn');
118-
const labelEl = btn.querySelector('.btn-label');
119-
const original = labelEl ? labelEl.textContent : btn.textContent;
177+
const passphrase = readPassphrase('generatePassphrase', 'generatePassphraseConfirm');
178+
if (!passphrase) return;
179+
180+
const btn = document.getElementById('generateConfirmBtn');
181+
const original = btn.textContent;
120182
try {
121-
if (labelEl) labelEl.textContent = 'Generating…';
183+
btn.textContent = 'Generating…';
122184
btn.disabled = true;
123185

124-
const response = await chrome.runtime.sendMessage({ type: 'GENERATE_KEYPAIR' });
186+
const response = await chrome.runtime.sendMessage({ type: 'GENERATE_KEYPAIR', passphrase });
125187

126188
if (DEBUG) console.log('[Podkey] Keypair generated:', response.publicKey);
127189

128-
// Show main screen
129190
await showMainScreen({
130191
exists: true,
131192
publicKey: response.publicKey,
132193
did: response.did
133194
});
134195
} catch (error) {
135196
alert('Error generating keypair: ' + error.message);
136-
if (labelEl) labelEl.textContent = original;
197+
} finally {
198+
btn.textContent = original;
137199
btn.disabled = false;
138200
}
139201
}
140202

203+
/**
204+
* Handle unlock: decrypt the vault into the session with the passphrase
205+
*/
206+
async function handleUnlock() {
207+
const passphrase = document.getElementById('unlockPassphrase').value;
208+
if (!passphrase) {
209+
alert('Please enter your passphrase.');
210+
return;
211+
}
212+
213+
const btn = document.getElementById('unlockBtn');
214+
const original = btn.textContent;
215+
try {
216+
btn.textContent = 'Unlocking…';
217+
btn.disabled = true;
218+
219+
const response = await chrome.runtime.sendMessage({ type: 'UNLOCK_VAULT', passphrase });
220+
221+
document.getElementById('unlockPassphrase').value = '';
222+
await showMainScreen({
223+
exists: true,
224+
publicKey: response.publicKey,
225+
did: response.did
226+
});
227+
} catch (error) {
228+
// 'Incorrect passphrase' from the background — keep it on-screen to retry.
229+
alert(error.message || 'Unlock failed.');
230+
} finally {
231+
btn.textContent = original;
232+
btn.disabled = false;
233+
}
234+
}
235+
236+
/**
237+
* Handle lock: drop the in-memory key (vault stays encrypted on disk)
238+
*/
239+
async function handleLock() {
240+
await chrome.runtime.sendMessage({ type: 'LOCK_VAULT' });
241+
const status = await chrome.runtime.sendMessage({ type: 'GET_KEYPAIR_STATUS' });
242+
showUnlockScreen(status);
243+
}
244+
245+
/**
246+
* Handle "forget key & start over": wipe the encrypted vault and identity so
247+
* the user can generate or import a different key. Irreversible.
248+
*/
249+
async function handleForgetKey(event) {
250+
if (event) event.preventDefault();
251+
const confirmed = confirm(
252+
'⚠️ This deletes the encrypted key stored on this device.\n\n' +
253+
'If you have not backed up your private key, you will lose access to this ' +
254+
'identity permanently.\n\nContinue?'
255+
);
256+
if (!confirmed) return;
257+
258+
await chrome.storage.session.remove(['podkey_private_key']);
259+
await chrome.storage.local.remove(['podkey_vault', 'podkey_public_key']);
260+
showSetupScreen();
261+
}
262+
141263
/**
142264
* Handle import keypair
143265
*/
@@ -150,19 +272,25 @@ async function handleImport() {
150272
return;
151273
}
152274

275+
const passphrase = readPassphrase('importPassphrase', 'importPassphraseConfirm');
276+
if (!passphrase) return;
277+
153278
const btn = document.getElementById('importConfirmBtn');
154279
btn.textContent = 'Importing...';
155280
btn.disabled = true;
156281

157282
const response = await chrome.runtime.sendMessage({
158283
type: 'IMPORT_KEYPAIR',
159-
privateKey
284+
privateKey,
285+
passphrase
160286
});
161287

162288
if (DEBUG) console.log('[Podkey] Keypair imported:', response.publicKey);
163289

164-
// Clear input
290+
// Clear inputs (private key + passphrases)
165291
document.getElementById('privateKeyInput').value = '';
292+
document.getElementById('importPassphrase').value = '';
293+
document.getElementById('importPassphraseConfirm').value = '';
166294

167295
// Show main screen
168296
await showMainScreen({
@@ -229,15 +357,15 @@ async function handleExport() {
229357
if (!confirmed) return;
230358

231359
try {
232-
// Private key is now stored in session storage (in-memory only).
233-
// Try session storage first, fall back to local for legacy installs.
360+
// The unlocked private key lives in session storage. If the vault is locked
361+
// (e.g. after a browser restart) there is nothing to export until unlocked.
234362
let { podkey_private_key: privateKey } = await chrome.storage.session.get(['podkey_private_key']);
235363
if (!privateKey) {
236364
({ podkey_private_key: privateKey } = await chrome.storage.local.get(['podkey_private_key']));
237365
}
238366

239367
if (!privateKey) {
240-
alert('No private key found. The key may have been cleared when the browser restarted. Please re-import your key.');
368+
alert('Podkey is locked. Unlock with your passphrase first, then export.');
241369
return;
242370
}
243371

0 commit comments

Comments
 (0)