diff --git a/.dockerignore b/.dockerignore index 509fe74a..1bca7aa2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,10 @@ node_modules !.env.example !.env.dev.example scripts/ +# ...except the first-party plugin builder, which the image build runs +# (see Dockerfile). Without this the whole scripts/ dir is absent from the +# build context and the RUN step fails with "Cannot find module". +!scripts/build-plugins.mjs TODO.md *.md !README.md diff --git a/.gitignore b/.gitignore index c7f7379f..b43db4a6 100644 --- a/.gitignore +++ b/.gitignore @@ -62,10 +62,14 @@ next-env.d.ts # k8s deploy secrets (create from the matching overlay's secret.example.yaml) /deploy/k8s/overlays/*/secret.yaml -# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build) -vnc/plugins/smime/node_modules/ -vnc/plugins/smime/dist/ +# First-party plugin build output (rebuild with: npm run build:plugins). +# vnc/plugins/build/ is the staging dir the server installs from at startup +# (see lib/admin/bundled-plugins.ts) - built, never committed. +vnc/plugins/build/ +vnc/plugins/*/node_modules/ +vnc/plugins/*/dist/ vnc/plugins/smime/smime-vnc.zip +vnc/plugins/smime/smime.zip # macOS .DS_Store diff --git a/Dockerfile b/Dockerfile index cd12db32..c5bfa163 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,12 @@ ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE # `git rev-parse` inside the build can't find it - CI must pass it in. ARG GIT_COMMIT=unknown ENV GIT_COMMIT=$GIT_COMMIT +# Build the first-party plugins (vnc/plugins/*) that ship with this fork - +# currently the audited S/MIME plugin, which the server installs into its +# plugin registry at startup (lib/admin/bundled-plugins.ts). Each plugin has +# its own package.json + lockfile, so this does its own npm ci. +# Runs BEFORE next build so a broken plugin fails the image build. +RUN node scripts/build-plugins.mjs RUN npx next build --webpack FROM node:24-alpine AS runner @@ -43,6 +49,10 @@ RUN apk upgrade --no-cache && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Staged first-party plugin bundles. Read by path at runtime, so Next's output +# file tracing does not carry them into .next/standalone - copy explicitly or +# the image boots with the S/MIME policy toggle on and no plugin installed. +COPY --from=builder --chown=nextjs:nodejs /app/vnc/plugins/build ./vnc/plugins/build RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data USER nextjs EXPOSE 3000 diff --git a/eslint.config.mjs b/eslint.config.mjs index 073bb7e9..9f079d42 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -100,7 +100,10 @@ export default [ // above. Pre-existing gap: this was blocking `npm run lint` (and thus // the pre-commit hook) repo-wide before this Electron work even // touched anything - see the electron-desktop branch's first commits. - "vnc/plugins/smime/**", + // Independent sub-packages, plus the generated staging dir + // vnc/plugins/build/** that scripts/build-plugins.mjs writes the bundled + // artifacts into (1.7 MB of vendored crypto - not our source to lint). + "vnc/plugins/**", ], }, ]; diff --git a/instrumentation.node.ts b/instrumentation.node.ts index b98c2f9f..298b49f0 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -43,6 +43,16 @@ migrateLegacyAdminLayout() } } }) + .then(async () => { + // Install the first-party plugins this fork ships with (currently the + // audited S/MIME plugin) into the server plugin registry - the same admin + // channel an operator-uploaded ZIP lands in, so bundles still get + // Ed25519-signed on serve and the privileged-tier gates still apply. + // Staged by scripts/build-plugins.mjs; gated by the matching policy + // feature toggle. Never throws. + const { seedBundledPlugins } = await import("./lib/admin/bundled-plugins"); + await seedBundledPlugins(); + }) .then(async () => { // Anonymous telemetry - on by default. Admins can disable via the // admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint. diff --git a/lib/admin/bundled-plugins.ts b/lib/admin/bundled-plugins.ts new file mode 100644 index 00000000..9b57eb9f --- /dev/null +++ b/lib/admin/bundled-plugins.ts @@ -0,0 +1,300 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; +import { ALL_PERMISSIONS, MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; +import { auditLog } from './audit'; +import { configManager } from './config-manager'; +import { isConfigReadOnly } from './paths'; +import { + getPluginRegistry, + savePlugin, + updatePluginMeta, + type ServerPlugin, +} from './plugin-registry'; +import type { FeatureGates } from './types'; + +/** + * First-party ("bundled") plugin installation. + * + * The plugin registry (`/plugins/`) is the host's admin channel: + * bundles served out of it are Ed25519-signed on the way out by + * `app/api/admin/plugins/[id]/bundle`, and `/api/plugins` is what makes a + * plugin `managed` on the client - which is what `resolvePluginTier` requires + * before it will grant the privileged (same-origin) tier. + * + * This module installs the plugins this fork ships with THROUGH that same + * channel, so nothing about the signing / approval / consent chain is + * bypassed or relaxed: the operator's own server performs the install that an + * operator would otherwise perform by uploading the ZIP in /admin. + * + * Input is the staging directory produced by `scripts/build-plugins.mjs`: + * + * vnc/plugins/build//manifest.json + * vnc/plugins/build// + * + * Nothing here is trusted blindly - the manifest is validated the same way the + * admin upload route validates one, and an unknown permission or a bad id is a + * refusal, not a warning. + */ + +/** Where the staged bundles live, relative to cwd (override for odd layouts). */ +function getBundledPluginsDir(): string { + return ( + process.env.BUNDLED_PLUGINS_DIR || + path.join(process.cwd(), 'vnc', 'plugins', 'build') + ); +} + +interface FirstPartyPlugin { + id: string; + /** + * Feature gate that decides whether this plugin is installed and served. + * Turning the gate off in the admin policy disables the plugin (and stops + * it being re-installed on the next boot) - that, not the Delete button, is + * the way to remove a bundled plugin, since a delete would be undone by the + * next restart. + */ + gate: keyof FeatureGates; + /** + * Force-enable for every user. Required for a bundled plugin to be reachable + * at all under the default policy: `pluginsEnabled` defaults to false, which + * hides the user-facing Settings > Plugins tab, so there would be no way for + * a user to switch it on by hand. + */ + forceEnable: boolean; +} + +const FIRST_PARTY_PLUGINS: FirstPartyPlugin[] = [ + // The audited S/MIME implementation (vnc/plugins/smime). It IS the S/MIME + // feature - the former in-host native pipeline is gone - so the long-standing + // `smimeEnabled` policy gate now controls this plugin. + { id: 'smime', gate: 'smimeEnabled', forceEnable: true }, +]; + +const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; +const VALID_TYPES = new Set(['ui-extension', 'sidebar-app', 'hook']); + +function asString(v: unknown, fallback = ''): string { + return typeof v === 'string' ? v : fallback; +} + +/** + * Validate a staged manifest and turn it into a registry entry. Returns a list + * of errors instead of throwing so one bad bundle can't take down startup. + */ +function toServerPlugin( + manifest: Record, + code: string, + opts: { enabled: boolean; forceEnabled: boolean; installedAt: string }, +): { plugin: ServerPlugin } | { errors: string[] } { + const errors: string[] = []; + const id = asString(manifest.id); + if (!ID_RE.test(id)) errors.push(`invalid id ${JSON.stringify(manifest.id)}`); + if (!asString(manifest.name)) errors.push('missing "name"'); + if (!asString(manifest.version)) errors.push('missing "version"'); + if (!asString(manifest.author)) errors.push('missing "author"'); + const entrypoint = asString(manifest.entrypoint); + if (!entrypoint) errors.push('missing "entrypoint"'); + if (!VALID_TYPES.has(asString(manifest.type))) { + errors.push(`invalid type ${JSON.stringify(manifest.type)}`); + } + + const permissions = Array.isArray(manifest.permissions) + ? manifest.permissions.filter((p): p is string => typeof p === 'string') + : []; + const known = new Set(ALL_PERMISSIONS as readonly string[]); + const unknownPerms = permissions.filter(p => !known.has(p)); + if (unknownPerms.length > 0) { + errors.push(`unknown permissions: ${unknownPerms.join(', ')}`); + } + + const size = Buffer.byteLength(code, 'utf-8'); + if (size > MAX_PLUGIN_SIZE) { + errors.push(`bundle is ${size} bytes, over the ${MAX_PLUGIN_SIZE} byte limit`); + } + + if (errors.length > 0) return { errors }; + + return { + plugin: { + id, + name: asString(manifest.name), + version: asString(manifest.version), + author: asString(manifest.author), + description: asString(manifest.description), + type: asString(manifest.type), + // Only 'privileged' is meaningful; anything else falls through to the + // default untrusted tier. Same narrowing the admin upload route applies. + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), + permissions, + entrypoint, + enabled: opts.enabled, + forceEnabled: opts.forceEnabled, + ...(manifest.configSchema && typeof manifest.configSchema === 'object' + ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } + : {}), + ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' + ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } + : {}), + ...(manifest.locales && typeof manifest.locales === 'object' + ? { locales: manifest.locales as ServerPlugin['locales'] } + : {}), + installedAt: opts.installedAt, + updatedAt: opts.installedAt, + }, + }; +} + +async function readStaged(dir: string, id: string): Promise< + { manifest: Record; code: string } | null +> { + const manifestPath = path.join(dir, id, 'manifest.json'); + if (!existsSync(manifestPath)) return null; + + let manifest: Record; + try { + const parsed = JSON.parse(await readFile(manifestPath, 'utf-8')); + if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object'); + manifest = parsed as Record; + } catch (err) { + logger.error(`[bundled-plugins] ${id}: unreadable manifest`, { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + const entrypoint = asString(manifest.entrypoint, 'index.js'); + if (entrypoint.includes('/') || entrypoint.includes('\\')) { + logger.error(`[bundled-plugins] ${id}: entrypoint must be a bare filename`); + return null; + } + const codePath = path.join(dir, id, entrypoint); + try { + return { manifest, code: await readFile(codePath, 'utf-8') }; + } catch (err) { + logger.error(`[bundled-plugins] ${id}: cannot read bundle ${entrypoint}`, { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Install / update / disable the bundled first-party plugins. Idempotent: a + * boot where nothing changed writes nothing. + * + * Never throws - a failure here must not stop the server from starting, it + * just means the plugin isn't there (and says so in the log). + */ +export async function seedBundledPlugins(): Promise { + try { + const dir = getBundledPluginsDir(); + const staged = existsSync(dir); + + await configManager.ensureLoaded(); + const features = configManager.getPolicy().features; + const registry = await getPluginRegistry(); + + for (const spec of FIRST_PARTY_PLUGINS) { + const gateOn = features[spec.gate] !== false; + const existing = registry.plugins.find(p => p.id === spec.id); + + if (!gateOn) { + // Policy says off. Stop serving it (the client's own sync then treats + // it as removed and cleans it up) but leave the bundle on disk so + // flipping the gate back on is instant. + if (existing && (existing.enabled || existing.forceEnabled)) { + if (isConfigReadOnly()) { + logger.warn( + `[bundled-plugins] ${spec.id}: policy "${spec.gate}" is off but the ` + + 'config dir is read-only, so it stays enabled', + ); + continue; + } + await updatePluginMeta(spec.id, { enabled: false, forceEnabled: false }); + logger.info(`[bundled-plugins] ${spec.id} disabled ("${spec.gate}" is off in policy)`); + await auditLog('plugin.bundled.disable', { id: spec.id, gate: spec.gate }, 'system'); + } + continue; + } + + if (!staged) continue; + + const read = await readStaged(dir, spec.id); + if (!read) { + logger.warn( + `[bundled-plugins] ${spec.id}: not staged in ${dir} - ` + + 'run "npm run build:plugins" (the container and standalone builds do this for you)', + ); + continue; + } + + const bundleHash = createHash('sha256').update(read.code).digest('hex'); + const version = asString(read.manifest.version); + const unchanged = + existing !== undefined && + existing.version === version && + existing.bundleHash === bundleHash && + existing.enabled === true && + existing.forceEnabled === spec.forceEnable; + if (unchanged) { + logger.debug(`[bundled-plugins] ${spec.id} v${version} already installed`); + continue; + } + + if (isConfigReadOnly()) { + logger.warn( + `[bundled-plugins] ${spec.id} v${version} cannot be installed: the admin ` + + 'config dir is read-only. Remount it read-write (or unset ' + + 'ADMIN_CONFIG_READONLY) once, so the plugin registry can be written.', + ); + continue; + } + + const built = toServerPlugin(read.manifest, read.code, { + enabled: true, + forceEnabled: spec.forceEnable, + installedAt: existing?.installedAt ?? new Date().toISOString(), + }); + if ('errors' in built) { + logger.error(`[bundled-plugins] ${spec.id}: manifest rejected`, { + errors: built.errors.join('; '), + }); + continue; + } + + await savePlugin(built.plugin, read.code); + const action = existing ? 'update' : 'install'; + logger.info( + `[bundled-plugins] ${existing ? 'updated' : 'installed'} ${spec.id} v${version} ` + + `(tier=${built.plugin.tier ?? 'untrusted'}, forceEnabled=${spec.forceEnable})`, + ); + await auditLog( + `plugin.bundled.${action}`, + { id: spec.id, version, bundleHash, tier: built.plugin.tier ?? 'untrusted' }, + 'system', + ); + } + } catch (err) { + logger.error('[bundled-plugins] seeding failed', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** Exposed for diagnostics / tests. */ +export async function listStagedBundledPlugins(): Promise { + const dir = getBundledPluginsDir(); + if (!existsSync(dir)) return []; + const names = await readdir(dir); + const out: string[] = []; + for (const name of names) { + if (name.startsWith('.')) continue; + try { + if ((await stat(path.join(dir, name))).isDirectory()) out.push(name); + } catch { /* ignore */ } + } + return out; +} diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 84863a5c..ea1ccd66 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -193,10 +193,22 @@ async function loadDevPlugin(pluginDir: string): Promise author: asString(manifest.author), description: asString(manifest.description), type: asString(manifest.type, 'hook'), + // Requested execution tier. Dropped here previously, which silently pinned + // every dev-loaded plugin to the untrusted (null-origin) tier - so a + // privileged plugin such as S/MIME could never be exercised from + // PLUGIN_DEV_DIR. Only 'privileged' is meaningful (same narrowing as the + // admin upload route); the tier is still *granted* only by + // resolvePluginTier, which additionally requires managed + consent. + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), permissions, entrypoint, enabled: true, forceEnabled: false, + // Manifest i18n tables - also previously dropped, so api.i18n.t() fell back + // to raw keys for dev-loaded plugins. + ...(manifest.locales && typeof manifest.locales === 'object' + ? { locales: manifest.locales as ServerPlugin['locales'] } + : {}), ...(manifest.configSchema && typeof manifest.configSchema === 'object' ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } : {}), diff --git a/package.json b/package.json index 2d5b0e16..a4c1df9b 100644 --- a/package.json +++ b/package.json @@ -23,16 +23,17 @@ "typescript" ], "scripts": { - "dev": "next dev --turbopack", - "build": "next build --turbopack", + "dev": "npm run build:plugins && next dev --turbopack", + "build": "npm run build:plugins && next build --turbopack", "start": "next start", + "build:plugins": "node scripts/build-plugins.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts", "test:integration": "bash integration/run-tests.sh", "prepare": "husky", "typecheck": "tsc --noEmit", - "build:standalone": "next build --webpack && node scripts/assemble-standalone.mjs", + "build:standalone": "npm run build:plugins && next build --webpack && node scripts/assemble-standalone.mjs", "build:electron": "node scripts/build-electron.mjs", "electron:dev": "npm run build:standalone && npm run build:electron && electron .", "test:electron": "playwright test -c playwright.electron.config.ts", diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs index e404beaf..adb05fb2 100644 --- a/scripts/assemble-standalone.mjs +++ b/scripts/assemble-standalone.mjs @@ -58,4 +58,27 @@ if (existsSync(sqlcipherSrc)) { ); } +// The staged first-party plugin bundles (scripts/build-plugins.mjs). The +// server installs these into its plugin registry at startup +// (lib/admin/bundled-plugins.ts), reading them from +// `/vnc/plugins/build` - and Next's generated server.js chdir's to its +// own directory, so "cwd" is this standalone dir in every packaged build. +// +// Output file tracing cannot find these: nothing imports them, they are read +// by path at runtime. Without this copy the Electron/standalone build boots +// with no S/MIME plugin at all while the policy toggle still says it is on - +// the same silent-drop failure mode as the sqlcipher prebuilds above. +const pluginsSrc = path.join(rootDir, "vnc", "plugins", "build"); +if (existsSync(pluginsSrc)) { + const pluginsDest = path.join(standaloneDir, "vnc", "plugins", "build"); + rmSync(pluginsDest, { recursive: true, force: true }); + cpSync(pluginsSrc, pluginsDest, { recursive: true }); + console.log("Copied bundled first-party plugins into the standalone output"); +} else { + console.warn( + "No bundled plugins staged at vnc/plugins/build - " + + 'run "npm run build:plugins" first, or the packaged app ships without S/MIME', + ); +} + console.log("Assembled standalone server at", standaloneDir); diff --git a/scripts/build-plugins.mjs b/scripts/build-plugins.mjs new file mode 100644 index 00000000..33690269 --- /dev/null +++ b/scripts/build-plugins.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Builds the FIRST-PARTY plugins that ship with this fork (vnc/plugins/*) and +// stages them where the running server can install them. +// +// Why this exists +// --------------- +// `vnc/plugins/smime` is audited SOURCE, not a prebuilt drop (upstream's own +// smime.zip was deliberately distrusted). Nothing built it in any real build, +// so the S/MIME policy toggle was on while no plugin existed. This script is +// the missing build step; `lib/admin/bundled-plugins.ts` is the matching +// install step that runs at server startup. +// +// Output layout (gitignored, produced not committed): +// +// vnc/plugins/build//manifest.json +// vnc/plugins/build// e.g. index.js +// +// That directory is copied into the container image (Dockerfile) and into +// .next/standalone (scripts/assemble-standalone.mjs), so every distribution +// path - container, Electron, local dev - boots with the same artifact. +// +// Each plugin keeps its OWN package.json + lockfile so its (crypto) deps stay +// pinned by the audit, rather than being restated in the app's root manifest. + +import { execFileSync } from "node:child_process"; +import { + cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const pluginsDir = path.join(rootDir, "vnc", "plugins"); +const outDir = path.join(pluginsDir, "build"); + +// Mirrors MAX_PLUGIN_SIZE in lib/plugin-types.ts - the cap the admin upload +// route enforces. A first-party plugin must live inside the same budget. +const MAX_BUNDLE_BYTES = 5 * 1024 * 1024; +const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; + +const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + +function run(cmd, args, cwd) { + execFileSync(cmd, args, { cwd, stdio: "inherit" }); +} + +function listPluginDirs() { + if (!existsSync(pluginsDir)) return []; + return readdirSync(pluginsDir) + .filter((name) => name !== "build" && !name.startsWith(".")) + .map((name) => path.join(pluginsDir, name)) + .filter((dir) => statSync(dir).isDirectory()) + .filter((dir) => existsSync(path.join(dir, "manifest.json"))); +} + +function buildOne(pluginDir) { + const manifestPath = path.join(pluginDir, "manifest.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + const id = manifest.id; + if (typeof id !== "string" || !ID_RE.test(id)) { + throw new Error(`${manifestPath}: invalid plugin id ${JSON.stringify(id)}`); + } + const entrypoint = + typeof manifest.entrypoint === "string" ? manifest.entrypoint : "index.js"; + if (entrypoint.includes("/") || entrypoint.includes("\\")) { + throw new Error(`${manifestPath}: entrypoint must be a bare filename`); + } + + const pkgPath = path.join(pluginDir, "package.json"); + if (!existsSync(pkgPath)) { + throw new Error(`${pluginDir}: no package.json, cannot build`); + } + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (!pkg.scripts?.build) { + throw new Error(`${pkgPath}: no "build" script`); + } + + // Install the plugin's own deps only when they're missing. Keeps repeat + // builds (and `npm run dev`) fast - the actual esbuild step is ~20ms. + if (!existsSync(path.join(pluginDir, "node_modules"))) { + const hasLock = existsSync(path.join(pluginDir, "package-lock.json")); + console.log(`[build-plugins] installing deps for ${id}`); + run(npm, hasLock ? ["ci"] : ["install", "--no-audit", "--no-fund"], pluginDir); + } + + console.log(`[build-plugins] building ${id}`); + run(npm, ["run", "build"], pluginDir); + + const built = path.join(pluginDir, "dist", entrypoint); + if (!existsSync(built)) { + throw new Error(`${id}: build produced no ${path.relative(rootDir, built)}`); + } + const size = statSync(built).size; + if (size > MAX_BUNDLE_BYTES) { + throw new Error( + `${id}: bundle is ${(size / 1024 / 1024).toFixed(2)} MB, over the ` + + `${MAX_BUNDLE_BYTES / 1024 / 1024} MB plugin limit`, + ); + } + + const stageDir = path.join(outDir, id); + rmSync(stageDir, { recursive: true, force: true }); + mkdirSync(stageDir, { recursive: true }); + cpSync(manifestPath, path.join(stageDir, "manifest.json")); + cpSync(built, path.join(stageDir, entrypoint)); + + console.log( + `[build-plugins] staged ${id} v${manifest.version} ` + + `(${(size / 1024).toFixed(0)} KB) -> ${path.relative(rootDir, stageDir)}`, + ); +} + +const dirs = listPluginDirs(); +if (dirs.length === 0) { + console.log("[build-plugins] no first-party plugins found under vnc/plugins"); + process.exit(0); +} + +// Rebuild the staging root from scratch so a plugin removed from the tree does +// not linger as a stale bundle that the server would happily keep installing. +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +for (const dir of dirs) buildOne(dir); +console.log(`[build-plugins] done (${dirs.length} plugin(s))`); diff --git a/vnc/plugins/smime/README.md b/vnc/plugins/smime/README.md index daf066a8..192df832 100644 --- a/vnc/plugins/smime/README.md +++ b/vnc/plugins/smime/README.md @@ -33,12 +33,48 @@ material ever leaves the device. "in-memory, cleared on reload" behaviour. - Returned HTML still passes through the host sanitizer. -## Build +## Build & installation + +Nothing manual is required in a normal build. From the repo root: ```bash -cd repos/plugins/smime -npm install # pulls pkijs / asn1js / pvtsutils / webcrypto-liner + esbuild -npm run build # → dist/index.js (~1.7 MB, under the privileged cap) +npm run build:plugins # → vnc/plugins/smime/dist/index.js + # + staged at vnc/plugins/build/smime/{manifest.json,index.js} +``` + +`npm run dev`, `npm run build` and `npm run build:standalone` all run it first, +and the `Dockerfile` runs `node scripts/build-plugins.mjs` in the builder stage. +The staged directory is copied into the container image (Dockerfile) and into +`.next/standalone` (`scripts/assemble-standalone.mjs`), which is what the +Electron package ships. + +At server startup `lib/admin/bundled-plugins.ts` installs the staged bundle into +the **server plugin registry** (`/plugins/`) — the same place +an operator-uploaded ZIP lands. That matters for the security model below: the +registry is the signed admin channel, so `/api/admin/plugins/smime/bundle` +Ed25519-signs the bytes on the way out and `/api/plugins` marks the plugin +`managed`, which is what `resolvePluginTier` needs before it will grant the +privileged tier. No gate is bypassed to get there. + +Installation is idempotent (a boot where the version + bundle hash are unchanged +writes nothing) and gated on the **`smimeEnabled` feature policy**, which is +therefore the operator's on/off switch for S/MIME: + +* `smimeEnabled: true` (the default) → installed, `forceEnabled`, served. +* `smimeEnabled: false` → the registry entry is disabled, `/api/plugins` stops + serving it, and clients clean it up on their next sync. + +Force-enabling is deliberate: `pluginsEnabled` defaults to `false`, which hides +the user-facing Settings ▸ Plugins tab, so a user would otherwise have no way to +switch the plugin on. To remove the plugin, turn the policy toggle **off** — +deleting it in the admin plugin list is undone by the next restart. + +For manual/one-off distribution the plugin also still packages as a ZIP: + +```bash +cd vnc/plugins/smime +npm ci # pkijs / asn1js / pvtsutils / webcrypto-liner + esbuild +npm run build # → dist/index.js (~1.7 MB, under the 5 MB plugin cap) npm run package # → smime.zip (manifest.json + index.js) for admin upload ``` @@ -46,6 +82,20 @@ The build aliases the Node `crypto` builtin (referenced by a dead `typeof process` branch in `asmcrypto.js`) to a browser shim so the bundle is self-contained. +### Known import limitation + +PKCS#12 files whose bags are encrypted with the old +`pbeWithSHA1And40BitRC2-CBC` / `pbeWithSHA1And128BitRC2-CBC` PBEs fail to import +with a bare `Unrecognized name` error. `crypto-engine.js` maps those OIDs to an +`RC2-CBC` WebCrypto algorithm that neither the browser nor `webcrypto-liner` +actually provides, so the declared support is not real. This is the default +`openssl pkcs12 -export` certificate PBE on LibreSSL (i.e. macOS's system +`openssl`). 3DES and PBES2/AES bags — what current OpenSSL, Windows and +Thunderbird produce — import fine. Re-export with +`-certpbe aes-256-cbc -keypbe aes-256-cbc` (or `-certpbe PBE-SHA1-3DES`) as a +workaround; a real fix needs either an RC2 implementation or an explicit, +actionable error. + ## Layout ``` @@ -66,8 +116,10 @@ src/ node-crypto-shim.js browser shim for the Node "crypto" builtin ``` -The crypto modules are faithful ports of the host's `lib/smime/*` (the former -native pipeline), so the plugin produces byte-compatible CMS. +The crypto modules are faithful ports of the host's former `lib/smime/*` native +pipeline (since removed), so the plugin produces byte-compatible CMS. With that +directory gone, this plugin **is** the S/MIME feature — which is why the +`smimeEnabled` policy gate now controls it. ## Note on host wiring