Files
webmail/lib/app-lock/shell.ts
T
Linus Rath 88d64b7f95 fix: protect the keys to the mail on this device for real
The review found the store key readable from a copy of the disk. The
desktop app only added its OS layer with the lock on, the record from
before a lock stayed in LevelDB and opened the mail with no PIN, any
frame could unwrap the PIN record and guess at it, a lock left the key
in the renderer's memory, and a store waiting after a refused login went
to whoever signed in next under the same username@host.

- The desktop app wraps the store key with safeStorage, lock or no lock.
  A record without the layer gets it at its next open. A refusal is said
  out loud, and only a computer with no key store goes without, which
  settings say. So do they for a browser, which has no such layer.
- A record that gained layers, and every record after a changed PIN, asks
  for a new key: `sqlite3_backup` into a new file under a new key while
  the store keeps working, one write to the record, then the old file
  goes. A changed PIN makes a new lock key, since the old PIN's record
  stays on disk too. The passkey and OS unlock go with the old key.
- `wrap` and `unwrap` come through `claim()`, and the key under the PIN
  takes a secret from main, one counted trip per guess.
- A lock on the desktop ends the renderer rather than reload in it.
- A sign-in compares the waiting store's JMAP account and server first,
  and wipes on a mismatch. The account id keeps its form: every store,
  setting and server record of an account is keyed by it.
- The 30 days also run in an app that stays open.

Changes an existing assertion: turning the lock off now keeps the
safeStorage layer, by decision, so that test expects it to stay.
2026-11-04 04:15:31 +01:00

52 lines
1.9 KiB
TypeScript

/**
* The calls only the page itself may make to the desktop app's main process. A same-origin frame
* reaches `bulwarkDesktop` through `parent`, and main can't tell its calls from the page's. So the
* preload hands these out once per document, and the page takes them in its first render, before
* any frame exists. Imports next to nothing, for lib/app-lock/vault.ts and controller.ts alike.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopAppLockBridge, DesktopAppLockControls } from '@/lib/desktop/types';
let claimedFrom: DesktopAppLockBridge | null = null;
let claimed: DesktopAppLockControls | null = null;
export function shellControls(): DesktopAppLockControls | null {
const member = getDesktopBridge()?.appLock ?? null;
if (member !== claimedFrom) {
claimedFrom = member;
claimed = member?.claim?.() ?? null;
}
return claimed;
}
/** A shell older than the webmail it loads has these on the bridge. */
function olderShell(): Pick<DesktopAppLockControls, 'wrap' | 'unwrap'> {
return (getDesktopBridge()?.appLock ?? {}) as unknown as Pick<DesktopAppLockControls, 'wrap' | 'unwrap'>;
}
export function shellWrapCall(): DesktopAppLockControls['wrap'] {
return shellControls()?.wrap ?? olderShell().wrap;
}
export function shellUnwrapCall(): DesktopAppLockControls['unwrap'] {
return shellControls()?.unwrap ?? olderShell().unwrap;
}
/**
* Whether this computer has a real key store the app can use. No: a browser, or Linux with no secret
* service, and settings say so. A shell that doesn't answer isn't a no: this rejects, so nobody
* writes a key without the layer because of it.
*/
export async function shellHasWrap(): Promise<boolean> {
const member = getDesktopBridge()?.appLock;
if (!member || !shellWrapCall()) return false;
return Boolean((await member.status()).wrap);
}
/** Tests only. */
export function resetShellForTests(): void {
claimedFrom = null;
claimed = null;
}