Files
webmail/lib/desktop/types.ts
T
Linus Rath fb3c82eed5 feat: lock the app with a PIN, a passkey or Windows Hello, with mail out of memory and the local store closed
The lock is off by default, and an OS policy can require it and set the
timeout. It locks after a time without use counted across all windows and
tabs, on OS screen lock in the desktop app, and from Lock now.

Locking saves open composers, stops the outbox at its next message, closes
the store and stops its worker with every key, then reloads the page, so
nothing of the old page stays in reach. The new page holds the app back at a
Suspense boundary until someone unlocks. Pop-outs get no session while
locked, push notifications name nobody, and subjects leave plain storage.

One random lock key wraps each account's key as a layer. The PIN wraps the
lock key with PBKDF2 at 600,000 iterations, with a wait after wrong tries
that a reload doesn't reset. A passkey with PRF and the desktop's OS storage
wrap it too. Details and limits are in the decisions file.
2026-11-03 13:08:37 +01:00

327 lines
13 KiB
TypeScript

/**
* The desktop app's preload bridge as the webmail sees it. Feature checks ask
* `window.bulwarkDesktop?.<area>`; in a browser the object isn't there.
*
* The shell side lives in the desktop repo (src/shared/bridge.ts), a copy of
* this contract. Add a member here and there together. A breaking change bumps
* `version`; adding a member doesn't.
*/
export const DESKTOP_BRIDGE_VERSION = 1;
export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings';
export interface DesktopWindowInfo {
id: number;
type: DesktopWindowType;
title: string;
focused: boolean;
/** False for a main window hidden while pop-outs are open. */
visible?: boolean;
}
export interface DesktopWindowsBridge {
/**
* Opens a pop-out window itself. The name is the window's identity: opening
* the same name again focuses the window that has it. Left out, the page uses
* window.open with a features string, which the shell's window-open handler
* sees too.
*/
open?: (url: string, options: { name: string; width: number; height: number }) => void;
/** One window per message: opening it again focuses it. */
openMessage?: (target: { url: string; emailId: string }) => void;
openComposer?: (target: { url: string; name?: string }) => void;
/** Focuses a window from `list()`, or the main window with no id. */
focus?: (id?: number) => void;
list?: () => Promise<DesktopWindowInfo[]>;
/** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */
onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void;
/**
* This page has saved or discarded and closes its window. Goes through the shell
* so bounds get stored and no second save runs. Works however the window opened.
*/
closeSelf?: () => void;
/** What this window's open drafts come back as at the next start: pop-out composer addresses. */
setRestore?: (urls: string[]) => void;
setRestoreEnabled?: (enabled: boolean) => void;
/** Signed in, with a session to lend: windows from last time can open. */
sessionReady?: () => void;
/** The warm spare's page, see components/popout/popout-spare-app.tsx. */
spare?: {
ready: () => void;
taken: () => void;
onActivate: (listener: (url: string) => void) => () => void;
};
}
export interface DesktopThemeBridge {
/** The page's colours, for title bar buttons and the first frame of new windows. */
set: (theme: { dark: boolean; background: string; foreground?: string }) => void;
/** Fires when the OS switches between light and dark, contrast or accent. Returns the unsubscribe. */
onSystemChange?: (listener: (system: DesktopSystemTheme) => void) => () => void;
/** How the OS looks now. */
system?: () => Promise<DesktopSystemTheme>;
}
export interface DesktopSystemTheme {
dark: boolean;
highContrast: boolean;
/** The OS accent colour as #rrggbb, where the OS has one and tells. */
accent?: string;
}
export type DesktopMenuState = Record<string, boolean | { enabled?: boolean; checked?: boolean }>;
/** A native menu built from an HTML one (lib/desktop/native-menu.ts). */
export interface DesktopNativeMenuItem {
/** Handed back when this item is chosen. Left out of separators and submenus. */
id?: string;
label?: string;
type?: 'normal' | 'separator' | 'checkbox' | 'radio';
enabled?: boolean;
checked?: boolean;
submenu?: DesktopNativeMenuItem[];
}
export interface DesktopMenuBarEntry {
id: string;
label: string;
/** The letter that opens it with Alt, lower case. */
mnemonic?: string;
}
export interface DesktopMenuBarEvent {
visible: boolean;
/** Open this menu right away: Alt plus its letter. */
open?: string;
}
/** What a page answers when asked to save before a window goes. */
export interface DesktopFlushAnswer {
failed?: number;
timedOut?: boolean;
/** Mail waiting to be sent. */
unsent?: number;
}
export interface DesktopMenuBridge {
/**
* What the app menu shows as enabled or checked, by command id. The shell
* keeps the last state per window and applies it when that window has focus.
*/
setState: (state: DesktopMenuState) => void;
/** A menu command to run. Returns the unsubscribe. */
onCommand?: (listener: (id: string) => void) => () => void;
/** Shows a native menu. At the pointer when `at` is left out. Answers with the id picked, or null. */
popup?: (items: DesktopNativeMenuItem[], at?: { x: number; y: number }) => Promise<string | null>;
/** The strip that stands in for a menu bar in a window with no native bar. */
bar?: {
entries: () => Promise<DesktopMenuBarEntry[]>;
/** Opens that menu natively, at page coordinates. */
open: (id: string, at: { x: number; y: number }) => void;
onEvent: (listener: (event: DesktopMenuBarEvent) => void) => () => void;
};
}
export interface DesktopShellBridge {
/** http, https and mailto only. */
openExternal: (url: string) => void;
/** The shell's menus follow `ui`. Spellcheck follows both. */
setLanguages?: (languages: { ui: string; composer?: string[] }) => void;
}
export interface DesktopDialogsBridge {
/** A native question. Answers with the index of the button chosen. The last button is the safe one. */
confirm: (options: {
message: string;
detail?: string;
buttons: string[];
defaultId?: number;
type?: 'none' | 'info' | 'warning' | 'question';
}) => Promise<number>;
}
export interface DesktopClipboardBridge {
/** Puts a file on the clipboard, for pasting into a folder. False when that didn't work. */
copyFile: (name: string, data: ArrayBuffer) => Promise<boolean>;
}
export type DesktopServerPrepareResult =
| { ok: true; approved: boolean }
| { ok: false; reason: 'declined' | 'invalid' };
export interface DesktopServersBridge {
/**
* Call before signing in to a server. Asks the user first when it is on the
* LAN or plain http, and lets the app reach it. Standalone mode only.
*/
prepare: (serverUrl: string) => Promise<DesktopServerPrepareResult>;
/**
* The sign-in worked, so the server stays: what `prepare` added lasts only until
* the app quits before this. Other origins a session names get a prompt.
*/
noteSession: (
serverUrl: string,
urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string },
) => void;
/** A sign-in that failed, or the last account on this server signing out. */
forget?: (serverUrl: string, reason: 'failed' | 'signed-out') => void;
}
export interface DesktopLifecycleBridge {
/**
* Before a window closes or the app quits, the shell asks the page to save what
* it has and waits a few seconds. A window with no handler closes at once.
*/
onFlushRequest: (handler: () => Promise<DesktopFlushAnswer | unknown>) => () => void;
}
export type DesktopBrowserSignInResult =
| { ok: true; code: string; state: string }
| { ok: false; error: string };
export interface DesktopAuthBridge {
/** Starts a short-lived loopback listener for one sign-in. */
beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>;
/** Opens the system browser on `authorizeUrl` and waits for the redirect. */
completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise<DesktopBrowserSignInResult>;
cancelBrowserSignIn: (flowId: string) => void;
}
export type DesktopPluginInstallResult =
| { ok: true; id: string; version: string }
| { ok: false; error: string; code?: string };
export interface DesktopPluginsBridge {
/**
* Installs from the directory through the local admin API, with its signature
* and hash checks, after a native prompt. No version means the latest. The
* renderer never holds an admin session. Standalone only.
*/
install: (slug: string, version?: string) => Promise<DesktopPluginInstallResult>;
}
export type DesktopKeystoreHash = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512';
export interface DesktopKeystoreCertificate {
/** Opaque: the only ids sign, decrypt and agree accept. */
id: string;
/** The leaf, DER. */
certificate: Uint8Array;
/** Intermediates, DER, without leaf and root. May be empty. */
chain: Uint8Array[];
subject: string;
issuer: string;
/** Hex. */
serial: string;
/** Milliseconds since 1970. */
notBefore: number;
notAfter: number;
/** keyUsage names. Empty without the extension. */
usages: string[];
/** extendedKeyUsage OIDs. Empty without the extension. */
extendedUsages: string[];
/** The key's algorithm OID. */
algorithm: string;
keyType: 'rsa' | 'ec';
curve?: 'P-256' | 'P-384' | 'P-521';
keyBits: number;
/** A hint for the UI. */
source: 'software' | 'smartcard' | 'keychain' | 'unknown';
signSchemes: string[];
decryptSchemes: string[];
canAgree: boolean;
/** The key can also sign the user in somewhere: the user is asked per use. */
askEachUse: boolean;
}
/** Whether this origin may use the keystore at all. `app` is standalone. */
export type DesktopKeystoreAccessState = 'app' | 'policy' | 'stored' | 'off';
export type DesktopKeystoreSignScheme =
| { scheme: 'rsa-pkcs1'; hash: DesktopKeystoreHash }
/** MGF1 on the same hash, salt as long as the hash. */
| { scheme: 'rsa-pss'; hash: DesktopKeystoreHash }
/** `hash` names the digest's length. */
| { scheme: 'ecdsa'; hash: DesktopKeystoreHash };
export type DesktopKeystoreDecryptScheme =
| { scheme: 'rsa-pkcs1' }
/** MGF1 on the same hash. */
| { scheme: 'rsa-oaep'; hash: DesktopKeystoreHash; label?: Uint8Array };
/**
* The OS certificate store and smart cards. Present only where the native helper
* loaded and the OS has one. Failures reject with a bare code as the message:
* keystore:cancelled, not-found, unsupported, locked, invalid, failed. The shell asks the
* user in a native dialog before any of this, on top of which the OS has its own prompts,
* so a call may take a while. On an instance it stays off until policy or the user allows
* it. The key never leaves the OS.
*/
export interface DesktopKeystoreBridge {
/** Certificates fit for mail that have a private key. The OS doesn't prompt. */
list: () => Promise<DesktopKeystoreCertificate[]>;
/** RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value. */
sign: (id: string, digest: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise<Uint8Array>;
/** Unwraps a CMS content key. */
decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise<Uint8Array>;
/** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */
agree: (id: string, peerPublicKey: Uint8Array) => Promise<Uint8Array>;
accessState: () => Promise<DesktopKeystoreAccessState>;
/** Takes back a stored yes and this session's grants. Only ever narrows. */
revokeAccess: () => Promise<void>;
}
export type DesktopOsUnlockKind = 'hello' | 'touchid';
/**
* App lock in the desktop app. Main holds whether it's locked, and nothing a page says can
* lift that: `prove` needs what only a real unlock gives, Windows Hello and Touch ID run in
* main, and `forget` asks natively while locked. A same-origin plugin frame can reach all
* of this through `parent`, so that has to hold on a call alone.
*/
export interface DesktopAppLockBridge {
status: () => Promise<{ locked: boolean; enrolled: boolean; os: DesktopOsUnlockKind | null; osEnrolled: boolean }>;
/** Locks. Anyone may: it only ever makes things more locked. */
lock: () => void;
/** Sets up the proof. False when one exists. */
enroll: (proof: Uint8Array) => Promise<boolean>;
/** Lifts the lock when the proof is the right one. */
prove: (proof: Uint8Array) => Promise<boolean>;
/** The lock goes off for good. */
disable: (proof: Uint8Array) => Promise<boolean>;
/** The PIN is lost, or everyone signed out. While locked, main asks the user natively. */
forget: () => Promise<boolean>;
/** Asks Windows Hello or Touch ID, then keeps the key in the OS's encrypted storage. */
osEnroll: (proof: Uint8Array, lockKey: Uint8Array) => Promise<boolean>;
osRemove: () => Promise<void>;
/** Only while locked. Main asks, and after a yes lifts its lock and hands over the key. */
osUnlock: () => Promise<{ ok: true; key: Uint8Array } | { ok: false; reason: 'unavailable' | 'denied' | 'failed' | 'not-locked' }>;
/** The screen locked, or "Lock now" in the menu or tray. Returns the unsubscribe. */
onLockRequest: (listener: () => void) => () => void;
}
export interface BulwarkDesktopBridge {
version: number;
platform: 'win32' | 'darwin' | 'linux';
mode?: 'standalone' | 'instance';
windows?: DesktopWindowsBridge;
theme?: DesktopThemeBridge;
menu?: DesktopMenuBridge;
shell?: DesktopShellBridge;
dialogs?: DesktopDialogsBridge;
clipboard?: DesktopClipboardBridge;
lifecycle?: DesktopLifecycleBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
keystore?: DesktopKeystoreBridge;
appLock?: DesktopAppLockBridge;
}
declare global {
interface Window {
bulwarkDesktop?: BulwarkDesktopBridge;
}
}