Merge branch 'feat/desktop-offline' into feat/do-proxy-fixes

This commit is contained in:
Linus Rath
2026-11-04 04:12:23 +01:00
29 changed files with 628 additions and 79 deletions
@@ -49,6 +49,36 @@ describe('installVersion', () => {
expect(calls).toHaveLength(1);
});
it('says what an update adds, and repeats with what the admin saw', async () => {
responses = [json({ error: 'adds', permissionsAdded: ['keystore'] }, 409), json({ warnings: [] })];
const asked: string[] = [];
const result = await installVersion(BODY, (q) => { asked.push(`${q.title} ${q.message}`); return true; });
expect(asked[0]).toContain('new permissions');
expect(asked[0]).toContain('keystore');
expect(calls.map((c) => c.body.acceptPermissions)).toEqual([undefined, ['keystore']]);
expect(calls.map((c) => c.body.force), 'not the same as forcing it').toEqual([undefined, undefined]);
expect(result.ok).toBe(true);
});
it('does not update past new permissions the admin declined', async () => {
responses = [json({ error: 'adds', permissionsAdded: ['keystore'] }, 409)];
expect(await installVersion(BODY, () => false)).toEqual({ ok: false, error: 'Not installed.' });
expect(calls).toHaveLength(1);
});
it('asks about both when a withdrawn version also adds permissions, and keeps both answers', async () => {
responses = [
json({ error: 'withdrawn', yanked: { reason: null } }, 409),
json({ error: 'adds', permissionsAdded: ['keystore'] }, 409),
json({ warnings: [] }),
];
const asked: string[] = [];
const result = await installVersion(BODY, (q) => { asked.push(q.title); return true; });
expect(asked).toHaveLength(2);
expect(calls.at(-1)!.body).toMatchObject({ force: true, acceptPermissions: ['keystore'] });
expect(result.ok).toBe(true);
});
it('shows any other 409 as the error it is', async () => {
responses = [json({ error: 'The configuration is read-only' }, 409)];
const result = await installVersion(BODY, () => true);
@@ -28,6 +28,7 @@ import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
import type { MarketplaceListing } from '@/lib/admin/marketplace-directory';
import { adminPath } from '@/lib/admin/admin-nav';
import { declaredPermissions, HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
import { cn } from '@/lib/utils';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
@@ -97,6 +98,8 @@ interface PreviewData {
requestedVersionMissing: string | null;
bundle: {
manifest: Record<string, unknown> | null;
/** What this version asks for that the installed one doesn't. */
addedPermissions?: string[];
source: { name: string; content: string; truncated: boolean } | null;
size: number;
error: string | null;
@@ -307,8 +310,10 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
const bundle = data.bundle;
const preview = data.preview;
const isPlugin = ext.type === 'plugin';
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || preview && ext.versions.find((v) => v.version === preview.version)?.permissions || ext.permissions || [];
// Optional permissions are granted like the others, so they count here too.
const manifestPerms = bundle.manifest ? declaredPermissions(bundle.manifest) : (preview && ext.versions.find((v) => v.version === preview.version)?.permissions) || ext.permissions || [];
const permissionText = new Map(ext.permissionDetails.map((p) => [p.id, p]));
const addedPermissions = new Set(bundle.addedPermissions ?? []);
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
@@ -705,7 +710,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
<ul className="space-y-1.5">
{manifestPerms.map(perm => {
const text = permissionText.get(perm);
const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm);
const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm) || HIGH_RISK_PERMISSIONS.has(perm);
return (
<li
key={perm}
@@ -721,6 +726,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
<div className="flex flex-wrap items-center gap-2">
{text && <span className="font-medium">{text.title}</span>}
<code className="font-mono text-xs opacity-80">{perm}</code>
{addedPermissions.has(perm) && <Badge tone="warning">New in this version</Badge>}
</div>
{text?.body && <p className="mt-0.5 text-xs opacity-90">{text.body}</p>}
</div>
+28 -9
View File
@@ -45,6 +45,7 @@ interface InstalledEntry {
interface UpdatesResult {
installed: InstalledEntry[];
updates: Array<{
addedPermissions?: string[];
slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; publishedAt: string | null;
changelog: string | null; notes?: ChangelogFlags; changelogs?: ChangelogEntry[];
}>;
@@ -95,7 +96,9 @@ const pillClass = (active: boolean) => cn(
* Installs one version of an extension. A version the publisher withdrew,
* or one older than the installed version, comes back as 409; the admin is
* asked (in the page's ConfirmDialog), and the install is repeated with
* `force` if they insist. Returns the error text to show, or null.
* `force` if they insist. An update that asks for more permissions comes back
* the same way, and is repeated with the ones the admin saw. "Update all" goes
* through here too, so it asks about each. Returns the error text to show, or null.
*/
export interface InstallQuestion {
title: string;
@@ -108,28 +111,39 @@ export async function installVersion(
body: { slug: string; version: string; type: 'plugin' | 'theme' },
confirm: (question: InstallQuestion) => boolean | Promise<boolean>,
): Promise<{ ok: true; warnings: string[]; signature: string | null; updated: boolean } | { ok: false; error: string }> {
const send = (force: boolean) => apiFetch('/api/admin/marketplace', {
let force = false;
let acceptPermissions: string[] | undefined;
const send = () => apiFetch('/api/admin/marketplace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(force ? { ...body, force: true } : body),
body: JSON.stringify({ ...body, ...(force ? { force: true } : {}), ...(acceptPermissions ? { acceptPermissions } : {}) }),
});
try {
let res = await send(false);
if (res.status === 409) {
let res = await send();
// At most one of each question: withdrawn or older, then permissions.
for (let asked = 0; res.status === 409 && asked < 3; asked += 1) {
const data = await res.json().catch(() => ({}));
if (data.yanked) {
if (Array.isArray(data.permissionsAdded) && !acceptPermissions) {
const names = (data.permissionsAdded as string[]).join(', ');
const message = `${body.slug} ${body.version} asks for new permissions that the installed version doesn't have: ${names}.`;
if (!(await confirm({ title: 'Allow the new permissions?', message, confirmText: 'Update', cancelText: 'Cancel' }))) {
return { ok: false, error: 'Not installed.' };
}
acceptPermissions = data.permissionsAdded;
} else if (data.yanked && !force) {
if (!(await confirm({ title: 'Install it anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) {
return { ok: false, error: 'Not installed.' };
}
res = await send(true);
} else if (data.downgrade) {
force = true;
} else if (data.downgrade && !force) {
if (!(await confirm({ title: 'Install the older version anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) {
return { ok: false, error: 'Not installed.' };
}
res = await send(true);
force = true;
} else {
return { ok: false, error: data.error || 'Installation failed' };
}
res = await send();
}
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: data.error || 'Installation failed' };
@@ -463,6 +477,11 @@ function UpdatesSection({
{u.channel === 'beta' && <Badge tone="info">beta</Badge>}
<ChangelogBadges flags={{ security: anyFlag(entries, 'security'), breaking: anyFlag(entries, 'breaking') }} />
</div>
{(u.addedPermissions?.length ?? 0) > 0 && (
<p className="mt-1 text-xs text-amber-800 dark:text-amber-300" data-testid={`update-permissions-${u.slug}`}>
New permissions: <span className="font-mono">{u.addedPermissions!.join(', ')}</span>
</p>
)}
{withNotes.length > 0 ? (
<details className="mt-1" data-testid={`update-notes-${u.slug}`}>
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">
+24 -2
View File
@@ -23,12 +23,22 @@ import {
type DirectoryDetail,
type DirectoryVersion,
} from '@/lib/admin/marketplace-directory';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { IMPLICIT_PERMISSIONS, MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { declaredPermissions, HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
import { getPluginRegistry } from '@/lib/admin/plugin-registry';
import { productName } from '@/lib/product-name';
import { withAppName } from '@/lib/settings-search';
const MAX_PREVIEW_SOURCE_LEN = 100_000;
/** For a directory that predates it. It only lists what it knows about, and admin pages are in English. */
const LOCAL_PERMISSION_TEXT: Record<string, { title: string; body: string }> = {
keystore: {
title: 'Use certificates on this computer',
body: 'Ask to sign and decrypt messages with certificates on this computer or a smart card. The app asks each user first. The plugin never gets the private key, but while they allow it, it can sign in their name and read what it decrypts.',
},
};
/** A version row as the detail page renders it. */
function versionRow(v: DirectoryVersion) {
return {
@@ -176,6 +186,17 @@ export async function GET(
const state = installedStateOf(installed, slug, type);
const history = await historyFor(type, slug);
const appName = productName(configManager.get<string>('appName'));
// What the bundle itself declares, optional permissions too: the directory's list can leave some out.
const declared = type === 'plugin' ? declaredPermissions(manifest) : [];
const listed = new Set((detail.permissions ?? []).map((p) => p.id));
const unlisted = declared.filter((id) => !listed.has(id) && LOCAL_PERMISSION_TEXT[id]).map((id) => ({
id,
...LOCAL_PERMISSION_TEXT[id],
highRisk: HIGH_RISK_PERMISSIONS.has(id),
}));
const installedPlugin = state.installed && type === 'plugin' ? (await getPluginRegistry()).plugins.find((p) => p.id === slug) : null;
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
const addedPermissions = installedPlugin ? declared.filter((p) => !installedPlugin.permissions?.includes(p) && !implicit.has(p)) : [];
return NextResponse.json(
{
@@ -189,7 +210,7 @@ export async function GET(
themePreviews: detail.themePreviews ?? [],
advisories: detail.advisories ?? [],
// The directory ships the webmail's permission texts unfilled.
permissionDetails: (detail.permissions ?? []).map((p) => withAppName(p, appName) as typeof p),
permissionDetails: [...(detail.permissions ?? []), ...unlisted].map((p) => withAppName(p, appName) as typeof p),
source: detail.source,
createdAt: detail.createdAt ?? null,
updatedAt: detail.updatedAt ?? null,
@@ -202,6 +223,7 @@ export async function GET(
requestedVersionMissing: requested && !requestedRow ? requested : null,
bundle: {
manifest,
addedPermissions,
source: sourcePreview,
size: bundleSize,
error: bundleError,
@@ -19,7 +19,7 @@ vi.mock('@/lib/admin/config-manager', () => ({
}));
vi.mock('@/lib/admin/plugin-registry', () => ({
getPluginRegistry: vi.fn(async () => ({
plugins: [{ id: 'pretty-dates', name: 'Pretty Dates', version: '1.1.0', enabled: true, source: { kind: 'marketplace', slug: 'pretty-dates', directoryUrl: 'https://extensions.test' } }],
plugins: [{ id: 'pretty-dates', name: 'Pretty Dates', version: '1.1.0', permissions: ['email:read'], enabled: true, source: { kind: 'marketplace', slug: 'pretty-dates', directoryUrl: 'https://extensions.test' } }],
})),
getThemeRegistry: vi.fn(async () => ({ themes: [] })),
}));
@@ -127,3 +127,43 @@ describe('GET /api/admin/marketplace/[slug]', () => {
expect((await preview('?version=..%2F..%2Fetc')).status).toBe(400);
});
});
describe('permissions in the preview', () => {
async function previewOf(manifest: Record<string, unknown>) {
const zip = new JSZip();
zip.file('manifest.json', JSON.stringify({ id: 'pretty-dates', name: 'Pretty Dates', version: '1.2.0', author: 'x', description: 'd', type: 'hook', entrypoint: 'index.js', ...manifest }));
zip.file('index.js', '// x');
const bytes = (await zip.generateAsync({ type: 'uint8array' })).slice().buffer as ArrayBuffer;
const versions = [v('1.2.0')];
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
const u = new URL(String(url));
if (u.pathname === '/api/v2/extensions/pretty-dates') {
return Response.json({ data: { ...summary, latest: versions[0], newest: versions[0], longDescription: 'long', versions, permissions: [{ id: 'crypto:full', title: 'Full crypto', body: 'b', highRisk: true }], screenshots: [], themePreviews: [], advisories: [] } });
}
if (u.pathname.endsWith('/versions')) return Response.json({ data: versions });
if (u.pathname.endsWith('/bundle')) return new Response(bytes);
return Response.json({ data: versions[0] });
}));
const res = await GET(new NextRequest('http://localhost/api/admin/marketplace/pretty-dates'), { params: Promise.resolve({ slug: 'pretty-dates' }) });
return res.json();
}
it('names an optional keystore with its own text and as high risk, even if the directory does not', async () => {
const body = await previewOf({ tier: 'privileged', permissions: ['crypto:full'], optionalPermissions: ['keystore'] });
const entry = body.extension.permissionDetails.find((p: { id: string }) => p.id === 'keystore');
expect(entry).toMatchObject({ highRisk: true });
expect(entry.title).toMatch(/certificates/i);
expect(body.extension.permissionDetails.filter((p: { id: string }) => p.id === 'crypto:full')).toHaveLength(1);
});
it('says what this version adds to the installed one', async () => {
const body = await previewOf({ tier: 'privileged', permissions: ['crypto:full', 'email:read'], optionalPermissions: ['keystore'] });
expect(body.bundle.addedPermissions).toEqual(['crypto:full', 'keystore']);
});
it('adds nothing for an untrusted plugin, which can not get it', async () => {
const body = await previewOf({ permissions: ['email:read'], optionalPermissions: ['keystore'] });
expect(body.extension.permissionDetails.map((p: { id: string }) => p.id)).not.toContain('keystore');
expect(body.bundle.addedPermissions).toEqual([]);
});
});
@@ -215,3 +215,49 @@ describe('POST /api/admin/marketplace', () => {
expect(registry.get('pgp-plugin')?.version).toBe('2.0.0');
});
});
describe('an update that asks for more than the installed version', () => {
const INSTALLED = { id: 'pretty-dates', name: 'Pretty Dates', version: '0.9.0', permissions: ['email:read'], enabled: true };
it('is refused until someone has seen what it adds, and installs nothing meanwhile', async () => {
registry.set('pretty-dates', { ...INSTALLED });
directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send'] }));
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
expect(res.status).toBe(409);
expect((await res.json()).permissionsAdded).toEqual(['email:send']);
expect(registry.get('pretty-dates')!.version).toBe('0.9.0');
});
it('counts an optional permission, since it is granted like any other', async () => {
registry.set('pretty-dates', { ...INSTALLED });
directory(await bundle({ ...MANIFEST, id: 'pretty-dates', optionalPermissions: ['email:send'] }));
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
expect(res.status).toBe(409);
expect((await res.json()).permissionsAdded).toEqual(['email:send']);
});
it('forcing it is about something else, and does not get past this', async () => {
registry.set('pretty-dates', { ...INSTALLED });
directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send'] }));
expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', force: true })).status).toBe(409);
});
it('goes through when what was accepted covers it, and only then', async () => {
registry.set('pretty-dates', { ...INSTALLED });
directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send', 'email:raw-send'] }));
expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', acceptPermissions: ['email:send'] })).status).toBe(409);
expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', acceptPermissions: 'email:send,email:raw-send' })).status).toBe(409);
const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', acceptPermissions: ['email:send', 'email:raw-send'] });
expect(res.status).toBe(200);
expect(registry.get('pretty-dates')!.permissions).toEqual(['email:read', 'email:send', 'email:raw-send']);
});
it('asks nothing when nothing is added, or on a first install', async () => {
registry.set('pretty-dates', { ...INSTALLED });
directory(await bundle({ ...MANIFEST, id: 'pretty-dates' }));
expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200);
registry.clear();
directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send'] }));
expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200);
});
});
@@ -17,7 +17,7 @@ vi.mock('@/lib/admin/config-manager', () => ({
vi.mock('@/lib/admin/plugin-registry', () => ({
getPluginRegistry: vi.fn(async () => ({
plugins: [
{ id: 'quick-reply', name: 'Quick Reply', version: '1.2.0', enabled: true, source: { kind: 'marketplace', slug: 'quick-reply', directoryUrl: 'https://extensions.test' } },
{ id: 'quick-reply', name: 'Quick Reply', version: '1.2.0', permissions: ['email:read'], enabled: true, source: { kind: 'marketplace', slug: 'quick-reply', directoryUrl: 'https://extensions.test' } },
{ id: 'house-plugin', name: 'House', version: '0.1.0', enabled: true, source: { kind: 'upload' } },
// Installed before the source was recorded: still asked about.
{ id: 'old-install', name: 'Old', version: '3.0.0', enabled: false },
@@ -34,6 +34,21 @@ import { GET } from '../updates/route';
afterEach(() => vi.unstubAllGlobals());
describe('GET /api/admin/marketplace/updates', () => {
it('says which permissions each update adds', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
data: {
updates: [
{ slug: 'quick-reply', currentVersion: '1.2.0', version: '1.3.0', channel: 'stable', changelog: null, permissions: ['email:read', 'keystore'] },
{ slug: 'old-install', currentVersion: '3.0.0', version: '3.1.0', channel: 'stable', changelog: null },
],
yanked: [], deprecated: [], suspended: [], advisories: [], unknown: [],
},
})));
const body = await (await GET(new NextRequest('http://localhost/api/admin/marketplace/updates'))).json();
expect(body.updates[0].addedPermissions).toEqual(['keystore']);
expect(body.updates[1].addedPermissions).toEqual([]);
});
it('asks the directory about the marketplace installs only, on the configured channel', async () => {
let posted: { url: string; body: unknown } | null = null;
vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => {
+17 -2
View File
@@ -42,7 +42,7 @@ import {
type DirectorySummary,
type DirectoryVersion,
} from '@/lib/admin/marketplace-directory';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
import { createHash } from 'node:crypto';
import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy';
@@ -116,7 +116,7 @@ export async function POST(request: NextRequest) {
}
const ip = getClientIP(request);
const { slug, version, type, force } = await request.json();
const { slug, version, type, force, acceptPermissions } = await request.json();
if (!slug || !version || !type) {
return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 });
@@ -340,6 +340,20 @@ export async function POST(request: NextRequest) {
const existingPlugin = await getPlugin(slug);
const isUpdate = existingPlugin !== null;
// An update must not widen what a plugin may do with nobody seeing it. `force` is for
// withdrawn and older versions, and says nothing about this.
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
const permissionsAdded = isUpdate
? manifest.permissions.filter((p) => !existingPlugin.permissions?.includes(p) && !implicit.has(p))
: [];
const accepted = Array.isArray(acceptPermissions) ? acceptPermissions : [];
if (permissionsAdded.some((p) => !accepted.includes(p))) {
return NextResponse.json(
{ error: `Version ${version} asks for new permissions: ${permissionsAdded.join(', ')}.`, permissionsAdded },
{ status: 409 },
);
}
const plugin: ServerPlugin = {
id: slug,
name: manifest.name,
@@ -382,6 +396,7 @@ export async function POST(request: NextRequest) {
httpOrigins: plugin.httpOrigins ?? [],
apiPostPaths: plugin.apiPostPaths ?? [],
signature: signature.status,
...(permissionsAdded.length > 0 ? { permissionsAdded } : {}),
...(meta.yanked ? { yanked: true } : {}),
...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
},
+11 -1
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/admin/session';
import { logger } from '@/lib/logger';
import { getPluginRegistry } from '@/lib/admin/plugin-registry';
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
import {
APP_VERSION,
DirectoryError,
@@ -19,7 +21,8 @@ import {
export interface UpdatesAnswer {
/** `changelogs` is missing from directories older than 2026-10-30; `changelog` is then the offered version's alone. */
updates: Array<DirectoryVersion & { slug: string; currentVersion: string; changelog: string | null; changelogs?: DirectoryChangelogEntry[] }>;
/** `addedPermissions` is what the directory lists against what's installed; the install itself reads the bundle. */
updates: Array<DirectoryVersion & { slug: string; currentVersion: string; changelog: string | null; changelogs?: DirectoryChangelogEntry[]; addedPermissions?: string[] }>;
yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>;
deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>;
suspended: string[];
@@ -66,6 +69,13 @@ export async function GET(request: NextRequest) {
body: JSON.stringify({ appVersion: APP_VERSION || undefined, channel, installed: ask }),
});
answer = { ...EMPTY, ...res.data };
const { plugins } = await getPluginRegistry();
const held = new Map(plugins.map((p) => [p.id, new Set<string>(p.permissions ?? [])]));
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
answer.updates = answer.updates.map((u) => ({
...u,
addedPermissions: (u.permissions ?? []).filter((p) => !implicit.has(p) && !(held.get(u.slug)?.has(p) ?? true)),
}));
} catch (err) {
// The installed list is still useful without the directory; say what
// went wrong instead of failing the whole view.
+6
View File
@@ -202,6 +202,12 @@
<list id="AutoSelectCertificateForUrls_Value" valuePrefix=""/>
</elements>
</policy>
<policy name="KeystoreEnabled" class="Both" displayName="$(string.KeystoreEnabled)" explainText="$(string.KeystoreEnabled_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="KeystoreEnabled">
<parentCategory ref="Bulwark_Desktop_signIn"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="KeystoreAllowedOrigins" class="Both" displayName="$(string.KeystoreAllowedOrigins)" explainText="$(string.KeystoreAllowedOrigins_Explain)" presentation="$(presentation.KeystoreAllowedOrigins)" key="Software\Policies\Bulwark\Desktop\KeystoreAllowedOrigins">
<parentCategory ref="Bulwark_Desktop_signIn"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
+2
View File
@@ -54,6 +54,8 @@
<string id="AuthNegotiateDelegateAllowlist_Explain">Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam.</string>
<string id="AutoSelectCertificateForUrls">Clientzertifikate automatisch wählen</string>
<string id="AutoSelectCertificateForUrls_Explain">Eine JSON-Regel pro Zeile, im Format von Chrome: {&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.</string>
<string id="KeystoreEnabled">Zertifikatspeicher des Systems erlauben</string>
<string id="KeystoreEnabled_Explain">Wenn deaktiviert, kann nichts in der App mit den Zertifikaten und Smartcards im Speicher des Betriebssystems signieren oder entschlüsseln, und niemand wird gefragt. Das hat Vorrang vor der Liste der Websites unten. Es gilt auch, wenn Plugins ausgeschaltet sind. Importierte Schlüssel sind nicht betroffen.</string>
<string id="KeystoreAllowedOrigins">Websites, die den Zertifikatspeicher des Systems verwenden dürfen</string>
<string id="KeystoreAllowedOrigins_Explain">Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.</string>
<string id="BrowserSignIn">Anmeldung über den Browser anbieten</string>
+2
View File
@@ -54,6 +54,8 @@
<string id="AuthNegotiateDelegateAllowlist_Explain">The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts.</string>
<string id="AutoSelectCertificateForUrls">Choose client certificates automatically</string>
<string id="AutoSelectCertificateForUrls_Explain">One JSON rule per line, in the format Chrome uses: {&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}. The app then picks the matching certificate for that site without asking.</string>
<string id="KeystoreEnabled">Allow the system certificate store</string>
<string id="KeystoreEnabled_Explain">When disabled, nothing in the app can sign or decrypt with the certificates and smart cards in the operating system&apos;s store, and nobody is asked. This overrides the list of sites below. It also holds when plugins are turned off. Imported keys are not affected.</string>
<string id="KeystoreAllowedOrigins">Sites that may use the system certificate store</string>
<string id="KeystoreAllowedOrigins_Explain">The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system&apos;s store, without the user being asked for each site. One per line.</string>
<string id="BrowserSignIn">Offer sign-in through the browser</string>
@@ -57,6 +57,8 @@
<array>
<string>{&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}</string>
</array>
<key>KeystoreEnabled</key>
<true/>
<key>KeystoreAllowedOrigins</key>
<array>
<string>https://mail.example.com</string>
+1
View File
@@ -22,6 +22,7 @@
"AutoSelectCertificateForUrls": [
"{\"pattern\":\"https://[*.]example.com\",\"filter\":{\"ISSUER\":{\"CN\":\"Example CA\"}}}"
],
"KeystoreEnabled": true,
"KeystoreAllowedOrigins": [
"https://mail.example.com"
],
+10 -4
View File
@@ -20,10 +20,10 @@ function post(source: unknown, data: unknown): void {
}
interface KeystoreFacade {
list(): Promise<unknown>;
sign(id: string, digest: Uint8Array, scheme: unknown): Promise<Uint8Array>;
decrypt(id: string, data: Uint8Array, scheme: unknown): Promise<Uint8Array>;
agree(id: string, peer: Uint8Array): Promise<Uint8Array>;
list(options?: { quiet?: boolean }): Promise<unknown>;
sign(id: string, signedAttributes: Uint8Array, scheme: unknown): Promise<Uint8Array>;
decrypt(id: string, data: Uint8Array, scheme: unknown, options?: { quiet?: boolean }): Promise<Uint8Array>;
agree(id: string, peer: Uint8Array, options?: { quiet?: boolean }): Promise<Uint8Array>;
}
describe('api.keystore in the sandbox', () => {
@@ -104,6 +104,12 @@ describe('api.keystore in the sandbox', () => {
const signing = api.sign('the-id', digest, scheme);
const sent = requests().at(-1)!;
expect(sent.args).toEqual(['the-id', digest, scheme]);
void api.list({ quiet: true }).catch(() => {});
expect(requests().at(-1)!.args).toEqual([{ quiet: true }]);
void api.decrypt('the-id', new Uint8Array(4), { scheme: 'rsa-pkcs1' }, { quiet: true }).catch(() => {});
expect(requests().at(-1)!.args).toEqual(['the-id', new Uint8Array(4), { scheme: 'rsa-pkcs1' }, { quiet: true }]);
void api.agree('the-id', new Uint8Array(4), { quiet: true }).catch(() => {});
expect(requests().at(-1)!.args).toEqual(['the-id', new Uint8Array(4), { quiet: true }]);
post(hostWindow, { type: 'api-response', id: sent.id, ok: false, error: 'keystore:cancelled' });
await expect(signing).rejects.toThrow(/^keystore:cancelled$/);
+102 -22
View File
@@ -12,7 +12,9 @@ import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api';
import { hasDialogFor, holdForUser, whenNoDialogFor } from '../plugin-sandbox/host-dialog';
import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
import { keystoreFeature } from '../plugin-sandbox/host-api-keystore';
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
import { DEFAULT_POLICY_SNAPSHOT } from '../desktop/policy-schema';
import { declaredPermissions, HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
import { pluginHasPermission } from '../plugin-sandbox/permissions';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
@@ -28,6 +30,7 @@ function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
type: 'hook',
tier: 'privileged',
managed: true,
grantedPermissions: ['keystore'],
permissions: ['crypto:full', 'keystore'],
entrypoint: 'index.js',
enabled: true,
@@ -37,7 +40,7 @@ function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
};
}
const DIGEST = new Uint8Array(32).fill(7);
const ATTRIBUTES = new Uint8Array(200).fill(7);
const SIGN = { scheme: 'rsa-pkcs1', hash: 'SHA-256' };
function fakeBridge() {
@@ -66,7 +69,7 @@ describe('who may call it', () => {
const METHODS: Array<[string, unknown[]]> = [
['keystore.list', []],
['keystore.sign', ['id', DIGEST, SIGN]],
['keystore.sign', ['id', ATTRIBUTES, SIGN]],
['keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }]],
['keystore.agree', ['id', new Uint8Array(91)]],
];
@@ -91,9 +94,17 @@ describe('who may call it', () => {
await expect(dispatchApiCall(notGiven, 'keystore.list', [], { privileged: true })).rejects.toThrow(/lacks permission "keystore"/);
});
it('is refused for a plugin an admin installed until this user says yes too', async () => {
const adminsOnly = plugin({ managed: true, grantedPermissions: [] });
await expect(dispatchApiCall(adminsOnly, 'keystore.list', [], { privileged: true })).rejects.toThrow(/lacks permission "keystore"/);
expect(keystoreFeature(adminsOnly)).toEqual({});
expect(pluginHasPermission(adminsOnly, 'crypto:full'), 'its other permissions are the admin\'s call').toBe(true);
expect(pluginHasPermission(plugin({ managed: true }), 'keystore')).toBe(true);
});
it('passes for a privileged plugin holding it', async () => {
await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', DIGEST, SIGN], { privileged: true })).resolves.toEqual(new Uint8Array([1, 2, 3]));
expect(bridge.sign).toHaveBeenCalledWith('id', DIGEST, SIGN);
await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', ATTRIBUTES, SIGN], { privileged: true })).resolves.toEqual(new Uint8Array([1, 2, 3]));
expect(bridge.sign).toHaveBeenCalledWith('id', ATTRIBUTES, SIGN);
await expect(dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true })).resolves.toEqual([]);
await expect(dispatchApiCall(plugin(), 'keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }], { privileged: true })).resolves.toEqual(new Uint8Array([4, 5, 6]));
await expect(dispatchApiCall(plugin(), 'keystore.agree', ['id', new Uint8Array(91)], { privileged: true })).resolves.toEqual(new Uint8Array([7, 8, 9]));
@@ -122,17 +133,17 @@ describe('what gets through to the shell', () => {
const call = (method: string, ...args: unknown[]) => dispatchApiCall(plugin(), method, args, { privileged: true });
it.each<[string, unknown[]]>([
['an id that is no string', [42, DIGEST, SIGN]],
['an empty id', ['', DIGEST, SIGN]],
['a long id', ['x'.repeat(65), DIGEST, SIGN]],
['a digest that is no bytes', ['id', 'not bytes', SIGN]],
['an empty digest', ['id', new Uint8Array(0), SIGN]],
['a digest longer than any hash', ['id', new Uint8Array(65), SIGN]],
['no scheme', ['id', DIGEST, null]],
['a list as scheme', ['id', DIGEST, []]],
['an unknown scheme', ['id', DIGEST, { scheme: 'raw', hash: 'SHA-256' }]],
['an unknown hash', ['id', DIGEST, { scheme: 'rsa-pkcs1', hash: 'MD5' }]],
['no hash', ['id', DIGEST, { scheme: 'rsa-pkcs1' }]],
['an id that is no string', [42, ATTRIBUTES, SIGN]],
['an empty id', ['', ATTRIBUTES, SIGN]],
['a long id', ['x'.repeat(65), ATTRIBUTES, SIGN]],
['attributes that are no bytes', ['id', 'not bytes', SIGN]],
['empty attributes', ['id', new Uint8Array(0), SIGN]],
['attributes past what S/MIME could need', ['id', new Uint8Array(4097), SIGN]],
['no scheme', ['id', ATTRIBUTES, null]],
['a list as scheme', ['id', ATTRIBUTES, []]],
['an unknown scheme', ['id', ATTRIBUTES, { scheme: 'raw', hash: 'SHA-256' }]],
['an unknown hash', ['id', ATTRIBUTES, { scheme: 'rsa-pkcs1', hash: 'MD5' }]],
['no hash', ['id', ATTRIBUTES, { scheme: 'rsa-pkcs1' }]],
])('sign refuses %s', async (_name, args) => {
await expect(call('keystore.sign', ...args)).rejects.toThrow('keystore:invalid');
expect(bridge.sign).not.toHaveBeenCalled();
@@ -148,9 +159,9 @@ describe('what gets through to the shell', () => {
it('passes only the fields it knows', async () => {
const data = new Uint8Array(256);
await call('keystore.decrypt', 'id', data, { scheme: 'rsa-oaep', hash: 'SHA-1', extra: 'x', label: new Uint8Array(0) });
expect(bridge.decrypt).toHaveBeenCalledWith('id', data, { scheme: 'rsa-oaep', hash: 'SHA-1' });
await call('keystore.sign', 'id', DIGEST, { ...SIGN, extra: 'x' });
expect(bridge.sign).toHaveBeenCalledWith('id', DIGEST, SIGN);
expect(bridge.decrypt).toHaveBeenCalledWith('id', data, { scheme: 'rsa-oaep', hash: 'SHA-1' }, {});
await call('keystore.sign', 'id', ATTRIBUTES, { ...SIGN, extra: 'x' });
expect(bridge.sign).toHaveBeenCalledWith('id', ATTRIBUTES, SIGN);
});
it('agree refuses an oversized peer key', async () => {
@@ -160,12 +171,81 @@ describe('what gets through to the shell', () => {
it.each(['cancelled', 'not-found', 'unsupported', 'locked', 'failed'])('hands keystore:%s to the frame as the message', async (code) => {
bridge.sign.mockRejectedValueOnce(new Error(`Error invoking remote method: Error: keystore:${code}`));
await expect(call('keystore.sign', 'id', DIGEST, SIGN)).rejects.toThrow(new RegExp(`^keystore:${code}$`));
await expect(call('keystore.sign', 'id', ATTRIBUTES, SIGN)).rejects.toThrow(new RegExp(`^keystore:${code}$`));
});
it('turns anything else into keystore:failed, not a stack or a path', async () => {
bridge.sign.mockRejectedValueOnce(new Error('C:\\secret\\path blew up'));
await expect(call('keystore.sign', 'id', DIGEST, SIGN)).rejects.toThrow(/^keystore:failed$/);
await expect(call('keystore.sign', 'id', ATTRIBUTES, SIGN)).rejects.toThrow(/^keystore:failed$/);
});
});
describe('a call that must not ask', () => {
const call = (method: string, ...args: unknown[]) => dispatchApiCall(plugin(), method, args, { privileged: true });
let bridge: ReturnType<typeof fakeBridge>;
beforeEach(() => {
bridge = fakeBridge();
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, keystore: bridge };
});
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it('passes quiet to the shell, and only quiet', async () => {
await call('keystore.list', { quiet: true, other: 1 });
expect(bridge.list).toHaveBeenCalledWith({ quiet: true });
await call('keystore.decrypt', 'id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }, { quiet: true });
expect(bridge.decrypt).toHaveBeenCalledWith('id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }, { quiet: true });
await call('keystore.agree', 'id', new Uint8Array(91), { quiet: true });
expect(bridge.agree).toHaveBeenCalledWith('id', new Uint8Array(91), { quiet: true });
});
it('a call without it, or with something else, can ask', async () => {
await call('keystore.list');
expect(bridge.list).toHaveBeenLastCalledWith({});
await call('keystore.list', { quiet: 'yes' });
expect(bridge.list).toHaveBeenLastCalledWith({});
});
it('the shell\'s answer comes through as its code', async () => {
bridge.list.mockRejectedValueOnce(new Error('keystore:ask-first'));
await expect(call('keystore.list', { quiet: true })).rejects.toThrow(/^keystore:ask-first$/);
});
});
describe('policy', () => {
const withPolicy = (values: Record<string, { value: unknown }>) => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = {
version: 1,
keystore: fakeBridge(),
policy: { get: () => ({ ...DEFAULT_POLICY_SNAPSHOT, ...values }) },
};
};
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it.each([
['KeystoreEnabled off', { KeystoreEnabled: { value: false } }],
['PluginsMode off', { PluginsMode: { value: 'off' } }],
])('%s takes the flag away and refuses the call', async (_name, values) => {
withPolicy(values);
expect(keystoreFeature(plugin())).toEqual({});
await expect(dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true })).rejects.toThrow('keystore:unsupported');
});
it('leaves it alone otherwise', () => {
withPolicy({});
expect(keystoreFeature(plugin())).toEqual({ keystore: true });
});
});
describe('what a manifest declares', () => {
it('counts optional permissions, as the install does', () => {
expect(declaredPermissions({ tier: 'privileged', permissions: ['crypto:full', 'email:read'], optionalPermissions: ['keystore', 'email:read'] }))
.toEqual(['crypto:full', 'email:read', 'keystore']);
});
it('leaves out what an untrusted plugin can not get, what this version does not know, and junk', () => {
expect(declaredPermissions({ permissions: ['email:read'], optionalPermissions: ['keystore', 'from:the-future', 7] })).toEqual(['email:read']);
expect(declaredPermissions(null)).toEqual([]);
expect(declaredPermissions({ permissions: 'email:read' })).toEqual([]);
});
});
@@ -285,7 +365,7 @@ describe('a keystore call and the hook timer', () => {
const bridge = fakeBridge();
bridge.sign.mockRejectedValueOnce(new Error('keystore:cancelled'));
installBridge(bridge);
await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', DIGEST, SIGN], { privileged: true })).rejects.toThrow('keystore:cancelled');
await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', ATTRIBUTES, SIGN], { privileged: true })).rejects.toThrow('keystore:cancelled');
expect(hasDialogFor(ID)).toBe(false);
await expect(dispatchApiCall(plugin(), 'keystore.sign', [42], { privileged: true })).rejects.toThrow('keystore:invalid');
+127 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
// We test the raw store by directly invoking Zustand
@@ -64,6 +64,7 @@ import { usePluginStore, PLUGIN_PERSIST_DELAY_MS } from '@/stores/plugin-store';
import { pluginStorage } from '@/lib/plugin-storage';
import { deactivatePlugin, forgetSlotOffers } from '@/lib/plugin-loader';
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { head as consentHead, resolveHead as resolveConsent, subscribe as subscribeConsent } from '@/lib/plugin-sandbox/consent';
function resetStore() {
usePluginStore.setState({
@@ -561,3 +562,128 @@ describe('saving the plugin list', () => {
}
});
});
describe('a plugin an admin installed, and the certificates on this computer', () => {
const serverSmime = {
id: 'smime', name: 'S/MIME', version: '1.5.0', author: 'a', description: '', type: 'hook', tier: 'privileged',
permissions: ['crypto:full', 'keystore'], entrypoint: 'index.js', forceEnabled: true, bundleHash: 'h',
};
const local = (overrides: Partial<InstalledPlugin> = {}) => mockPlugin({
id: 'smime', name: 'S/MIME', version: '1.5.0', tier: 'privileged', managed: true, enabled: true, status: 'enabled', adminApproved: true, bundleHash: 'h',
permissions: ['crypto:full', 'keystore'], ...overrides,
});
const answerConsent = (answer: boolean) => {
const seen: string[][] = [];
const stop = subscribeConsent(() => {
const request = consentHead();
if (!request) return;
seen.push(request.permissions);
queueMicrotask(() => resolveConsent(answer));
});
return { seen, stop };
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
beforeEach(() => {
localStorage.clear();
resetStore();
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, keystore: {} };
vi.mocked(pluginStorage.codeIds).mockResolvedValue(['smime']);
mocks.apiFetch.mockImplementation(async (url: string) => (String(url).startsWith('/api/plugins')
? new Response(JSON.stringify({ plugins: [serverSmime], themes: [] }))
: new Response(JSON.stringify({}))));
});
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it('asks this person, for that alone, and keeps a yes', async () => {
usePluginStore.setState({ plugins: [local()] });
const { seen, stop } = answerConsent(true);
await usePluginStore.getState().initializePlugins();
await settle();
stop();
expect(seen).toEqual([['keystore']]);
expect(usePluginStore.getState().plugins[0].grantedPermissions).toEqual(['keystore']);
});
it('asks when an update adds it to a plugin that had none', async () => {
usePluginStore.setState({ plugins: [local({ permissions: ['crypto:full'], version: '1.4.0' })] });
vi.mocked(mocks.downloadManagedBundle).mockResolvedValue('code');
const { seen, stop } = answerConsent(true);
await usePluginStore.getState().initializePlugins();
await settle();
stop();
expect(seen).toEqual([['keystore']]);
});
it('runs it either way, and a no is not asked again', async () => {
const { loadPlugin } = await import('@/lib/plugin-loader');
vi.mocked(loadPlugin).mockClear();
usePluginStore.setState({ plugins: [local()] });
const first = answerConsent(false);
await usePluginStore.getState().initializePlugins();
await settle();
first.stop();
expect(first.seen).toHaveLength(1);
expect(loadPlugin).toHaveBeenCalledTimes(1);
expect(usePluginStore.getState().plugins[0].grantedPermissions ?? []).not.toContain('keystore');
usePluginStore.setState({ initialized: false });
const second = answerConsent(true);
await usePluginStore.getState().initializePlugins();
await settle();
second.stop();
expect(second.seen).toEqual([]);
});
it('starts the plugin again after a yes, so it sees what it was given', async () => {
const { loadPlugin } = await import('@/lib/plugin-loader');
vi.mocked(loadPlugin).mockClear();
vi.mocked(deactivatePlugin).mockClear();
usePluginStore.setState({ plugins: [local()] });
const { stop } = answerConsent(true);
await usePluginStore.getState().initializePlugins();
await settle();
stop();
expect(deactivatePlugin).toHaveBeenCalledWith('smime');
expect(vi.mocked(loadPlugin).mock.lastCall?.[0]).toMatchObject({ grantedPermissions: ['keystore'] });
});
it('asks nothing in a browser, where there is no such thing', async () => {
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
usePluginStore.setState({ plugins: [local()] });
const { seen, stop } = answerConsent(true);
await usePluginStore.getState().initializePlugins();
await settle();
stop();
expect(seen).toEqual([]);
});
it('asks nothing for a plugin that does not want it', async () => {
mocks.apiFetch.mockImplementation(async (url: string) => (String(url).startsWith('/api/plugins')
? new Response(JSON.stringify({ plugins: [{ ...serverSmime, permissions: ['crypto:full'] }], themes: [] }))
: new Response(JSON.stringify({}))));
usePluginStore.setState({ plugins: [local({ permissions: ['crypto:full'] })] });
const { seen, stop } = answerConsent(true);
await usePluginStore.getState().initializePlugins();
await settle();
stop();
expect(seen).toEqual([]);
});
it('asks when someone turns it on themselves, and its other permissions stay the admin\'s call', async () => {
usePluginStore.setState({ plugins: [local({ enabled: false, status: 'installed', forceEnabled: false })] });
const { seen, stop } = answerConsent(true);
await usePluginStore.getState().enablePlugin('smime');
stop();
expect(seen).toEqual([['keystore']]);
expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: true, grantedPermissions: ['keystore'] });
});
it('forgets the answer once everyone has signed out: the next person gets asked', async () => {
usePluginStore.setState({ plugins: [local({ grantedPermissions: ['keystore'], declinedPermissions: ['keystore'] })] });
await purgeSignedOutData();
const [plugin] = usePluginStore.getState().plugins;
expect(plugin.grantedPermissions ?? []).toEqual([]);
expect(plugin.declinedPermissions ?? []).toEqual([]);
expect(plugin.managed).toBe(true);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@ describe('the schema', () => {
'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate',
'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed',
'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist',
'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData',
'AutoSelectCertificateForUrls', 'KeystoreEnabled', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData',
]);
for (const key of POLICY_KEYS) {
const { title, description } = POLICY_SCHEMA[key];
+15
View File
@@ -75,6 +75,7 @@ export interface PolicyValues {
AuthServerAllowlist: string | null;
AuthNegotiateDelegateAllowlist: string | null;
AutoSelectCertificateForUrls: CertificateRule[];
KeystoreEnabled: boolean;
KeystoreAllowedOrigins: string[];
BrowserSignIn: boolean;
WipeOfflineData: string | null;
@@ -371,6 +372,19 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
de: 'Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.',
},
},
KeystoreEnabled: {
category: 'signIn',
forcedOnly: true,
type: 'boolean',
default: true,
onInvalid: false,
sample: true,
title: { en: 'Allow the system certificate store', de: 'Zertifikatspeicher des Systems erlauben' },
description: {
en: 'When disabled, nothing in the app can sign or decrypt with the certificates and smart cards in the operating system\'s store, and nobody is asked. This overrides the list of sites below. It also holds when plugins are turned off. Imported keys are not affected.',
de: 'Wenn deaktiviert, kann nichts in der App mit den Zertifikaten und Smartcards im Speicher des Betriebssystems signieren oder entschlüsseln, und niemand wird gefragt. Das hat Vorrang vor der Liste der Websites unten. Es gilt auch, wenn Plugins ausgeschaltet sind. Importierte Schlüssel sind nicht betroffen.',
},
},
KeystoreAllowedOrigins: {
category: 'signIn',
forcedOnly: true,
@@ -840,6 +854,7 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' },
QuickComposeShortcut: { repo: 'shell', file: 'src/main/features/os-quick-compose.ts' },
AutoSelectCertificateForUrls: { repo: 'shell', file: 'src/main/features/client-certificates.ts' },
KeystoreEnabled: { repo: 'shell', file: 'src/main/features/policies.ts' },
KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' },
BrowserSignIn: { repo: 'shell', file: 'src/main/features/browser-sign-in.ts' },
};
+6
View File
@@ -55,6 +55,12 @@ export function useDesktopPolicy(): PolicySnapshot {
return useSyncExternalStore(onDesktopPolicyChange, getDesktopPolicy, serverSnapshot);
}
/** Policy has the OS certificate store off: by its own switch, or with all plugins. */
export function keystoreOffByDesktopPolicy(): boolean {
const policy = getDesktopPolicy();
return !policy.KeystoreEnabled.value || policy.PluginsMode.value === 'off';
}
export function desktopPolicyEntry<K extends PolicyKey>(key: K): PolicyEntry<K> {
return getDesktopPolicy()[key];
}
+19 -10
View File
@@ -331,23 +331,32 @@ export type DesktopKeystoreDecryptScheme =
/** MGF1 on the same hash. */
| { scheme: 'rsa-oaep'; hash: DesktopKeystoreHash; label?: Uint8Array };
/** `quiet`: answer from what's already allowed, with keystore:ask-first in place of a dialog. */
export interface DesktopKeystoreUseOptions {
quiet?: boolean;
}
/**
* 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.
* loaded, the OS has one and policy allows it. Failures reject with a bare code as the
* message: keystore:cancelled, ask-first, 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>;
list: (options?: DesktopKeystoreUseOptions) => Promise<DesktopKeystoreCertificate[]>;
/**
* Signs a CMS message's signed attributes, given as their DER (a SET): content type,
* message digest and signing time at least. The shell checks them and hashes them itself,
* and asks at every call. RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value.
*/
sign: (id: string, signedAttributes: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise<Uint8Array>;
/** Unwraps a CMS content key. */
decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise<Uint8Array>;
decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme, options?: DesktopKeystoreUseOptions) => Promise<Uint8Array>;
/** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */
agree: (id: string, peerPublicKey: Uint8Array) => Promise<Uint8Array>;
agree: (id: string, peerPublicKey: Uint8Array, options?: DesktopKeystoreUseOptions) => Promise<Uint8Array>;
accessState: () => Promise<DesktopKeystoreAccessState>;
/** Takes back a stored yes and this session's grants. Only ever narrows. */
revokeAccess: () => Promise<void>;
+23 -11
View File
@@ -8,17 +8,19 @@
// 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.
// keystore:cancelled, keystore:ask-first, 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 type { DesktopKeystoreBridge, DesktopKeystoreCertificate, DesktopKeystoreUseOptions } from '../desktop/types';
import { keystoreOffByDesktopPolicy } from '../desktop/policy';
import { holdForUser } from './host-dialog';
import { pluginHasPermission } from './permissions';
import { resolvePluginTier } from './tier';
const MAX_ID = 64;
const MAX_DIGEST = 64;
// The DER of a CMS message's signed attributes. The shell checks what they are.
const MAX_SIGNED_ATTRIBUTES = 4096;
// A 16384-bit modulus, the most anything issues.
const MAX_CIPHERTEXT = 2048;
const MAX_LABEL = 256;
@@ -38,13 +40,18 @@ export const KEYSTORE_PERMS = {
export const KEYSTORE_METHODS: readonly string[] = Object.keys(KEYSTORE_PERMS);
function desktopKeystore(): DesktopKeystoreBridge | undefined {
return typeof window === 'undefined' ? undefined : window.bulwarkDesktop?.keystore;
if (typeof window === 'undefined' || keystoreOffByDesktopPolicy()) return undefined;
return window.bulwarkDesktop?.keystore;
}
/** Whether this plugin could get at it at all, permission aside: the desktop app, policy, the tier. */
export function keystoreCouldReach(plugin: InstalledPlugin): boolean {
return !!desktopKeystore() && resolvePluginTier(plugin).tier === 'privileged';
}
/** 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 {};
if (!plugin || !keystoreCouldReach(plugin) || !pluginHasPermission(plugin, 'keystore')) return {};
return { keystore: true };
}
@@ -89,6 +96,11 @@ function checkedDecryptScheme(value: unknown): { scheme: string; hash?: string;
return { scheme: scheme.scheme, hash: readHash(scheme.hash), ...(label ? { label } : {}) };
}
/** Only `quiet`, and only when it's true: anything else may ask. */
function readOptions(value: unknown): DesktopKeystoreUseOptions {
return (value as { quiet?: unknown } | null | undefined)?.quiet === true ? { quiet: true } : {};
}
/** 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];
@@ -103,13 +115,13 @@ export async function doKeystoreCall(plugin: InstalledPlugin, method: string, ar
const run = (): Promise<DesktopKeystoreCertificate[] | Uint8Array> => {
switch (method) {
case 'keystore.list':
return keystore.list();
return keystore.list(readOptions(args[0]));
case 'keystore.sign':
return keystore.sign(readId(args[0]), readBytes(args[1], MAX_DIGEST), checkedSignScheme(args[2]) as never);
return keystore.sign(readId(args[0]), readBytes(args[1], MAX_SIGNED_ATTRIBUTES), checkedSignScheme(args[2]) as never);
case 'keystore.decrypt':
return keystore.decrypt(readId(args[0]), readBytes(args[1], MAX_CIPHERTEXT), checkedDecryptScheme(args[2]) as never);
return keystore.decrypt(readId(args[0]), readBytes(args[1], MAX_CIPHERTEXT), checkedDecryptScheme(args[2]) as never, readOptions(args[3]));
case 'keystore.agree':
return keystore.agree(readId(args[0]), readBytes(args[1], MAX_PEER_KEY));
return keystore.agree(readId(args[0]), readBytes(args[1], MAX_PEER_KEY), readOptions(args[2]));
default:
throw new Error('keystore:unsupported');
}
+14
View File
@@ -40,6 +40,20 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = new Set([
'settings:change',
]);
/**
* What a manifest asks for, as an install grants it (checkPluginManifest): optional
* permissions count, minus what this version doesn't offer and what the tier can't have.
* Everything that shows permissions from a manifest goes through this.
*/
export function declaredPermissions(manifest: unknown): string[] {
const m = (manifest ?? {}) as { permissions?: unknown; optionalPermissions?: unknown; tier?: unknown };
const strings = (value: unknown) => (Array.isArray(value) ? value.filter((p): p is string => typeof p === 'string') : []);
const known = new Set<string>(ALL_PERMISSIONS);
return [...new Set([...strings(m.permissions), ...strings(m.optionalPermissions)])]
.filter((p) => known.has(p))
.filter((p) => p !== 'keystore' || m.tier === 'privileged');
}
/** Locale key segment for a permission: `email:read` → `email_read`. */
export function permissionKey(perm: string): string {
return perm.replace(/[:-]/g, '_');
+8 -1
View File
@@ -3,6 +3,13 @@ import { IMPLICIT_PERMISSIONS } from '../plugin-types';
import { hookSpec } from '../plugin-hook-catalog';
import { slotSpec } from '../plugin-slot-catalog';
/**
* What an admin's install doesn't settle. These reach things on the user's own computer that
* the admin has no say over, so the user is asked even for a plugin the admin put there, and
* also when an update adds one to it.
*/
export const ALWAYS_ASK_USER: ReadonlySet<string> = new Set(['keystore']);
/**
* Whether `plugin` may use `perm`: the manifest must declare it, and an
* admin must have installed the plugin (managed) or the user must have
@@ -14,7 +21,7 @@ export function pluginHasPermission(plugin: InstalledPlugin, perm: Permission):
// Defense-in-depth: even if the manifest declares a permission, the host
// refuses it unless an admin has marked the plugin as managed, or the user
// has explicitly granted it via the consent dialog.
if (plugin.managed) return true;
if (plugin.managed && !ALWAYS_ASK_USER.has(perm)) return true;
return (plugin.grantedPermissions ?? []).includes(perm);
}
+13 -7
View File
@@ -512,18 +512,24 @@ function buildCoreApi(manifest: PluginManifest) {
* with the `keystore` permission, when api.host.features.keystore is true. The
* shell asks the user first, so any call can answer keystore:cancelled. Key
* operations have no timeout here: the OS may be showing a PIN prompt, and the shell
* gives up by itself. Errors are bare codes: keystore:cancelled, not-found,
* unsupported, locked, invalid, failed.
* gives up by itself. Errors are bare codes: keystore:cancelled, ask-first, not-found,
* unsupported, locked, invalid, failed. `{ quiet: true }` never opens a dialog: it
* answers from what's already allowed, or with ask-first. Use it for anything the user
* didn't just ask for.
*/
keystore: {
/** Certificates fit for mail that have a private key. The OS doesn't prompt, and it has the usual timeout. */
list: () => callApi('keystore.list', []),
/** Signs a digest. scheme: { scheme: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa', hash }. RSA gives the raw signature, ECDSA DER. */
sign: (id: string, digest: Uint8Array, scheme: unknown) => callApi('keystore.sign', [id, digest, scheme], 0) as Promise<Uint8Array>,
list: (options?: { quiet?: boolean }) => callApi('keystore.list', [options]),
/**
* Signs a CMS message's signed attributes, given as their DER (a SET): content type,
* message digest and signing time at least. The shell hashes them, and asks every time.
* scheme: { scheme: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa', hash }. RSA gives the raw signature, ECDSA DER.
*/
sign: (id: string, signedAttributes: Uint8Array, scheme: unknown) => callApi('keystore.sign', [id, signedAttributes, scheme], 0) as Promise<Uint8Array>,
/** Unwraps a content key. scheme: { scheme: 'rsa-pkcs1' } or { scheme: 'rsa-oaep', hash, label? }. */
decrypt: (id: string, data: Uint8Array, scheme: unknown) => callApi('keystore.decrypt', [id, data, scheme], 0) as Promise<Uint8Array>,
decrypt: (id: string, data: Uint8Array, scheme: unknown, options?: { quiet?: boolean }) => callApi('keystore.decrypt', [id, data, scheme, options], 0) as Promise<Uint8Array>,
/** ECDH: the raw shared secret, big-endian, from the peer's SubjectPublicKeyInfo. */
agree: (id: string, peerPublicKey: Uint8Array) => callApi('keystore.agree', [id, peerPublicKey], 0) as Promise<Uint8Array>,
agree: (id: string, peerPublicKey: Uint8Array, options?: { quiet?: boolean }) => callApi('keystore.agree', [id, peerPublicKey, options], 0) as Promise<Uint8Array>,
},
/** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */
app: {
+2
View File
@@ -283,6 +283,8 @@ export interface InstalledPlugin {
* skip the consent prompt (admin pre-approval).
*/
grantedPermissions?: string[];
/** Asked for a plugin an admin installed (ALWAYS_ASK_USER), and the user said no: not asked again. */
declinedPermissions?: string[];
/**
* For plugins the user installed: the validated manifest, kept so the
* package can be rebuilt for an approval request, and the hash an admin
@@ -158,6 +158,17 @@ describe('sign-out with plugins', () => {
expect(clearGrants).toHaveBeenCalledTimes(1);
});
it('and when a session ends with no sign-out at all', () => {
const clearGrants = vi.fn().mockResolvedValue(undefined);
withBridge(clearGrants);
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
expect(clearGrants).not.toHaveBeenCalled();
useAuthStore.setState({ client: null, isAuthenticated: false, activeAccountId: null });
expect(clearGrants).toHaveBeenCalledTimes(1);
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' });
expect(clearGrants, 'nothing to take back when one starts').toHaveBeenCalledTimes(1);
});
it('signs out all the same on a shell without the call, or one that fails', async () => {
withBridge(undefined);
await useAuthStore.getState().logout();
+3 -1
View File
@@ -4009,8 +4009,10 @@ useAuthStore.subscribe((state, prev) => {
const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId;
if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId });
if (state.activeAccountId !== prev.activeAccountId) noteDesktopCertificateAccount(state.activeAccountId);
// Whenever the account in use goes, not only at a sign-out: an expired or refused session
// ends with no account, and the next person at this computer has to be asked.
if (prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) clearKeystoreGrants();
if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) {
clearKeystoreGrants();
clearDesktopCrashFiles();
void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId });
}
+40 -3
View File
@@ -14,6 +14,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
import { requestConsent } from '@/lib/plugin-sandbox/consent';
import { keystoreCouldReach } from '@/lib/plugin-sandbox/host-api-keystore';
import { packageHash, type ValidatedPluginManifest } from '@/lib/extension-package';
import JSZip from 'jszip';
import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity';
@@ -249,7 +250,9 @@ export const usePluginStore = create<PluginStoreState>()(
// Per-user consent gate: prompt for any permission the user has not
// explicitly approved yet. Managed plugins (admin-pushed) skip this -
// the admin has already approved them at install time.
// the admin has already approved them at install time - but for what
// an admin can't approve for someone else (askManagedKeystore).
await askManagedKeystore(plugin, set);
const implicit = new Set<string>(IMPLICIT_PERMISSIONS);
const granted = new Set<string>(plugin.grantedPermissions ?? []);
const missing = (plugin.permissions ?? [])
@@ -401,6 +404,17 @@ export const usePluginStore = create<PluginStoreState>()(
await Promise.allSettled(enabledPlugins.filter((_, i) => allowed[i]).map(plugin => loadPlugin(plugin)));
set({ initialized: true });
// After the start, which doesn't wait for anyone to answer. A plugin runs without
// the keystore until then, and starts again to see what it was given.
for (const plugin of get().plugins.filter(p => p.managed && p.enabled && p.status !== 'error')) {
void askManagedKeystore(plugin, set).then((granted) => {
const latest = get().plugins.find(p => p.id === plugin.id);
if (!granted || !latest?.enabled) return;
deactivatePlugin(plugin.id);
return loadPlugin(latest);
}).catch(() => {});
}
})();
try {
@@ -461,6 +475,25 @@ function removePluginData(id: string): void {
}
/** Give the loader its way back into the store. */
/**
* An admin's install doesn't cover the certificates on this person's computer, so they're
* asked for that alone, also when an update adds it. Runs with or without it, as with any
* optional permission. Says whether this added a grant.
*/
async function askManagedKeystore(
plugin: InstalledPlugin,
set: (partial: Partial<PluginStoreState> | ((state: PluginStoreState) => Partial<PluginStoreState>)) => void,
): Promise<boolean> {
if (!plugin.managed || !plugin.permissions?.includes('keystore') || !keystoreCouldReach(plugin)) return false;
if (plugin.grantedPermissions?.includes('keystore') || plugin.declinedPermissions?.includes('keystore')) return false;
const accepted = await requestConsent(plugin.id, plugin.name, ['keystore' as Permission]);
const field = accepted ? 'grantedPermissions' : 'declinedPermissions';
set(state => ({
plugins: state.plugins.map(p => (p.id === plugin.id ? { ...p, [field]: [...new Set([...(p[field] ?? []), 'keystore'])] } : p)),
}));
return accepted;
}
function wireLoader(
get: () => PluginStoreState,
set: (partial: Partial<PluginStoreState> | ((state: PluginStoreState) => Partial<PluginStoreState>)) => void,
@@ -870,7 +903,8 @@ usePluginStore.subscribe((state, prev) => {
// A plugin the user installed runs on that user's consent and settings. Once
// everyone has signed out, the next person at this browser must be asked
// again: the code stays, the grants and settings go, and it is left off.
// The admin's plugins are the admin's choice and stay as they are.
// The admin's plugins are the admin's choice and stay as they are, but for
// what that person answered about their own certificates.
onFullSignOut(() => {
const { plugins } = usePluginStore.getState();
for (const plugin of plugins) {
@@ -878,7 +912,10 @@ onFullSignOut(() => {
}
usePluginStore.setState({
plugins: plugins.map((plugin) => {
if (plugin.managed) return plugin;
if (plugin.managed) {
const { grantedPermissions: _kept, declinedPermissions: _declined, ...kept } = plugin;
return kept;
}
const { grantedPermissions: _granted, ...rest } = plugin;
return { ...rest, enabled: false, status: 'disabled' as PluginStatus, error: undefined, settings: {} };
}),