···11/**
22- * Key passphrase UI — prompts the user to set or enter their key-wrapping passphrase.
22+ * Key passphrase UI — opt-in passphrase protection for the key bundle.
33 *
44- * The passphrase is used to derive a wrapping key (PBKDF2) that encrypts the
55- * document key bundle before it is stored in localStorage or sent to the server.
44+ * By default, keys are stored as plaintext in localStorage (the Tailscale
55+ * identity layer provides the trust boundary). Users can opt-in to passphrase
66+ * wrapping via a future settings UI.
67 *
77- * The derived key lives only in memory for the session — it is never persisted.
88+ * The ensureWrappingKey / ensureWrappingKeyForStore functions are no-ops
99+ * unless the user has explicitly enabled passphrase protection. All callers
1010+ * can continue to call them unconditionally.
811 */
9121013import {
···1316 isLegacyFormat,
1417 migrateLegacyKeys,
1518 getLocalKeys,
1919+ isWrappedFormat,
2020+ unwrapToPlaintext,
1621} from './key-sync.js';
17221823const PASSPHRASE_SET_KEY = 'tools-keys-passphrase-set';
2424+const PASSPHRASE_OPT_IN_KEY = 'tools-keys-passphrase-enabled';
19252026/** Check if the user has previously set a passphrase (stored as a flag, not the passphrase). */
2127export function hasPassphraseBeenSet(): boolean {
2228 return localStorage.getItem(PASSPHRASE_SET_KEY) === '1';
2329}
24303131+/** Check if the user has opted in to passphrase protection. */
3232+export function isPassphraseEnabled(): boolean {
3333+ return localStorage.getItem(PASSPHRASE_OPT_IN_KEY) === '1';
3434+}
3535+2536/** Mark that a passphrase has been configured. */
2637function markPassphraseSet(): void {
2738 localStorage.setItem(PASSPHRASE_SET_KEY, '1');
2839}
29403041/**
3131- * Ensure the wrapping key is initialized. Shows a modal prompt if needed.
3232- * Returns true if the key is ready, false if the user cancelled.
4242+ * Ensure the wrapping key is initialized.
4343+ *
4444+ * If passphrase protection is not enabled (default), returns true immediately.
4545+ * If enabled, shows a modal prompt if needed.
3346 */
3447export async function ensureWrappingKey(): Promise<boolean> {
4848+ // If passphrase protection is not opted in, skip entirely
4949+ if (!isPassphraseEnabled()) {
5050+ // If keys were previously wrapped (from before opt-in change),
5151+ // prompt once to unwrap them back to plaintext
5252+ if (isWrappedFormat()) {
5353+ const passphrase = await showPassphraseModal('unlock');
5454+ if (passphrase === null) return true; // can't unwrap, but don't block
5555+ await initWrappingKey(passphrase);
5656+ try {
5757+ await unwrapToPlaintext();
5858+ } catch { /* wrong passphrase — keys stay wrapped, user can try again */ }
5959+ // Clear the passphrase flag since they're opting out
6060+ localStorage.removeItem(PASSPHRASE_SET_KEY);
6161+ localStorage.removeItem(PASSPHRASE_OPT_IN_KEY);
6262+ }
6363+ return true;
6464+ }
6565+3566 if (hasWrappingKey()) return true;
36673768 const isLegacy = isLegacyFormat();
···4071 // If there are no stored keys at all and no passphrase set, skip — new user
4172 const raw = localStorage.getItem('tools-keys');
4273 if (!raw && !hasPassphraseBeenSet()) {
4343- // Brand new user with no keys — set up passphrase silently on first key store
4474 return true;
4575 }
4676···5080 await initWrappingKey(passphrase);
51815282 if (isLegacy) {
5353- // Migrate plaintext keys to wrapped format
5483 await migrateLegacyKeys();
5555- } else if (isFirstTime) {
5656- // First-time setup — just mark it
5757- } else {
8484+ } else if (!isFirstTime) {
5885 // Verify the passphrase decrypts existing keys
5986 try {
6087 await getLocalKeys();
6188 } catch {
6262- // Wrong passphrase — decryption failed
6389 throw new Error('incorrect-passphrase');
6490 }
6591 }
···70967197/**
7298 * Ensure wrapping key is ready before storing a key.
7373- * For new users who haven't been prompted yet, prompts now.
9999+ *
100100+ * If passphrase protection is not enabled (default), returns true immediately.
74101 */
75102export async function ensureWrappingKeyForStore(): Promise<boolean> {
103103+ if (!isPassphraseEnabled()) return true;
104104+76105 if (hasWrappingKey()) return true;
7710678107 if (!hasPassphraseBeenSet()) {
7979- // New user storing their first key — prompt to set passphrase
80108 const passphrase = await showPassphraseModal('setup');
81109 if (passphrase === null) return false;
82110 await initWrappingKey(passphrase);
···85113 }
8611487115 return ensureWrappingKey();
116116+}
117117+118118+/**
119119+ * Enable passphrase protection. Call from a settings UI.
120120+ * Returns false if the user cancelled the passphrase prompt.
121121+ */
122122+export async function enablePassphraseProtection(): Promise<boolean> {
123123+ const passphrase = await showPassphraseModal('setup');
124124+ if (passphrase === null) return false;
125125+126126+ localStorage.setItem(PASSPHRASE_OPT_IN_KEY, '1');
127127+ await initWrappingKey(passphrase);
128128+ await migrateLegacyKeys();
129129+ markPassphraseSet();
130130+ return true;
131131+}
132132+133133+/**
134134+ * Disable passphrase protection. Unwraps keys back to plaintext.
135135+ */
136136+export async function disablePassphraseProtection(): Promise<void> {
137137+ if (isWrappedFormat() && hasWrappingKey()) {
138138+ await unwrapToPlaintext();
139139+ }
140140+ localStorage.removeItem(PASSPHRASE_OPT_IN_KEY);
141141+ localStorage.removeItem(PASSPHRASE_SET_KEY);
88142}
8914390144// --- Modal UI ---
+32
src/lib/key-sync.ts
···7272 return null;
7373}
74747575+/** Check if the store is in v2 wrapped (encrypted) format. */
7676+export function isWrappedFormat(): boolean {
7777+ try {
7878+ const raw = localStorage.getItem(STORAGE_KEY);
7979+ if (!raw) return false;
8080+ const parsed = JSON.parse(raw);
8181+ return parsed?.v === 2;
8282+ } catch {
8383+ return false;
8484+ }
8585+}
8686+8787+/**
8888+ * Unwrap v2 encrypted keys back to plaintext format.
8989+ * Requires the wrapping key to be initialized first.
9090+ */
9191+export async function unwrapToPlaintext(): Promise<void> {
9292+ if (!_wrappingKey) throw new Error('No wrapping key');
9393+ const keys = await getLocalKeys();
9494+ if (Object.keys(keys).length === 0) return;
9595+ // Store as plaintext (bypass wrapping by clearing the key temporarily)
9696+ const savedKey = _wrappingKey;
9797+ const savedSalt = _salt;
9898+ _wrappingKey = null;
9999+ _salt = null;
100100+ await setLocalKeys(keys);
101101+ // Push plaintext to server too
102102+ await pushKeysToServer(keys);
103103+ _wrappingKey = savedKey;
104104+ _salt = savedSalt;
105105+}
106106+75107/** Check if the store is still in legacy plaintext format. */
76108export function isLegacyFormat(): boolean {
77109 try {