mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
133 lines
5.7 KiB
TypeScript
133 lines
5.7 KiB
TypeScript
// Host side of api.keystore: the OS certificate store and smart cards, for an
|
|
// S/MIME plugin, in the desktop app. Signing and decrypting happen in the OS. The
|
|
// private key never reaches the webmail, and the OS's own prompts stay on.
|
|
//
|
|
// Privileged tier only (host-api.ts), so a user-uploaded plugin never gets here.
|
|
// None of this is a security boundary: a privileged frame is same-origin and can reach
|
|
// window.parent.bulwarkDesktop directly. The checks are for the front door. What holds in
|
|
// both cases is in the shell: a native dialog in main (keystore-consent.ts) and the OS.
|
|
//
|
|
// RPC errors reach the frame as message strings only, so the code is the message:
|
|
// keystore:cancelled, keystore:not-found, keystore:unsupported, keystore:locked,
|
|
// keystore:invalid, keystore:failed.
|
|
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
import type { DesktopKeystoreBridge, DesktopKeystoreCertificate } from '../desktop/types';
|
|
import { holdForUser } from './host-dialog';
|
|
import { pluginHasPermission } from './permissions';
|
|
import { resolvePluginTier } from './tier';
|
|
|
|
const MAX_ID = 64;
|
|
const MAX_DIGEST = 64;
|
|
// A 16384-bit modulus, the most anything issues.
|
|
const MAX_CIPHERTEXT = 2048;
|
|
const MAX_LABEL = 256;
|
|
const MAX_PEER_KEY = 256;
|
|
|
|
const HASHES = new Set(['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']);
|
|
const SIGN_SCHEMES = new Set(['rsa-pkcs1', 'rsa-pss', 'ecdsa']);
|
|
const DECRYPT_SCHEMES = new Set(['rsa-pkcs1', 'rsa-oaep']);
|
|
|
|
export const KEYSTORE_PERMS = {
|
|
'keystore.list': 'keystore',
|
|
'keystore.sign': 'keystore',
|
|
'keystore.decrypt': 'keystore',
|
|
'keystore.agree': 'keystore',
|
|
} as const;
|
|
|
|
export const KEYSTORE_METHODS: readonly string[] = Object.keys(KEYSTORE_PERMS);
|
|
|
|
function desktopKeystore(): DesktopKeystoreBridge | undefined {
|
|
return typeof window === 'undefined' ? undefined : window.bulwarkDesktop?.keystore;
|
|
}
|
|
|
|
/** api.host.features.keystore for this plugin. Absent rather than false when it's not: older hosts don't name it. */
|
|
export function keystoreFeature(plugin?: InstalledPlugin): { keystore?: true } {
|
|
if (!plugin || !desktopKeystore()) return {};
|
|
if (resolvePluginTier(plugin).tier !== 'privileged' || !pluginHasPermission(plugin, 'keystore')) return {};
|
|
return { keystore: true };
|
|
}
|
|
|
|
function invalid(): Error {
|
|
return new Error('keystore:invalid');
|
|
}
|
|
|
|
function readId(value: unknown): string {
|
|
if (typeof value !== 'string' || !value || value.length > MAX_ID) throw invalid();
|
|
return value;
|
|
}
|
|
|
|
function readBytes(value: unknown, max: number): Uint8Array {
|
|
const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value;
|
|
if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > max) throw invalid();
|
|
return bytes;
|
|
}
|
|
|
|
function readScheme(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid();
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function readHash(value: unknown): string {
|
|
if (typeof value !== 'string' || !HASHES.has(value)) throw invalid();
|
|
return value;
|
|
}
|
|
|
|
function checkedSignScheme(value: unknown): { scheme: string; hash: string } {
|
|
const scheme = readScheme(value);
|
|
if (typeof scheme.scheme !== 'string' || !SIGN_SCHEMES.has(scheme.scheme)) throw invalid();
|
|
return { scheme: scheme.scheme, hash: readHash(scheme.hash) };
|
|
}
|
|
|
|
function checkedDecryptScheme(value: unknown): { scheme: string; hash?: string; label?: Uint8Array } {
|
|
const scheme = readScheme(value);
|
|
if (typeof scheme.scheme !== 'string' || !DECRYPT_SCHEMES.has(scheme.scheme)) throw invalid();
|
|
if (scheme.scheme === 'rsa-pkcs1') return { scheme: scheme.scheme };
|
|
const label = scheme.label === undefined || (scheme.label instanceof Uint8Array && scheme.label.byteLength === 0)
|
|
? undefined
|
|
: readBytes(scheme.label, MAX_LABEL);
|
|
return { scheme: scheme.scheme, hash: readHash(scheme.hash), ...(label ? { label } : {}) };
|
|
}
|
|
|
|
/** Only the code, whatever the bridge put around it. */
|
|
function codeOnly(error: unknown): Error {
|
|
const code = /keystore:[a-z-]+/.exec(error instanceof Error ? error.message : String(error))?.[0];
|
|
return new Error(code ?? 'keystore:failed');
|
|
}
|
|
|
|
export async function doKeystoreCall(plugin: InstalledPlugin, method: string, args: unknown[]): Promise<unknown> {
|
|
const keystore = desktopKeystore();
|
|
// A browser, a build without the helper, a plugin on an older shell.
|
|
if (!keystore) throw new Error('keystore:unsupported');
|
|
|
|
const run = (): Promise<DesktopKeystoreCertificate[] | Uint8Array> => {
|
|
switch (method) {
|
|
case 'keystore.list':
|
|
return keystore.list();
|
|
case 'keystore.sign':
|
|
return keystore.sign(readId(args[0]), readBytes(args[1], MAX_DIGEST), checkedSignScheme(args[2]) as never);
|
|
case 'keystore.decrypt':
|
|
return keystore.decrypt(readId(args[0]), readBytes(args[1], MAX_CIPHERTEXT), checkedDecryptScheme(args[2]) as never);
|
|
case 'keystore.agree':
|
|
return keystore.agree(readId(args[0]), readBytes(args[1], MAX_PEER_KEY));
|
|
default:
|
|
throw new Error('keystore:unsupported');
|
|
}
|
|
};
|
|
|
|
// The OS may be showing a PIN dialog right now, which the host can't see. While it
|
|
// is, the plugin is waiting on the user, not hung: this holds the hook timer the
|
|
// way one of its own dialogs does, so a card's PIN during onRenderEmailBody doesn't
|
|
// count towards auto-disable. Not for a list: it never touches a card, and a plugin
|
|
// shouldn't be able to stop the timer with it. The hold has an absolute deadline
|
|
// (plugin-hooks.ts), and the shell gives up on a stuck call before that.
|
|
const release = method === 'keystore.list' ? () => {} : holdForUser(plugin.id);
|
|
try {
|
|
return await run();
|
|
} catch (error) {
|
|
throw codeOnly(error);
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|