From dbbe194e69fa6d8fcad22420678a723f940a8213 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:14:13 +0800 Subject: [PATCH 1/5] feat(spec): structured plugin manifest schema (ADR-0025 F1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ManifestSchema with the authoritative plugin-distribution shapes so the cloud control plane can drop its stopgap mirror and import the canonical schemas. - PluginPermissionsSchema: structured { services, hooks, network, fs } (.strict()); ADR-0025 §3.2 - PluginEnginesSchema: { platform, protocol } (protocol-first, §3.10 #3) - PluginRuntimeSchema: node | sandbox | worker (trust tier, §3.6) - PluginPackagingSchema: bundled | manifest-deps (§3.3) - PluginIntegritySchema: Record (§3.2) ManifestSchema.permissions becomes a backward-compatible union of the legacy string[] and the structured block; new optional runtime / packaging / integrity / engines fields added. Legacy engine:{objectstack} retained and superseded by engines. Shapes match cloud's stopgap (service-cloud/src/plugin-artifact.ts) so cloud's swap is a one-line import from @objectstack/spec/kernel. Verified: tsc clean, 6609 spec tests pass, exports surface in dist/kernel, runtime parse smoke (legacy + structured + strict reject). Co-Authored-By: Claude Opus 4.8 --- packages/spec/src/kernel/manifest.zod.ts | 148 ++++++++++++++++++++++- 1 file changed, 142 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 40fbf337b..6f7b76f8d 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -7,6 +7,109 @@ import { CORE_PLUGIN_TYPES } from './plugin.zod'; import { DatasetSchema } from '../data/dataset.zod'; import { NavigationContributionSchema } from '../ui/app.zod'; +// ───────────────────────────────────────────────────────────────────── +// Plugin distribution (ADR-0025 §3.2) — authoritative shapes. +// +// These are the canonical schemas for a signed, permissioned plugin +// package. The cloud control plane mirrors them when it validates a +// published `.osplugin` manifest and persists the declared metadata onto +// `sys_package_version`; cloud swaps its local stopgap for these imports +// (see cloud docs/design/plugin-distribution-framework-tasks.md F1). +// ───────────────────────────────────────────────────────────────────── + +/** + * Structured permission grants requested by a plugin (ADR-0025 §3.2). + * Each list scopes one capability surface the plugin may touch. The + * install-time consent flow (ADR §3.5 step 2) turns this declaration into + * the persisted `granted_permissions` set enforced at load by the + * PluginPermissionEnforcer. + * + * @example + * ```jsonc + * { "services": ["object", "http"], "hooks": ["record.beforeInsert"], + * "network": ["api.acme.com"], "fs": [] } + * ``` + */ +export const PluginPermissionsSchema = z + .object({ + services: z.array(z.string()).optional() + .describe('Platform services the plugin may resolve (e.g. "object", "http")'), + hooks: z.array(z.string()).optional() + .describe('Lifecycle hooks the plugin may register (e.g. "record.beforeInsert")'), + network: z.array(z.string()).optional() + .describe('Network hosts the plugin may reach (e.g. "api.acme.com")'), + fs: z.array(z.string()).optional() + .describe('Filesystem paths the plugin may access'), + }) + .strict() + .describe('Structured plugin permission grants (ADR-0025 §3.2)'); + +export type PluginPermissions = z.infer; + +/** + * Backward-compatible manifest `permissions` value: either the legacy flat + * list of permission strings (apps / older packages) or the structured + * plugin permission block above. New code should prefer the structured form. + */ +export const ManifestPermissionsSchema = z.union([ + z.array(z.string()), + PluginPermissionsSchema, +]); + +export type ManifestPermissions = z.infer; + +/** + * Compatibility ranges for a plugin (ADR-0025 §3.2, §3.10 #3). + * `protocol` (the metadata/runtime contract version) is checked first and + * takes precedence over `platform` (the engine release range), so a plugin + * keeps working across platform releases that preserve the protocol. + */ +export const PluginEnginesSchema = z + .object({ + platform: z.string().optional() + .describe('ObjectStack platform release range (SemVer, e.g. ">=4.0 <5")'), + protocol: z.string().optional() + .describe('Runtime/metadata protocol range, checked first (ADR §3.10 #3)'), + }) + .describe('Plugin compatibility ranges (ADR-0025 §3.2)'); + +export type PluginEngines = z.infer; + +/** + * Trust / isolation tier the plugin runs under (ADR-0025 §3.6): + * - `node` — in-process, full PluginContext (first-party / verified only) + * - `sandbox` — QuickJS-WASM, capability-gated surface + * - `worker` — out-of-process (reserved) + */ +export const PluginRuntimeSchema = z + .enum(['node', 'sandbox', 'worker']) + .describe('Plugin trust tier (ADR-0025 §3.6)'); + +export type PluginRuntime = z.infer; + +/** + * Dependency packaging strategy (ADR-0025 §3.3): + * - `bundled` — deps pre-bundled into the artifact, no install-time npm + * - `manifest-deps`— deps resolved at install (`pnpm install`, opt-in) + */ +export const PluginPackagingSchema = z + .enum(['bundled', 'manifest-deps']) + .describe('Dependency packaging strategy (ADR-0025 §3.3)'); + +export type PluginPackaging = z.infer; + +/** + * Per-file content digests of the packaged artifact (ADR-0025 §3.2), + * mapping artifact-relative path → digest string (e.g. "sha256-"). + * Re-verified by the runtime when it unpacks the `.osplugin` (ADR §3.5 + * step 5). + */ +export const PluginIntegritySchema = z + .record(z.string(), z.string()) + .describe('Per-file content digests of the plugin artifact (ADR-0025 §3.2)'); + +export type PluginIntegrity = z.infer; + /** * Schema for the ObjectStack Manifest. * This defines the structure of a package configuration in the ObjectStack ecosystem. @@ -148,13 +251,18 @@ export const ManifestSchema = z.object({ */ description: z.string().optional().describe('Package description'), - /** - * Array of permission strings that the package requires. - * These form the "Scope" requested by the package at installation. - * + /** + * Permissions the package requires — the "Scope" requested at installation. + * + * Accepts either the legacy flat list of permission strings, or the + * structured plugin permission block ({@link PluginPermissionsSchema}, + * ADR-0025 §3.2) that maps to service / hook / network / fs capabilities. + * * @example ["system.user.read", "system.data.write"] + * @example { "services": ["object", "http"], "hooks": ["record.beforeInsert"] } */ - permissions: z.array(z.string()).optional().describe('Array of required permission strings'), + permissions: ManifestPermissionsSchema.optional() + .describe('Required permissions: legacy string[] or structured plugin block (ADR-0025 §3.2)'), /** * Glob patterns specifying ObjectQL schemas files. @@ -417,7 +525,35 @@ export const ManifestSchema = z.object({ objectstack: z.string() .regex(/^[><=~^]*\d+\.\d+\.\d+/) .describe('ObjectStack platform version requirement (SemVer range, e.g. ">=3.0.0")'), - }).optional().describe('Platform compatibility requirements'), + }).optional().describe('Platform compatibility requirements (legacy; superseded by `engines`)'), + + /** + * Compatibility ranges (ADR-0025 §3.2). Protocol-first: `engines.protocol` + * is checked before `engines.platform`. Supersedes the legacy single-field + * `engine`, which is retained for backward compatibility. + */ + engines: PluginEnginesSchema.optional() + .describe('Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`)'), + + /** + * Trust / isolation tier the plugin runs under (ADR-0025 §3.6). + * Unset implies a pure-metadata package (no executable code). + */ + runtime: PluginRuntimeSchema.optional() + .describe('Plugin trust tier (ADR-0025 §3.6)'), + + /** + * Dependency packaging strategy for code-bearing plugins (ADR-0025 §3.3). + */ + packaging: PluginPackagingSchema.optional() + .describe('Dependency packaging strategy (ADR-0025 §3.3)'), + + /** + * Per-file content digests of the packaged artifact (ADR-0025 §3.2), + * verified at install/load time when the runtime unpacks the `.osplugin`. + */ + integrity: PluginIntegritySchema.optional() + .describe('Per-file content digests of the plugin artifact (ADR-0025 §3.2)'), }); /** From 8f574fff16dbafd739e4cfa4010234e0d06229ce Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:24:00 +0800 Subject: [PATCH 2/5] feat(cli): `os plugin build` + .osplugin packaging (ADR-0025 F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the build half of the plugin distribution pipeline (ADR-0025 §3.4): - src/utils/osplugin.ts — dependency-free packaging primitives: - sriDigest(): canonical per-file integrity string `sha256-` (matches ADR §3.2's example; the format cloud/runtime align to). - computeIntegrity(): builds the manifest `integrity` map (excludes the manifest itself + SIGNATURE; deterministic key order). - createTar()/createTarGz(): reproducible ustar+gzip writer (mtime pinned to 0, sorted entries) so any tar reader can unpack the artifact and identical inputs yield byte-identical blobs. - src/commands/plugin/build.ts — `os plugin build`: 1. validate objectstack.plugin.json against the canonical ManifestSchema (@objectstack/spec/kernel — F1), failing fast with zod diagnostics; 2. esbuild-bundle the entry to dist/index.mjs, externalizing @objectstack/* (and declared deps for packaging: manifest-deps); 3. compute per-file integrity + emit the compiled manifest; 4. pack dist/ (+assets, +package.json/lockfile for manifest-deps, +SIGNATURE placeholder) into -.osplugin. Signing is a separate step; this emits an unsigned artifact. Tests (6, all green; full CLI suite 143 green): SRI vector, integrity exclusion+ordering, ustar round-trip with valid checksums, gzip validity, reproducibility, and an end-to-end build that bundles a fixture plugin and reads the .osplugin back — exercising F1's schema through the CLI. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/plugin/build.ts | 232 ++++++++++++++++++++++ packages/cli/src/utils/osplugin.ts | 134 +++++++++++++ packages/cli/test/osplugin.test.ts | 136 +++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 packages/cli/src/commands/plugin/build.ts create mode 100644 packages/cli/src/utils/osplugin.ts create mode 100644 packages/cli/test/osplugin.test.ts diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts new file mode 100644 index 000000000..ededb8228 --- /dev/null +++ b/packages/cli/src/commands/plugin/build.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin build` — compile a plugin into a signed-ready `.osplugin` + * artifact (ADR-0025 §3.4 step 1, framework F2). + * + * Flow: + * 1. Load + validate `objectstack.plugin.json` against the canonical + * ManifestSchema (@objectstack/spec/kernel, ADR-0025 §3.2 — landed by + * framework F1). Malformed manifests fail fast with zod diagnostics. + * 2. esbuild-bundle the entry to `dist/index.mjs`, externalizing + * `@objectstack/*` (peer-provided by the host runtime; ADR §3.10 #2). + * For `packaging: manifest-deps`, dependencies are externalized too and + * `package.json` + lockfile are carried for install-time resolution. + * 3. Compute per-file `integrity` (`sha256-`, ADR §3.2) and write + * the compiled manifest with that map + a `dist/index.mjs` entry. + * 4. Pack everything (+ a `SIGNATURE` placeholder) into a reproducible + * ustar+gzip `-.osplugin`. + * + * Signing is a separate step (`os plugin sign`); this command emits an + * unsigned artifact whose `SIGNATURE` is a placeholder. + */ + +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, relative, resolve as resolvePath, sep as PATH_SEP } from 'node:path'; +import { Args, Command, Flags } from '@oclif/core'; +import { ManifestSchema } from '@objectstack/spec/kernel'; +import { + printError, + printHeader, + printKV, + printStep, + printSuccess, + formatZodErrors, +} from '../../utils/format.js'; +import { + type ArchiveFile, + MANIFEST_FILENAME, + OSPLUGIN_EXT, + SIGNATURE_FILENAME, + computeIntegrity, + createTarGz, + sha256Hex, +} from '../../utils/osplugin.js'; + +const ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.mjs', 'src/index.js']; + +/** Walk a directory recursively, returning archive files under `prefix`. */ +async function collectDir(dir: string, prefix: string): Promise { + const out: ArchiveFile[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const abs = join(dir, e.name); + const rel = `${prefix}/${e.name}`; + if (e.isDirectory()) { + out.push(...(await collectDir(abs, rel))); + } else if (e.isFile()) { + out.push({ path: rel, data: new Uint8Array(await readFile(abs)) }); + } + } + return out; +} + +export default class PluginBuild extends Command { + static override description = + 'Compile a plugin into a signed-ready `.osplugin` artifact (ADR-0025 §3.4)'; + + static override examples = [ + '$ os plugin build', + '$ os plugin build --entry src/main.ts', + '$ os plugin build --out dist/my-plugin.osplugin', + ]; + + static override args = { + dir: Args.string({ + description: 'Plugin project directory (defaults to cwd)', + required: false, + }), + }; + + static override flags = { + entry: Flags.string({ + char: 'e', + description: 'Entry module to bundle (defaults to the first of src/index.{ts,tsx,mjs,js})', + }), + out: Flags.string({ + char: 'o', + description: 'Output path for the .osplugin (defaults to -.osplugin in cwd)', + }), + minify: Flags.boolean({ description: 'Minify the bundled output', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginBuild); + const cwd = resolvePath(process.cwd(), args.dir ?? '.'); + + printHeader('Build Plugin'); + + // 1. Load + validate the source manifest. ────────────────────────── + const manifestPath = resolvePath(cwd, MANIFEST_FILENAME); + let rawManifest: Record; + try { + rawManifest = JSON.parse(await readFile(manifestPath, 'utf-8')); + } catch (err) { + printError(`Cannot read ${MANIFEST_FILENAME} in ${cwd}: ${(err as Error).message}`); + this.exit(1); + return; + } + + const parsed = ManifestSchema.safeParse(rawManifest); + if (!parsed.success) { + printError(`${MANIFEST_FILENAME} is invalid:`); + formatZodErrors(parsed.error); + this.exit(1); + return; + } + const manifest = parsed.data; + const id = manifest.id; + const version = manifest.version; + if (!id || !version) { + printError(`${MANIFEST_FILENAME} must declare both "id" and "version".`); + this.exit(1); + return; + } + const packaging = manifest.packaging ?? 'bundled'; + printStep(`Loaded ${id}@${version} (runtime: ${manifest.runtime ?? 'unset'}, packaging: ${packaging})`); + + // 2. Resolve entry + esbuild bundle. ─────────────────────────────── + const entryRel = + flags.entry ?? (typeof rawManifest.main === 'string' ? rawManifest.main : undefined) ?? + ENTRY_CANDIDATES.find((c) => existsSync(resolvePath(cwd, c))); + if (!entryRel) { + printError(`No entry module found. Add a "main" to ${MANIFEST_FILENAME} or pass --entry.`); + this.exit(1); + return; + } + const entryAbs = resolvePath(cwd, entryRel); + if (!existsSync(entryAbs)) { + printError(`Entry module not found: ${entryRel}`); + this.exit(1); + return; + } + printStep(`Bundling ${relative(cwd, entryAbs).split(PATH_SEP).join('/')}...`); + + let esbuild: typeof import('esbuild'); + try { + esbuild = await import('esbuild'); + } catch (err) { + printError(`esbuild is required to build plugins but is not installed: ${(err as Error).message}`); + this.exit(1); + return; + } + + // Externalize peer-provided @objectstack/*; for manifest-deps, also keep + // declared dependencies external (resolved at install time). + const external = ['@objectstack/*']; + if (packaging === 'manifest-deps') { + try { + const pkg = JSON.parse(await readFile(resolvePath(cwd, 'package.json'), 'utf-8')); + external.push(...Object.keys(pkg.dependencies ?? {})); + } catch { + /* no package.json — nothing extra to externalize */ + } + } + + let bundleBytes: Uint8Array; + try { + const result = await esbuild.build({ + entryPoints: [entryAbs], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node18', + write: false, + outfile: resolvePath(cwd, 'dist/index.mjs'), + sourcemap: false, + minify: flags.minify, + external, + logLevel: 'silent', + legalComments: 'none', + banner: { js: '// @generated by `os plugin build` — do not edit.' }, + }); + const outputs = result.outputFiles ?? []; + const js = outputs.find((f) => f.path.endsWith('.mjs') || f.path.endsWith('.js')) ?? outputs[0]; + if (!js) throw new Error('esbuild produced no output'); + bundleBytes = js.contents; + } catch (err) { + printError(`Bundle failed: ${(err as Error).message}`); + this.exit(1); + return; + } + + // 3. Stage archive files, compute integrity, compile manifest. ────── + const files: ArchiveFile[] = [{ path: 'dist/index.mjs', data: bundleBytes }]; + + const assetsDir = resolvePath(cwd, 'assets'); + if (existsSync(assetsDir) && (await stat(assetsDir)).isDirectory()) { + files.push(...(await collectDir(assetsDir, 'assets'))); + } + + if (packaging === 'manifest-deps') { + for (const dep of ['package.json', 'pnpm-lock.yaml']) { + const p = resolvePath(cwd, dep); + if (existsSync(p)) files.push({ path: dep, data: new Uint8Array(await readFile(p)) }); + } + } + + const integrity = computeIntegrity(files); + const compiledManifest = { ...manifest, main: 'dist/index.mjs', integrity }; + const manifestBytes = new Uint8Array( + Buffer.from(JSON.stringify(compiledManifest, null, 2) + '\n', 'utf-8'), + ); + files.push({ path: MANIFEST_FILENAME, data: manifestBytes }); + // Unsigned placeholder; `os plugin sign` overwrites this (ADR §3.4). + files.push({ path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned\n', 'utf-8')) }); + + // 4. Pack the artifact. ───────────────────────────────────────────── + const blob = createTarGz(files); + const outPath = resolvePath(cwd, flags.out ?? `${id}-${version}${OSPLUGIN_EXT}`); + await mkdir(resolvePath(outPath, '..'), { recursive: true }); + await writeFile(outPath, blob); + + printSuccess('Plugin built'); + printKV(' Artifact', relative(cwd, outPath).split(PATH_SEP).join('/') || outPath); + printKV(' Plugin', `${id}@${version}`); + printKV(' Files', String(files.length)); + printKV(' Integrity entries', String(Object.keys(integrity).length)); + printKV(' Size', `${(blob.byteLength / 1024).toFixed(1)} KB`); + printKV(' sha256', sha256Hex(blob)); + } +} diff --git a/packages/cli/src/utils/osplugin.ts b/packages/cli/src/utils/osplugin.ts new file mode 100644 index 000000000..4e9c06170 --- /dev/null +++ b/packages/cli/src/utils/osplugin.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `.osplugin` packaging primitives (ADR-0025 §3.1 / §3.2, framework F2). + * + * A `.osplugin` is a gzipped tar (ustar) of: + * + * objectstack.plugin.json ← compiled manifest (with `integrity`) + * dist/** ← bundled, @objectstack/*-externalized code + * assets/** ← optional static assets + * package.json ← only for `packaging: manifest-deps` + * pnpm-lock.yaml ← only for `packaging: manifest-deps` + * SIGNATURE ← detached publisher signature (placeholder + * until `os plugin sign`; ADR §3.4) + * + * The control plane (cloud) stores this blob opaquely and re-verifies the + * per-file `integrity` at install/load time when the runtime unpacks it + * (ADR §3.5 step 5). This module owns the two contracts the runtime and + * cloud must agree on byte-for-byte: + * + * 1. The integrity digest STRING FORMAT — Subresource-Integrity style + * `sha256-` (matches ADR-0025 §3.2's example). See + * {@link sriDigest}. + * 2. The archive being a standards-compliant ustar+gzip so any tar + * reader (node-tar, GNU tar) can unpack it. See {@link createTarGz}. + * + * Everything here is pure (no oclif / filesystem) so it can be unit-tested + * and reused by `os plugin sign` / `publish`. + */ + +import { createHash } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; + +/** A single file destined for the archive. `path` is POSIX, archive-relative. */ +export interface ArchiveFile { + path: string; + data: Uint8Array; +} + +/** + * Subresource-Integrity-style digest of `bytes`: `sha256-`. + * This is the canonical per-file integrity string written into the + * compiled manifest's `integrity` map and re-verified by the runtime. + */ +export function sriDigest(bytes: Uint8Array): string { + return 'sha256-' + createHash('sha256').update(bytes).digest('base64'); +} + +/** Plain hex sha256 of bytes (used for the whole-artifact checksum). */ +export function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * Build the per-file `integrity` map (archive-relative POSIX path → + * `sha256-`) for the given files. The compiled manifest itself + * (`objectstack.plugin.json`) and the `SIGNATURE` are excluded — the + * manifest can't hash itself (it embeds the map) and the signature signs + * the manifest. + */ +export function computeIntegrity(files: ArchiveFile[]): Record { + const out: Record = {}; + for (const f of files) { + if (f.path === MANIFEST_FILENAME || f.path === SIGNATURE_FILENAME) continue; + out[f.path] = sriDigest(f.data); + } + // Deterministic key order for reproducible manifests. + return Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +} + +export const MANIFEST_FILENAME = 'objectstack.plugin.json'; +export const SIGNATURE_FILENAME = 'SIGNATURE'; +export const OSPLUGIN_EXT = '.osplugin'; + +// ───────────────────────────────────────────────────────────────────── +// ustar writer (POSIX tar). Files only — directory entries are implicit +// from path prefixes (node-tar / GNU tar create them on extract). mtime is +// pinned to 0 so identical inputs produce byte-identical archives. +// ───────────────────────────────────────────────────────────────────── + +const BLOCK = 512; + +function writeOctal(buf: Buffer, value: number, offset: number, len: number): void { + // `len - 1` octal digits, zero-padded, then a NUL terminator. + const s = value.toString(8).padStart(len - 1, '0') + '\0'; + buf.write(s, offset, len, 'ascii'); +} + +function tarHeader(file: ArchiveFile): Buffer { + if (Buffer.byteLength(file.path, 'utf8') > 100) { + throw new Error(`osplugin: archive path too long for ustar (>100 bytes): ${file.path}`); + } + const h = Buffer.alloc(BLOCK, 0); + h.write(file.path, 0, 100, 'utf8'); // name + writeOctal(h, 0o644, 100, 8); // mode + writeOctal(h, 0, 108, 8); // uid + writeOctal(h, 0, 116, 8); // gid + writeOctal(h, file.data.byteLength, 124, 12); // size + writeOctal(h, 0, 136, 12); // mtime (pinned → reproducible) + h.write(' ', 148, 8, 'ascii'); // chksum field = spaces while summing + h.write('0', 156, 1, 'ascii'); // typeflag: regular file + h.write('ustar\0', 257, 6, 'ascii'); // magic + h.write('00', 263, 2, 'ascii'); // version + + // Header checksum: unsigned sum of all 512 bytes (chksum field as spaces), + // written as 6 octal digits + NUL + space. + let sum = 0; + for (let i = 0; i < BLOCK; i++) sum += h[i]; + h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii'); + return h; +} + +/** + * Serialize `files` into an uncompressed ustar buffer. Entries are emitted + * in sorted path order for reproducibility, terminated by two zero blocks. + */ +export function createTar(files: ArchiveFile[]): Buffer { + const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const chunks: Buffer[] = []; + for (const f of sorted) { + chunks.push(tarHeader(f)); + const data = Buffer.from(f.data); + chunks.push(data); + const pad = (BLOCK - (data.byteLength % BLOCK)) % BLOCK; + if (pad) chunks.push(Buffer.alloc(pad, 0)); + } + chunks.push(Buffer.alloc(BLOCK * 2, 0)); // end-of-archive + return Buffer.concat(chunks); +} + +/** ustar + gzip → the `.osplugin` blob bytes. */ +export function createTarGz(files: ArchiveFile[]): Buffer { + return gzipSync(createTar(files), { level: 9 }); +} diff --git a/packages/cli/test/osplugin.test.ts b/packages/cli/test/osplugin.test.ts new file mode 100644 index 000000000..b033a91ca --- /dev/null +++ b/packages/cli/test/osplugin.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterAll } from 'vitest'; +import { gunzipSync } from 'node:zlib'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + type ArchiveFile, + MANIFEST_FILENAME, + SIGNATURE_FILENAME, + computeIntegrity, + createTar, + createTarGz, + sriDigest, +} from '../src/utils/osplugin.js'; +import PluginBuild from '../src/commands/plugin/build.js'; + +/** Minimal ustar reader used to prove our writer is standards-compliant. */ +function readTar(buf: Buffer): Record { + const files: Record = {}; + let off = 0; + while (off + 512 <= buf.length) { + const name = buf.toString('utf8', off, off + 100).replace(/\0.*$/s, ''); + if (!name) break; // zero block → end of archive + const size = parseInt(buf.toString('ascii', off + 124, off + 136).replace(/\0.*$/s, '').trim(), 8); + const magic = buf.toString('ascii', off + 257, off + 263); + const storedChksum = parseInt(buf.toString('ascii', off + 148, off + 156).replace(/\0.*$/s, '').trim(), 8); + // Recompute checksum with the chksum field treated as spaces. + let sum = 0; + for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 0x20 : buf[off + i]; + files[name] = { + data: buf.subarray(off + 512, off + 512 + size), + magic, + chksumOk: sum === storedChksum, + }; + off += 512 + Math.ceil(size / 512) * 512; + } + return files; +} + +describe('osplugin: integrity', () => { + it('sriDigest uses the SRI sha256- format', () => { + const d = sriDigest(new Uint8Array(Buffer.from('hello'))); + expect(d).toMatch(/^sha256-[A-Za-z0-9+/]+=*$/); + // Canonical SRI vector for "hello". + expect(d).toBe('sha256-LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ='); + }); + + it('computeIntegrity excludes the manifest and SIGNATURE and sorts keys', () => { + const files: ArchiveFile[] = [ + { path: 'dist/b.mjs', data: new Uint8Array(Buffer.from('b')) }, + { path: 'dist/a.mjs', data: new Uint8Array(Buffer.from('a')) }, + { path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from('{}')) }, + { path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned')) }, + ]; + const integrity = computeIntegrity(files); + expect(Object.keys(integrity)).toEqual(['dist/a.mjs', 'dist/b.mjs']); + expect(integrity['dist/a.mjs']).toBe(sriDigest(new Uint8Array(Buffer.from('a')))); + }); +}); + +describe('osplugin: archive', () => { + const files: ArchiveFile[] = [ + { path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x = 1;\n')) }, + { path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from('{"id":"a"}\n')) }, + ]; + + it('createTar produces standards-compliant ustar entries (readable + valid checksums)', () => { + const tar = createTar(files); + const read = readTar(tar); + expect(Object.keys(read).sort()).toEqual(['dist/index.mjs', MANIFEST_FILENAME].sort()); + for (const name of Object.keys(read)) { + expect(read[name].magic).toBe('ustar\0'); + expect(read[name].chksumOk).toBe(true); + } + expect(read['dist/index.mjs'].data.toString('utf8')).toBe('export const x = 1;\n'); + }); + + it('createTarGz emits a valid gzip stream that gunzips back to the tar', () => { + const gz = createTarGz(files); + expect(gz[0]).toBe(0x1f); // gzip magic + expect(gz[1]).toBe(0x8b); + const tar = gunzipSync(gz); + expect(readTar(tar)[MANIFEST_FILENAME].data.toString('utf8')).toBe('{"id":"a"}\n'); + }); + + it('is reproducible — identical inputs yield byte-identical archives', () => { + expect(Buffer.compare(createTarGz(files), createTarGz([...files].reverse()))).toBe(0); + }); +}); + +describe('os plugin build (end-to-end)', () => { + let dir: string; + afterAll(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + it('bundles, computes integrity, and packs a readable .osplugin', async () => { + dir = await mkdtemp(join(tmpdir(), 'osplugin-')); + await mkdir(join(dir, 'src'), { recursive: true }); + await writeFile( + join(dir, MANIFEST_FILENAME), + JSON.stringify({ + id: 'com.acme.demo', + name: 'Demo', + version: '1.0.0', + type: 'plugin', + runtime: 'sandbox', + packaging: 'bundled', + main: 'src/index.ts', + permissions: { services: ['object'], hooks: ['record.beforeInsert'] }, + engines: { platform: '>=4.0 <5', protocol: '>=1.0' }, + }), + ); + await writeFile(join(dir, 'src', 'index.ts'), `export const hello = (): string => 'hi';\n`); + + await PluginBuild.run([dir]); + + const blob = await readFile(join(dir, 'com.acme.demo-1.0.0.osplugin')); + const tar = readTar(gunzipSync(blob)); + expect(tar['dist/index.mjs']).toBeDefined(); + expect(tar[MANIFEST_FILENAME]).toBeDefined(); + expect(tar[SIGNATURE_FILENAME]).toBeDefined(); + + const compiled = JSON.parse(tar[MANIFEST_FILENAME].data.toString('utf8')); + expect(compiled.id).toBe('com.acme.demo'); + expect(compiled.main).toBe('dist/index.mjs'); + // integrity is SRI over the bundled file and matches its bytes. + expect(compiled.integrity['dist/index.mjs']).toBe( + sriDigest(new Uint8Array(tar['dist/index.mjs'].data)), + ); + // @objectstack/* would be externalized; the trivial bundle has no imports. + expect(tar['dist/index.mjs'].data.toString('utf8')).toContain('hello'); + }); +}); From 9e91f3c900bf4372c737c1a08db9db543ff42167 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:43:24 +0800 Subject: [PATCH 3/5] feat(core,cli): Ed25519 plugin signature contract + `os plugin sign` (ADR-0025 F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the canonical signature half of the plugin distribution pipeline, byte-for-byte aligned with the cloud control plane's package-signing so the two never drift. core/src/security/plugin-artifact-signature.ts — the shared Ed25519 detached-signature contract: - format `ed25519::`; sign/verify via node:crypto (`sign(null,…)`/`verify(null,…)`), keyId as the rotation handle. - verifyPublisherSignature(): publisher sig over raw artifact bytes, keyId-resolved key, mirroring cloud's publish-time policy (no sig → unverified-but-ok; malformed/unknown-key/mismatch → not ok). - counterSignPayload()/verifyPlatformSignature(): platform counter-sign over [package_id, version, blob_key, signature].join("\n") — identical to cloud's payload. - verifyPluginArtifact(): runs both trust chains at load time (ADR §3.7); requirePlatform=false for first-party/local builds. Exported from @objectstack/core/security. cli plugin/sign.ts — `os plugin sign --key [--key-id]`: detached publisher signature over the EXACT artifact bytes (the bytes cloud verifies at publish), written to a `.sig` sidecar, with a self-verify guard. Completes build → sign → publish. plugin-loader.ts: replace the placeholder verifyPluginSignature with an honest check — artifact-bytes/counter-sign verification belongs at materialize time (no artifact bytes exist at loadPlugin()), so the loader now validates signature well-formedness via parseSignature and fails fast on a malformed value, pointing at verifyPluginArtifact for the real chains. Verified: core 269 tests (incl. 14 signature: format, determinism, tamper, cloud-contract alignment, publisher policy, counter-sign, combined chains, KeyObject), cli 138 (incl. build→sign→verify e2e against the exact bytes). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/plugin/sign.ts | 113 ++++++++ packages/cli/test/plugin-sign.test.ts | 61 +++++ packages/core/src/plugin-loader.ts | 24 +- packages/core/src/security/index.ts | 18 ++ .../plugin-artifact-signature.test.ts | 147 +++++++++++ .../src/security/plugin-artifact-signature.ts | 243 ++++++++++++++++++ 6 files changed, 602 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/commands/plugin/sign.ts create mode 100644 packages/cli/test/plugin-sign.test.ts create mode 100644 packages/core/src/security/plugin-artifact-signature.test.ts create mode 100644 packages/core/src/security/plugin-artifact-signature.ts diff --git a/packages/cli/src/commands/plugin/sign.ts b/packages/cli/src/commands/plugin/sign.ts new file mode 100644 index 000000000..61a013016 --- /dev/null +++ b/packages/cli/src/commands/plugin/sign.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os plugin sign` — produce a publisher signature over a built `.osplugin` + * (ADR-0025 §3.4 step 2, framework F3). + * + * The signature is DETACHED and computed over the exact artifact bytes that + * will be uploaded — the same bytes the cloud control plane verifies at + * publish time (`verifyPublisherSignature`) and the runtime re-verifies at + * materialize time. It is emitted as `ed25519::` and + * written to a `.sig` sidecar (and printed), to be passed as the + * `signature` field when publishing. The artifact itself is NOT modified, so + * signing is idempotent and the signed bytes are exactly the built bytes. + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createPrivateKey, createPublicKey } from 'node:crypto'; +import { resolve as resolvePath } from 'node:path'; +import { Args, Command, Flags } from '@oclif/core'; +import { parseSignature, signPayload, verifyPayload } from '@objectstack/core'; +import { printError, printHeader, printKV, printStep, printSuccess } from '../../utils/format.js'; +import { OSPLUGIN_EXT } from '../../utils/osplugin.js'; + +export default class PluginSign extends Command { + static override description = + 'Sign a built .osplugin with a publisher Ed25519 key (ADR-0025 §3.4)'; + + static override examples = [ + '$ os plugin sign my-plugin-1.0.0.osplugin --key ./publisher.key.pem', + '$ os plugin sign my-plugin-1.0.0.osplugin --key ./publisher.key.pem --key-id acme-2026', + ]; + + static override args = { + artifact: Args.string({ description: 'Path to the .osplugin artifact', required: true }), + }; + + static override flags = { + key: Flags.string({ + char: 'k', + description: 'Path to the publisher Ed25519 private key (PKCS#8 PEM)', + required: true, + }), + 'key-id': Flags.string({ + description: 'Key identifier embedded in the signature (rotation handle)', + default: 'default', + }), + out: Flags.string({ + char: 'o', + description: 'Output path for the detached signature (defaults to .sig)', + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(PluginSign); + printHeader('Sign Plugin'); + + const artifactPath = resolvePath(process.cwd(), args.artifact); + if (!existsSync(artifactPath)) { + printError(`Artifact not found: ${args.artifact}`); + this.exit(1); + return; + } + if (!artifactPath.endsWith(OSPLUGIN_EXT)) { + printStep(`Warning: ${args.artifact} does not have a ${OSPLUGIN_EXT} extension`); + } + + let privateKeyPem: string; + try { + privateKeyPem = await readFile(resolvePath(process.cwd(), flags.key), 'utf-8'); + } catch (err) { + printError(`Cannot read private key: ${(err as Error).message}`); + this.exit(1); + return; + } + + const artifact = new Uint8Array(await readFile(artifactPath)); + const keyId = flags['key-id']; + + let signature: string; + try { + signature = signPayload(artifact, privateKeyPem, keyId); + } catch (err) { + printError(`Signing failed: ${(err as Error).message}`); + this.exit(1); + return; + } + + // Self-check: verify the freshly produced signature against the public + // half so a bad key / wrong format never ships silently. + try { + const pub = createPublicKey(createPrivateKey(privateKeyPem)); + if (!verifyPayload(artifact, signature, pub)) { + printError('Self-verification of the produced signature failed.'); + this.exit(1); + return; + } + } catch (err) { + printError(`Self-verification error: ${(err as Error).message}`); + this.exit(1); + return; + } + + const outPath = resolvePath(process.cwd(), flags.out ?? `${args.artifact}.sig`); + await writeFile(outPath, signature + '\n', 'utf-8'); + + printSuccess('Plugin signed'); + printKV(' Artifact', args.artifact); + printKV(' Key ID', parseSignature(signature)?.keyId ?? keyId); + printKV(' Signature', signature); + printKV(' Sidecar', flags.out ?? `${args.artifact}.sig`); + } +} diff --git a/packages/cli/test/plugin-sign.test.ts b/packages/cli/test/plugin-sign.test.ts new file mode 100644 index 000000000..a5c49d4e6 --- /dev/null +++ b/packages/cli/test/plugin-sign.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterAll } from 'vitest'; +import { gunzipSync } from 'node:zlib'; +import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { generateEd25519KeyPair, parseSignature, verifyPayload } from '@objectstack/core'; +import PluginBuild from '../src/commands/plugin/build.js'; +import PluginSign from '../src/commands/plugin/sign.js'; +import { MANIFEST_FILENAME } from '../src/utils/osplugin.js'; + +describe('os plugin sign (end-to-end, build → sign → verify)', () => { + let dir: string; + afterAll(async () => { + if (dir) await rm(dir, { recursive: true, force: true }); + }); + + it('produces a detached ed25519 signature over the exact artifact bytes', async () => { + dir = await mkdtemp(join(tmpdir(), 'osplugin-sign-')); + await mkdir(join(dir, 'src'), { recursive: true }); + await writeFile( + join(dir, MANIFEST_FILENAME), + JSON.stringify({ + id: 'com.acme.signed', + name: 'Signed', + version: '1.0.0', + type: 'plugin', + runtime: 'node', + packaging: 'bundled', + main: 'src/index.ts', + permissions: { services: ['object'] }, + }), + ); + await writeFile(join(dir, 'src', 'index.ts'), `export const v = 1;\n`); + + const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair(); + await writeFile(join(dir, 'publisher.key.pem'), privateKeyPem); + + await PluginBuild.run([dir]); + const artifactPath = join(dir, 'com.acme.signed-1.0.0.osplugin'); + + await PluginSign.run([artifactPath, '--key', join(dir, 'publisher.key.pem'), '--key-id', 'acme-2026']); + + const sig = (await readFile(`${artifactPath}.sig`, 'utf-8')).trim(); + expect(parseSignature(sig)?.keyId).toBe('acme-2026'); + + // The signature must verify against the EXACT bytes that were signed — the + // same check the cloud control plane runs at publish time. + const artifactBytes = new Uint8Array(await readFile(artifactPath)); + expect(verifyPayload(artifactBytes, sig, publicKeyPem)).toBe(true); + + // Tampering with the artifact invalidates the signature. + const tampered = new Uint8Array(artifactBytes); + tampered[tampered.length - 1] ^= 0xff; + expect(verifyPayload(tampered, sig, publicKeyPem)).toBe(false); + + // Sanity: the signed artifact is a valid gzip (unchanged by signing). + expect(() => gunzipSync(Buffer.from(artifactBytes))).not.toThrow(); + }); +}); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index ff6539279..c7764c0d3 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -4,6 +4,7 @@ import { Plugin, PluginContext } from './types.js'; import type { Logger } from '@objectstack/spec/contracts'; import { z } from 'zod'; import { PluginConfigValidator } from './security/plugin-config-validator.js'; +import { parseSignature } from './security/plugin-artifact-signature.js'; /** * Service Lifecycle Types @@ -423,10 +424,25 @@ export class PluginLoader { return; } - // Plugin signature verification is now implemented in PluginSignatureVerifier - // This is a placeholder that logs the verification would happen - // The actual verification should be done by the caller with proper security config - this.logger.debug(`Plugin ${plugin.name} has signature (use PluginSignatureVerifier for verification)`); + // Cryptographic verification of a third-party plugin's PUBLISHER and + // PLATFORM signatures is performed against the `.osplugin` artifact + // bytes + version identity at materialize/install time, by + // `verifyPluginArtifact` (security/plugin-artifact-signature.ts — + // ADR-0025 §3.7). By the time a plugin reaches loadPlugin() it is an + // in-memory module with no artifact bytes, so we cannot re-run the + // artifact chains here; we only validate that any signature carried + // on the metadata is well-formed (`ed25519::`) and + // surface its keyId, failing fast on a malformed value. + const parsed = parseSignature(plugin.signature); + if (!parsed) { + throw new Error( + `Plugin ${plugin.name} carries a malformed signature (expected ed25519::)`, + ); + } + this.logger.debug( + `Plugin ${plugin.name} signature well-formed (alg=${parsed.alg}, keyId=${parsed.keyId}); ` + + `artifact verification occurs at materialize time`, + ); } private async getSingletonService(registration: ServiceRegistration): Promise { diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index 5418f03c6..b22e857b5 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -17,6 +17,24 @@ export { type SignatureVerificationResult, } from './plugin-signature-verifier.js'; +// Canonical Ed25519 artifact-signature contract (ADR-0025 F3), shared +// byte-for-byte with the cloud control plane's package-signing. +export { + SIGNATURE_ALG, + type KeyInput, + type ParsedSignature, + type PublisherVerifyResult, + type PluginArtifactVerifyResult, + generateEd25519KeyPair, + signPayload, + parseSignature, + verifyPayload, + counterSignPayload, + verifyPublisherSignature, + verifyPlatformSignature, + verifyPluginArtifact, +} from './plugin-artifact-signature.js'; + export { PluginConfigValidator, createPluginConfigValidator, diff --git a/packages/core/src/security/plugin-artifact-signature.test.ts b/packages/core/src/security/plugin-artifact-signature.test.ts new file mode 100644 index 000000000..d81714abb --- /dev/null +++ b/packages/core/src/security/plugin-artifact-signature.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { createPublicKey, createPrivateKey } from 'node:crypto'; +import { + counterSignPayload, + generateEd25519KeyPair, + parseSignature, + signPayload, + verifyPayload, + verifyPlatformSignature, + verifyPluginArtifact, + verifyPublisherSignature, +} from './plugin-artifact-signature.js'; + +const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair(); +const artifact = new Uint8Array(Buffer.from('fake .osplugin bytes')); + +describe('plugin-artifact-signature: format + roundtrip', () => { + it('signPayload emits ed25519:: and verifies', () => { + const sig = signPayload(artifact, privateKeyPem, 'acme-2026'); + expect(sig).toMatch(/^ed25519:acme-2026:[A-Za-z0-9_-]+$/); // base64url alphabet + expect(verifyPayload(artifact, sig, publicKeyPem)).toBe(true); + }); + + it('is deterministic (Ed25519) — same input yields the same signature', () => { + expect(signPayload(artifact, privateKeyPem, 'k')).toBe(signPayload(artifact, privateKeyPem, 'k')); + }); + + it('rejects a keyId containing ":"', () => { + expect(() => signPayload(artifact, privateKeyPem, 'bad:id')).toThrow(); + }); + + it('parseSignature handles valid + malformed strings', () => { + const sig = signPayload(artifact, privateKeyPem, 'k1'); + const parsed = parseSignature(sig); + expect(parsed?.alg).toBe('ed25519'); + expect(parsed?.keyId).toBe('k1'); + expect(parseSignature('rsa:k:zzz')).toBeNull(); + expect(parseSignature('ed25519:onlyonepart')).toBeNull(); + expect(parseSignature(undefined)).toBeNull(); + }); + + it('detects tampering of the payload and the signature', () => { + const sig = signPayload(artifact, privateKeyPem, 'k'); + expect(verifyPayload(new Uint8Array(Buffer.from('other bytes')), sig, publicKeyPem)).toBe(false); + const tampered = sig.slice(0, -2) + (sig.endsWith('AA') ? 'BB' : 'AA'); + expect(verifyPayload(artifact, tampered, publicKeyPem)).toBe(false); + }); +}); + +describe('plugin-artifact-signature: cloud contract alignment', () => { + it('counterSignPayload is exactly [package_id, version, blob_key, signature].join("\\n")', () => { + expect( + counterSignPayload({ package_id: 'p', version: '1.0.0', blob_key: 'b', signature: 's' }), + ).toBe('p\n1.0.0\nb\ns'); + // null/undefined fields collapse to empty strings (matches cloud). + expect(counterSignPayload({ package_id: 'p', version: '1.0.0' })).toBe('p\n1.0.0\n\n'); + }); +}); + +describe('plugin-artifact-signature: publisher verification policy', () => { + it('no signature → ok but unverified', async () => { + const r = await verifyPublisherSignature({ artifact, signature: null }); + expect(r).toMatchObject({ ok: true, verified: false }); + }); + + it('malformed signature → not ok', async () => { + const r = await verifyPublisherSignature({ artifact, signature: 'garbage' }); + expect(r.ok).toBe(false); + }); + + it('signature present but no key registry → ok, unverified', async () => { + const sig = signPayload(artifact, privateKeyPem, 'k'); + const r = await verifyPublisherSignature({ artifact, signature: sig }); + expect(r).toMatchObject({ ok: true, verified: false }); + }); + + it('unknown keyId → not ok', async () => { + const sig = signPayload(artifact, privateKeyPem, 'k'); + const r = await verifyPublisherSignature({ artifact, signature: sig }, () => null); + expect(r.ok).toBe(false); + }); + + it('valid signature + resolvable key → ok + verified', async () => { + const sig = signPayload(artifact, privateKeyPem, 'k'); + const r = await verifyPublisherSignature({ artifact, signature: sig }, (id) => + id === 'k' ? publicKeyPem : null, + ); + expect(r).toMatchObject({ ok: true, verified: true }); + }); +}); + +describe('plugin-artifact-signature: platform counter-sign + combined chains', () => { + const platform = generateEd25519KeyPair(); + const version = { package_id: 'com.acme.p', version: '2.1.0', blob_key: 'packages/acme/p/2.1.0.osplugin' }; + + it('verifyPlatformSignature roundtrips against the version identity', () => { + const platform_signature = signPayload( + counterSignPayload({ ...version, signature: 'pub-sig' }), + platform.privateKeyPem, + 'platform', + ); + expect( + verifyPlatformSignature({ ...version, signature: 'pub-sig', platform_signature }, platform.publicKeyPem), + ).toBe(true); + // Wrong signature field in the identity breaks the attestation. + expect( + verifyPlatformSignature({ ...version, signature: 'OTHER', platform_signature }, platform.publicKeyPem), + ).toBe(false); + }); + + it('verifyPluginArtifact requires a valid platform counter-sign by default', async () => { + const pubSig = signPayload(artifact, privateKeyPem, 'pub'); + const v = { ...version, signature: pubSig }; + const platform_signature = signPayload(counterSignPayload(v), platform.privateKeyPem, 'platform'); + + const ok = await verifyPluginArtifact( + { artifact, version: { ...v, platform_signature } }, + { platformPublicKey: platform.publicKeyPem, getPublisherPublicKey: () => publicKeyPem }, + ); + expect(ok).toMatchObject({ ok: true, publisherVerified: true, platformVerified: true }); + + // Missing platform key → rejected under default requirePlatform. + const noPlatform = await verifyPluginArtifact( + { artifact, version: { ...v, platform_signature } }, + { getPublisherPublicKey: () => publicKeyPem }, + ); + expect(noPlatform.ok).toBe(false); + + // First-party opt-out: requirePlatform=false accepts publisher-only. + const firstParty = await verifyPluginArtifact( + { artifact, version: v }, + { getPublisherPublicKey: () => publicKeyPem, requirePlatform: false }, + ); + expect(firstParty.ok).toBe(true); + }); +}); + +describe('plugin-artifact-signature: KeyObject inputs', () => { + it('accepts KeyObject as well as PEM', () => { + const priv = createPrivateKey(privateKeyPem); + const pub = createPublicKey(publicKeyPem); + const sig = signPayload(artifact, priv, 'k'); + expect(verifyPayload(artifact, sig, pub)).toBe(true); + }); +}); diff --git a/packages/core/src/security/plugin-artifact-signature.ts b/packages/core/src/security/plugin-artifact-signature.ts new file mode 100644 index 000000000..57da0ca73 --- /dev/null +++ b/packages/core/src/security/plugin-artifact-signature.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Plugin artifact signing & verification (ADR-0025 §3.4–§3.7, framework F3). + * + * This is the CANONICAL Ed25519 detached-signature contract shared by the + * whole plugin distribution pipeline. It is intentionally byte-for-byte + * compatible with the cloud control plane's `package-signing.ts` so the + * two never drift: + * + * - signature string format: `ed25519::` + * - publisher signature: Ed25519 over the raw `.osplugin` artifact bytes, + * produced by `os plugin sign`, verified by cloud at publish time and by + * the runtime when it materializes the artifact. + * - platform counter-signature: Ed25519 over {@link counterSignPayload} + * (the version identity), produced by cloud at approval, verified by the + * runtime at load time as the marketplace's "reviewed + approved" attest. + * + * Algorithm: Ed25519 via node:crypto (`sign(null, …)` / `verify(null, …)`): + * short, deterministic, no padding ambiguity. The `keyId` is an opaque + * rotation handle used to resolve the verifying public key. + * + * The two trust chains the runtime checks before loading a third-party + * plugin are combined in {@link verifyPluginArtifact}. + */ + +import { + sign as cryptoSign, + verify as cryptoVerify, + createPublicKey, + createPrivateKey, + generateKeyPairSync, + type KeyObject, +} from 'node:crypto'; + +export const SIGNATURE_ALG = 'ed25519'; +const SIG_PREFIX = 'ed25519:'; + +export type KeyInput = string | KeyObject; + +function toPrivateKey(key: KeyInput): KeyObject { + return typeof key === 'string' ? createPrivateKey(key) : key; +} +function toPublicKey(key: KeyInput): KeyObject { + return typeof key === 'string' ? createPublicKey(key) : key; +} +function toBytes(payload: string | Uint8Array): Uint8Array { + return typeof payload === 'string' ? new TextEncoder().encode(payload) : payload; +} + +/** Generate an Ed25519 keypair as PEM strings (publisher bootstrap / tests). */ +export function generateEd25519KeyPair(): { publicKeyPem: string; privateKeyPem: string } { + const { publicKey, privateKey } = generateKeyPairSync('ed25519'); + return { + publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + }; +} + +/** + * Sign `payload` with an Ed25519 private key, returning the formatted + * signature string `ed25519::`. + */ +export function signPayload( + payload: string | Uint8Array, + privateKey: KeyInput, + keyId = 'default', +): string { + if (keyId.includes(':')) throw new Error('keyId must not contain ":"'); + const sig = cryptoSign(null, toBytes(payload), toPrivateKey(privateKey)); + return `${SIG_PREFIX}${keyId}:${sig.toString('base64url')}`; +} + +export interface ParsedSignature { + alg: 'ed25519'; + keyId: string; + signature: Uint8Array; +} + +/** Parse an `ed25519::` signature string. Returns null if malformed. */ +export function parseSignature(s: string | undefined | null): ParsedSignature | null { + if (typeof s !== 'string' || !s.startsWith(SIG_PREFIX)) return null; + const rest = s.slice(SIG_PREFIX.length); + const idx = rest.indexOf(':'); + if (idx <= 0) return null; + const keyId = rest.slice(0, idx); + const b64 = rest.slice(idx + 1); + if (!keyId || !b64) return null; + try { + return { alg: 'ed25519', keyId, signature: new Uint8Array(Buffer.from(b64, 'base64url')) }; + } catch { + return null; + } +} + +/** Verify a formatted signature string over `payload` with the given public key. */ +export function verifyPayload( + payload: string | Uint8Array, + signature: string, + publicKey: KeyInput, +): boolean { + const parsed = parseSignature(signature); + if (!parsed) return false; + try { + return cryptoVerify(null, toBytes(payload), toPublicKey(publicKey), parsed.signature); + } catch { + return false; + } +} + +/** + * Canonical payload the platform counter-signs at approval. Binds the + * attestation to the version identity + artifact location + the publisher + * signature (which itself binds the artifact bytes). MUST match the cloud + * control plane's `counterSignPayload` exactly. + */ +export function counterSignPayload(version: { + package_id: string; + version: string; + blob_key?: string | null; + signature?: string | null; +}): string { + return [ + version.package_id, + version.version, + version.blob_key ?? '', + version.signature ?? '', + ].join('\n'); +} + +export interface PublisherVerifyResult { + /** Whether loading may proceed on signature grounds. */ + ok: boolean; + /** True when a signature was present AND cryptographically verified. */ + verified: boolean; + reason?: string; +} + +/** + * Verify a publisher signature over the raw artifact bytes. `getPublicKey` + * resolves the verifying key from the signature's embedded keyId. + * + * Mirrors cloud's publish-time policy: + * - no signature → ok, verified=false (caller decides via trust tier). + * - malformed / fails verification → NOT ok. + * - unknown keyId → NOT ok (never silently trust). + */ +export async function verifyPublisherSignature( + args: { artifact: Uint8Array; signature?: string | null }, + getPublicKey?: (keyId: string) => Promise | KeyInput | null, +): Promise { + const sig = args.signature; + if (!sig) return { ok: true, verified: false, reason: 'no signature supplied' }; + + const parsed = parseSignature(sig); + if (!parsed) return { ok: false, verified: false, reason: 'signature is malformed' }; + + if (!getPublicKey) { + return { ok: true, verified: false, reason: 'no publisher key registry configured' }; + } + + const pub = await getPublicKey(parsed.keyId); + if (!pub) return { ok: false, verified: false, reason: `unknown publisher key '${parsed.keyId}'` }; + + return verifyPayload(args.artifact, sig, pub) + ? { ok: true, verified: true } + : { ok: false, verified: false, reason: 'publisher signature does not match artifact' }; +} + +/** Verify a platform counter-signature against the version identity + platform public key. */ +export function verifyPlatformSignature( + version: { + package_id: string; + version: string; + blob_key?: string | null; + signature?: string | null; + platform_signature?: string | null; + }, + platformPublicKey: KeyInput, +): boolean { + if (!version.platform_signature) return false; + return verifyPayload(counterSignPayload(version), version.platform_signature, platformPublicKey); +} + +export interface PluginArtifactVerifyResult { + /** Overall verdict: both required chains satisfied under the given policy. */ + ok: boolean; + publisherVerified: boolean; + platformVerified: boolean; + reason?: string; +} + +/** + * Verify both trust chains for a downloaded plugin artifact at load time + * (ADR-0025 §3.7). The platform counter-signature is the authoritative + * marketplace attestation; the publisher signature additionally binds the + * exact bytes. `requirePlatform` (default true) rejects artifacts that lack + * a valid platform counter-sign — set false for first-party / local builds. + */ +export async function verifyPluginArtifact( + input: { + artifact: Uint8Array; + version: { + package_id: string; + version: string; + blob_key?: string | null; + signature?: string | null; + platform_signature?: string | null; + }; + }, + keys: { + platformPublicKey?: KeyInput; + getPublisherPublicKey?: (keyId: string) => Promise | KeyInput | null; + requirePlatform?: boolean; + }, +): Promise { + const requirePlatform = keys.requirePlatform ?? true; + + const publisher = await verifyPublisherSignature( + { artifact: input.artifact, signature: input.version.signature }, + keys.getPublisherPublicKey, + ); + if (!publisher.ok) { + return { ok: false, publisherVerified: false, platformVerified: false, reason: publisher.reason }; + } + + let platformVerified = false; + if (keys.platformPublicKey) { + platformVerified = verifyPlatformSignature(input.version, keys.platformPublicKey); + } + if (requirePlatform && !platformVerified) { + return { + ok: false, + publisherVerified: publisher.verified, + platformVerified, + reason: keys.platformPublicKey + ? 'platform counter-signature missing or invalid' + : 'no platform public key configured', + }; + } + + return { ok: true, publisherVerified: publisher.verified, platformVerified }; +} From b65181dbc6f0216288ec9e7d5c1ea310f5c1ce08 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:57:34 +0800 Subject: [PATCH 4/5] feat(core): enforce install-time granted_permissions (ADR-0025 F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge the cloud control plane's persisted consent into runtime enforcement. `PluginPermissionEnforcer.registerGrantedPermissions()` and `buildPermissionsFromGrants()` turn the structured grant set cloud writes to `sys_package_installation.granted_permissions` (`{ services, hooks, network, fs }`, ADR §3.2) into the runtime `PluginPermissions` bag that `SecurePluginContext` checks. Matching: exact value, glob (`*` / `**`), or wildcard `*`; network grants match the request URL's host; `fs` governs read and write; a null/empty grant set denies everything (least privilege). This enforces what was GRANTED at install, not merely what the manifest declared — the right default for distributed third-party plugins. Verified: 6 new tests (service/hook/fs/network matching, least-privilege default, enforcer + SecurePluginContext gating); full core suite 275 green. Co-Authored-By: Claude Opus 4.8 --- .../src/security/granted-permissions.test.ts | 89 +++++++++++++++++++ packages/core/src/security/index.ts | 1 + .../security/plugin-permission-enforcer.ts | 81 ++++++++++++++++- 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/security/granted-permissions.test.ts diff --git a/packages/core/src/security/granted-permissions.test.ts b/packages/core/src/security/granted-permissions.test.ts new file mode 100644 index 000000000..af991ac69 --- /dev/null +++ b/packages/core/src/security/granted-permissions.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + PluginPermissionEnforcer, + SecurePluginContext, + buildPermissionsFromGrants, +} from './plugin-permission-enforcer.js'; + +const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +} as any; + +describe('buildPermissionsFromGrants (ADR-0025 F4)', () => { + it('matches services by exact value, glob, and wildcard', () => { + const p = buildPermissionsFromGrants({ services: ['object', 'http.*'] }); + expect(p.canAccessService('object')).toBe(true); + expect(p.canAccessService('http.client')).toBe(true); + expect(p.canAccessService('storage')).toBe(false); + + const all = buildPermissionsFromGrants({ services: ['*'] }); + expect(all.canAccessService('anything')).toBe(true); + }); + + it('matches hooks and treats fs as both read and write', () => { + const p = buildPermissionsFromGrants({ + hooks: ['record.beforeInsert'], + fs: ['/data/**'], + }); + expect(p.canTriggerHook('record.beforeInsert')).toBe(true); + expect(p.canTriggerHook('record.afterDelete')).toBe(false); + expect(p.canReadFile('/data/a/b.txt')).toBe(true); + expect(p.canWriteFile('/data/a/b.txt')).toBe(true); + expect(p.canReadFile('/etc/passwd')).toBe(false); + }); + + it('matches network grants against the request URL host', () => { + const p = buildPermissionsFromGrants({ network: ['api.acme.com'] }); + expect(p.canNetworkRequest('https://api.acme.com/v1/orders')).toBe(true); + expect(p.canNetworkRequest('https://evil.example/steal')).toBe(false); + }); + + it('denies everything for a null / empty grant set (least privilege)', () => { + for (const g of [null, undefined, {}]) { + const p = buildPermissionsFromGrants(g as any); + expect(p.canAccessService('object')).toBe(false); + expect(p.canTriggerHook('x')).toBe(false); + expect(p.canReadFile('/a')).toBe(false); + expect(p.canWriteFile('/a')).toBe(false); + expect(p.canNetworkRequest('https://a.b')).toBe(false); + } + }); +}); + +describe('PluginPermissionEnforcer.registerGrantedPermissions', () => { + it('enforces the granted surface and denies the rest', () => { + const enforcer = new PluginPermissionEnforcer(logger); + enforcer.registerGrantedPermissions('com.acme.p', { services: ['object'], hooks: [] }); + + expect(() => enforcer.enforceServiceAccess('com.acme.p', 'object')).not.toThrow(); + expect(() => enforcer.enforceServiceAccess('com.acme.p', 'storage')).toThrow(/cannot access service storage/); + expect(() => enforcer.enforceHookTrigger('com.acme.p', 'record.beforeInsert')).toThrow(); + }); + + it('SecurePluginContext gates getService against the grant set', () => { + const enforcer = new PluginPermissionEnforcer(logger); + enforcer.registerGrantedPermissions('com.acme.p', { services: ['object'] }); + + const base = { + getService: vi.fn((name: string) => `svc:${name}`), + registerService: vi.fn(), + replaceService: vi.fn(), + getServices: vi.fn(), + hook: vi.fn(), + trigger: vi.fn(), + logger, + getKernel: vi.fn(), + registerServiceFactory: vi.fn(), + getServiceScoped: vi.fn(), + } as any; + + const secure = new SecurePluginContext('com.acme.p', enforcer, base); + expect(secure.getService('object')).toBe('svc:object'); + expect(() => secure.getService('storage')).toThrow(/cannot access service storage/); + }); +}); diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index b22e857b5..b8ee47a64 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -44,6 +44,7 @@ export { PluginPermissionEnforcer, SecurePluginContext, createPluginPermissionEnforcer, + buildPermissionsFromGrants, type PluginPermissions, type PermissionCheckResult, } from './plugin-permission-enforcer.js'; diff --git a/packages/core/src/security/plugin-permission-enforcer.ts b/packages/core/src/security/plugin-permission-enforcer.ts index 4cf66158a..89c736207 100644 --- a/packages/core/src/security/plugin-permission-enforcer.ts +++ b/packages/core/src/security/plugin-permission-enforcer.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Logger } from '@objectstack/spec/contracts'; -import type { PluginCapability } from '@objectstack/spec/kernel'; +import type { PluginCapability, PluginPermissions as GrantedPermissions } from '@objectstack/spec/kernel'; import type { PluginContext } from '../types.js'; /** @@ -87,9 +87,32 @@ export class PluginPermissionEnforcer { }); } + /** + * Register the install-time GRANTED permission set for a plugin + * (ADR-0025 F4). This is the structured `{ services, hooks, network, fs }` + * grant that the cloud control plane persists to + * `sys_package_installation.granted_permissions` after the user consents + * at install (ADR §3.5 step 2). The runtime calls this when materializing + * a third-party plugin so {@link SecurePluginContext} enforces exactly the + * consented surface — independent of whatever the manifest *requested*. + * + * Prefer this over {@link registerPluginPermissions} for distributed + * plugins: it enforces what was granted, not what was declared. + */ + registerGrantedPermissions(pluginName: string, granted: GrantedPermissions | null | undefined): void { + this.permissionRegistry.set(pluginName, buildPermissionsFromGrants(granted)); + this.logger.info(`Granted permissions registered for plugin: ${pluginName}`, { + plugin: pluginName, + services: granted?.services?.length ?? 0, + hooks: granted?.hooks?.length ?? 0, + network: granted?.network?.length ?? 0, + fs: granted?.fs?.length ?? 0, + }); + } + /** * Enforce service access permission - * + * * @param pluginName - Plugin requesting access * @param serviceName - Service to access * @throws Error if permission denied @@ -435,10 +458,62 @@ export class SecurePluginContext implements PluginContext { /** * Create a plugin permission enforcer - * + * * @param logger - Logger instance * @returns Plugin permission enforcer */ export function createPluginPermissionEnforcer(logger: Logger): PluginPermissionEnforcer { return new PluginPermissionEnforcer(logger); } + +/** + * Glob match supporting `*` (within a path segment) and `**` (across + * segments). A bare `*` entry matches everything. + */ +function grantGlobMatch(pattern: string, value: string): boolean { + if (pattern === '*' || pattern === '**') return true; + const regexStr = pattern + .split('**') + .map((segment) => segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')) + .join('.*'); + return new RegExp(`^${regexStr}$`).test(value); +} + +/** Extract the host from a URL for network-grant matching; falls back to the raw value. */ +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +const inList = (list: string[] | undefined, value: string): boolean => + Array.isArray(list) && list.some((p) => p === value || grantGlobMatch(p, value)); + +/** + * Build the runtime {@link PluginPermissions} bag from a structured + * install-time grant set (ADR-0025 §3.2 `{ services, hooks, network, fs }`). + * + * Matching: an entry allows when it equals the requested value, is a glob + * that matches it, or is the wildcard `*`. Network grants match against the + * request URL's host (or the raw URL). `fs` governs both read and write — + * the structured grant set does not split the two. A null/empty grant set + * denies everything (principle of least privilege). + */ +export function buildPermissionsFromGrants( + granted: GrantedPermissions | null | undefined, +): PluginPermissions { + const services = granted?.services; + const hooks = granted?.hooks; + const network = granted?.network; + const fs = granted?.fs; + return { + canAccessService: (name) => inList(services, name), + canTriggerHook: (name) => inList(hooks, name), + canReadFile: (path) => inList(fs, path), + canWriteFile: (path) => inList(fs, path), + canNetworkRequest: (url) => + inList(network, hostOf(url)) || inList(network, url), + }; +} From 6dc5ea1231b67348a55267b5e5d97a10581a88d7 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:57:34 +0800 Subject: [PATCH 5/5] feat(spec): plugin fields on uploadArtifact contract (ADR-0025 F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend UploadArtifactInput/Result so the IPackageService upload path carries code-bearing `.osplugin` plugins alongside metadata packages, aligned with the cloud control plane's publish flow: - Input: `kind` ('metadata' | 'plugin'), detached `signature` (`ed25519::`), `expectedChecksum` (artifact sha256). - Result: `versionId`, `listingStatus` (e.g. pending_review), and `signatureVerified`. All additive + optional — metadata packages are unaffected. Co-Authored-By: Claude Opus 4.8 --- .../spec/src/contracts/package-service.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/spec/src/contracts/package-service.ts b/packages/spec/src/contracts/package-service.ts index 5b736d5e2..faa94ef5a 100644 --- a/packages/spec/src/contracts/package-service.ts +++ b/packages/spec/src/contracts/package-service.ts @@ -152,6 +152,11 @@ export interface RollbackInput { /** * Input for uploading a package artifact. + * + * Carries both pure-metadata packages and code-bearing `.osplugin` plugins + * (ADR-0025): the plugin fields below are optional and absent for metadata + * packages. They align with the cloud control plane's plugin publish path + * (`preparePluginVersion` / `verifyPublisherSignature`). */ export interface UploadArtifactInput { /** Package artifact metadata */ @@ -160,6 +165,26 @@ export interface UploadArtifactInput { content: string; /** Publisher authentication token */ token?: string; + + // ---- Plugin distribution (ADR-0025) ---- + + /** + * Artifact discriminator. `plugin` triggers the signed/permissioned + * path (verify signature → permission audit → store blob → pending review); + * `metadata` (default) is the legacy pure-metadata fast path. + */ + kind?: 'metadata' | 'plugin'; + /** + * Detached publisher signature over the raw artifact bytes, in the + * canonical `ed25519::` format (produced by + * `os plugin sign`). Verified server-side before the version is stored. + */ + signature?: string; + /** + * Expected SHA-256 (hex) of the artifact bytes for tamper detection on + * upload. The server recomputes and rejects on mismatch. + */ + expectedChecksum?: string; } /** @@ -174,6 +199,22 @@ export interface UploadArtifactResult { sha256?: string; /** Error message if upload failed */ errorMessage?: string; + + // ---- Plugin distribution (ADR-0025) ---- + + /** Identifier of the created package version row (e.g. sys_package_version id). */ + versionId?: string; + /** + * Listing/review state after upload — e.g. `pending_review` when a plugin + * requires human approval before it can be installed (ADR §3.7). + */ + listingStatus?: string; + /** + * Whether the publisher signature was present AND cryptographically + * verified. `false` means accepted-but-unverified (trust tier gates apply) + * or no signature supplied. + */ + signatureVerified?: boolean; } // ==========================================