diff --git a/.github/workflows/plugin-api-publish.yml b/.github/workflows/plugin-api-publish.yml new file mode 100644 index 00000000..70ef77ac --- /dev/null +++ b/.github/workflows/plugin-api-publish.yml @@ -0,0 +1,46 @@ +name: Publish @mindgraph/plugin-api + +# Maintainer-Schritt (manuell). Publish via npm Trusted Publishing (OIDC) + Provenance — KEIN +# langlebiger npm-Token als Secret. Standardmäßig Dry-run; echter Publish nur mit dry_run=false +# und freigegebenem Environment 'npm-publish'. + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Nur testen (npm publish --dry-run), nicht wirklich veröffentlichen' + type: boolean + default: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + id-token: write # NUR dieser Job: npm Trusted Publishing (OIDC). Keine weiteren Scopes. + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: npm + cache-dependency-path: app/package-lock.json + - name: Install (Workspace, inkl. Paket-Deps) + run: npm ci + working-directory: app + - name: npm aktualisieren (Trusted Publishing/OIDC braucht npm >= 11.5) + run: npm install -g npm@latest + - name: Publish (OIDC + provenance) + working-directory: app/packages/plugin-api + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + echo "Dry-run — kein echter Publish." + npm publish --provenance --access public --dry-run + else + npm publish --provenance --access public + fi diff --git a/.github/workflows/plugin-artifact-ci.yml b/.github/workflows/plugin-artifact-ci.yml new file mode 100644 index 00000000..7dd5202d --- /dev/null +++ b/.github/workflows/plugin-artifact-ci.yml @@ -0,0 +1,43 @@ +name: Plugin Artifact CI + +# PR-/Push-Gate: führt die Artefakt-Pipeline aus (Template build → mit Dev-Key signieren → +# mit passendem Test-Keyring verifizieren). Umgesetzt als vitest-E2E-Test (artifact/e2e.test.ts), +# weil das Template @mindgraph/plugin-api erst nach dem npm-Publish via `npm install` beziehen kann; +# der E2E-Test bundelt stattdessen gegen die Workspace-Quelle. + +on: + pull_request: + paths: + - 'app/**' + - 'plugin-template/**' + - '.github/workflows/plugin-artifact-ci.yml' + push: + branches: [master] + paths: + - 'app/**' + - 'plugin-template/**' + - '.github/workflows/plugin-artifact-ci.yml' + +permissions: + contents: read + +jobs: + pipeline: + name: build → sign(dev) → verify(test keyring) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + cache: npm + cache-dependency-path: app/package-lock.json + - name: Install (app + workspace plugin-api) + run: npm ci + working-directory: app + - name: Typecheck (App + Paket) + run: npm run typecheck + working-directory: app + - name: Artefakt-Pipeline + Verifier (build → sign(dev) → verify) + run: npm run test + working-directory: app diff --git a/.github/workflows/plugin-release-sign.yml b/.github/workflows/plugin-release-sign.yml new file mode 100644 index 00000000..d83f21bd --- /dev/null +++ b/.github/workflows/plugin-release-sign.yml @@ -0,0 +1,42 @@ +name: Sign Plugin Release (Produktion) + +# Vom Build GETRENNT und NICHT automatisch: nur manuell und ausschließlich im geschützten +# Environment 'release-signing' (Required Reviewers + Secret PLUGIN_SIGNING_KEY). Standardmäßig +# Dry-run: prüft nur Verfügbarkeit/Form des Prod-Keys, signiert nichts. Der echte Prod-Sign + Pack +# bleibt ein Maintainer-Schritt (wird nach Bereitstellung des offiziellen Pack-CLI scharf geschaltet). + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Nur Key-Verfügbarkeit/-Form prüfen (nicht signieren)' + type: boolean + default: true + +permissions: + contents: read + +jobs: + sign: + runs-on: ubuntu-latest + environment: release-signing # geschützt: Required Reviewers + Secret PLUGIN_SIGNING_KEY + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + - name: Prod-Signierschlüssel prüfen (Form, ohne den Wert zu loggen) + env: + PLUGIN_SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }} + run: | + if [ -z "$PLUGIN_SIGNING_KEY" ]; then + echo "::error::PLUGIN_SIGNING_KEY fehlt — Environment 'release-signing' nicht freigegeben." + exit 1 + fi + node -e "const {createPrivateKey}=require('node:crypto');const k=createPrivateKey(process.env.PLUGIN_SIGNING_KEY);if(k.asymmetricKeyType!=='ed25519'){console.error('Kein Ed25519-Schlüssel');process.exit(1)}console.log('Prod-Key ok: Ed25519, PKCS#8.')" + - name: Prod-Sign + Pack + if: ${{ inputs.dry_run == false }} + run: | + echo "::error::Echter Prod-Sign + Pack ist noch nicht scharf geschaltet." + echo "Wird über das offizielle Pack-/Signier-CLI ausgeführt (Maintainer-Aktivierung)." + exit 1 diff --git a/.gitignore b/.gitignore index 75e11225..a7eac276 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ pitch/__pycache__/ # Persönliche Projekt-RAG-Eval-Testsets (echte Namen/Projektdaten) — nur example.json wird versioniert app/scripts/rag-eval.*.json !app/scripts/rag-eval.example.json + +# @mindgraph/plugin-api npm-Publish-Output (nur via prepublishOnly gebaut) +app/packages/plugin-api/dist/ diff --git a/app/package-lock.json b/app/package-lock.json index 439330b5..d1d7c060 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -51,6 +51,7 @@ "react-window": "^2.2.5", "reactflow": "^11.11.4", "sql.js": "^1.13.0", + "tar": "^7.5.19", "turndown": "^7.2.4", "ws": "^8.18.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -2052,7 +2053,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -4868,7 +4868,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -8261,7 +8260,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -8401,7 +8399,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -10028,10 +10025,9 @@ } }, "node_modules/tar": { - "version": "7.5.17", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.17.tgz", - "integrity": "sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==", - "dev": true, + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -10048,7 +10044,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=18" diff --git a/app/package.json b/app/package.json index 2e4a4ea2..fa35c3ef 100644 --- a/app/package.json +++ b/app/package.json @@ -173,6 +173,7 @@ "react-window": "^2.2.5", "reactflow": "^11.11.4", "sql.js": "^1.13.0", + "tar": "^7.5.19", "turndown": "^7.2.4", "ws": "^8.18.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", diff --git a/app/packages/plugin-api/package.json b/app/packages/plugin-api/package.json index 924b643f..c2da0596 100644 --- a/app/packages/plugin-api/package.json +++ b/app/packages/plugin-api/package.json @@ -1,15 +1,35 @@ { "name": "@mindgraph/plugin-api", "version": "0.2.0", - "private": true, "description": "Stabile öffentliche Grenze zwischen MindGraph und seinen Plugins (Typen + schmale Laufzeit-Helfer).", "type": "module", "exports": { ".": "./src/index.ts", "./validation": "./src/validation.ts" }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./validation": { + "types": "./dist/validation.d.ts", + "default": "./dist/validation.js" + } + } + }, "scripts": { - "typecheck": "tsc -p tsconfig.json" + "typecheck": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.build.json && echo '{ \"type\": \"commonjs\" }' > dist/package.json", + "prepublishOnly": "npm run build" }, "dependencies": { "ajv": "^6.14.0", diff --git a/app/packages/plugin-api/tsconfig.build.json b/app/packages/plugin-api/tsconfig.build.json new file mode 100644 index 00000000..32d16d7e --- /dev/null +++ b/app/packages/plugin-api/tsconfig.build.json @@ -0,0 +1,18 @@ +// Emit-Konfiguration NUR für den npm-Publish (dist/). Die In-Repo-Konsumption läuft weiter +// über die TS-Quelle (exports → src, App-Alias, vitest). Hier wird CommonJS + .d.ts erzeugt; +// ein `dist/package.json` mit { "type": "commonjs" } (vom build-Script geschrieben) markiert das +// dist-Verzeichnis als CJS, obwohl das Paket-Root `"type": "module"` bleibt. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/app/src/main/plugins/artifact/e2e.test.ts b/app/src/main/plugins/artifact/e2e.test.ts new file mode 100644 index 00000000..6a3ed255 --- /dev/null +++ b/app/src/main/plugins/artifact/e2e.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { generateKeyPairSync } from 'node:crypto' +import { createRequire } from 'node:module' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { packPluginArtifact } from './pack' +import { verifyPluginArtifact, type Keyring } from './verify' +import { canonicalJsonBytes } from './format' +import type { PluginMainEntry } from '@mindgraph/plugin-api' + +// End-to-End-Pipeline (ADR docs/plugin-artifact-format-plan.md): das Main-only-Template wird mit +// derselben esbuild-Konfiguration gebaut wie im echten Repo, mit einem Dev-Key signiert, verifiziert +// und das verifizierte main.js per createRequire aus einem Ordner OHNE package.json geladen. Deckt +// die ADR-Akzeptanzkriterien 2–4 ab (build → sign → verify → CJS-Lade-Test). + +const here = dirname(fileURLToPath(import.meta.url)) +const repoRoot = resolve(here, '../../../../..') +const templateDir = join(repoRoot, 'plugin-template') +const apiSrc = join(repoRoot, 'app/packages/plugin-api/src/index.ts') + +const tmpDirs: string[] = [] +afterAll(() => { + for (const d of tmpDirs) rmSync(d, { recursive: true, force: true }) +}) + +async function loadBuildConfig(): Promise<{ + PLUGIN_BUILD_OPTIONS: Record + banBuiltins: () => unknown +}> { + return (await import(pathToFileURL(join(templateDir, 'esbuild.config.mjs')).href)) as never +} + +async function bundleTemplateMain(): Promise { + const esbuild = await import('esbuild') + const cfg = await loadBuildConfig() + const result = await esbuild.build({ + ...cfg.PLUGIN_BUILD_OPTIONS, + entryPoints: [join(templateDir, 'src/main.ts')], + bundle: true, + write: false, + alias: { '@mindgraph/plugin-api': apiSrc }, + plugins: [cfg.banBuiltins() as never], + }) + return Buffer.from(result.outputFiles![0].contents) +} + +describe('A0/3 E2E — Template: build → sign → verify → createRequire', () => { + it('baut, signiert (Dev-Key), verifiziert und lädt main.js als CommonJS ohne package.json', async () => { + const mainJs = await bundleTemplateMain() + const manifestBytes = canonicalJsonBytes(JSON.parse(readFileSync(join(templateDir, 'manifest.json'), 'utf8'))) + + const kp = generateKeyPairSync('ed25519') + const keyId = 'dev-e2e' + const archive = await packPluginArtifact({ + files: [ + { path: 'manifest.json', content: manifestBytes }, + { path: 'main.js', content: mainJs }, + ], + signKey: kp.privateKey, + keyId, + }) + + const quarantineDir = mkdtempSync(join(tmpdir(), 'mgx-e2e-')) + tmpDirs.push(quarantineDir) + const keyring: Keyring = { get: (id) => (id === keyId ? kp.publicKey : undefined) } + + const result = await verifyPluginArtifact(archive, { keyring, appVersion: '0.8.14', quarantineDir }) + expect(result.id).toBe('example-plugin') + expect(result.files.map((f) => f.path).sort()).toEqual(['main.js', 'manifest.json']) + + // ADR-Kriterium: verifiziertes main.js aus einem Ordner OHNE package.json via createRequire laden. + const req = createRequire(join(quarantineDir, 'loader.cjs')) + const entry = req(join(quarantineDir, 'main.js')) as PluginMainEntry + expect(entry.id).toBe('example-plugin') + expect(typeof entry.register).toBe('function') + }) + + it('der Build verbietet Node-/Electron-Built-ins', async () => { + const esbuild = await import('esbuild') + const cfg = await loadBuildConfig() + await expect( + esbuild.build({ + ...cfg.PLUGIN_BUILD_OPTIONS, + stdin: { contents: "import 'node:fs'\nexport default {}", resolveDir: templateDir, loader: 'ts' }, + bundle: true, + write: false, + plugins: [cfg.banBuiltins() as never], + }) + ).rejects.toThrow(/Built-in/) + }) +}) diff --git a/app/src/main/plugins/artifact/format.test.ts b/app/src/main/plugins/artifact/format.test.ts new file mode 100644 index 00000000..19a913c6 --- /dev/null +++ b/app/src/main/plugins/artifact/format.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { + canonicalJsonString, + canonicalJsonBytes, + parseUtf8Json, + artifactPathError, + assertArtifactPath, + validateIntegrityDoc, + validateSigEnvelope, + type IntegrityDoc, +} from './format' +import { ArtifactError, ARTIFACT_LIMITS } from './limits' + +describe('kanonische JSON-Bytes', () => { + it('ist JSON.stringify(v,null,2) + LF, ohne BOM', () => { + const s = canonicalJsonString({ b: 1, a: 2 }) + expect(s).toBe('{\n "b": 1,\n "a": 2\n}\n') + const buf = canonicalJsonBytes({ x: 1 }) + expect(buf[0]).not.toBe(0xef) // kein BOM + expect(buf[buf.length - 1]).toBe(0x0a) // endet auf LF + }) +}) + +describe('parseUtf8Json', () => { + it('akzeptiert gültiges JSON', () => { + expect(parseUtf8Json(Buffer.from('{"a":1}', 'utf8'), 'json-invalid', 'x')).toEqual({ a: 1 }) + }) + it('lehnt BOM ab', () => { + const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{}', 'utf8')]) + expect(() => parseUtf8Json(withBom, 'json-invalid', 'x')).toThrow(/BOM/) + }) + it('lehnt kaputtes JSON ab', () => { + expect(() => parseUtf8Json(Buffer.from('{nope', 'utf8'), 'json-invalid', 'x')).toThrow(/JSON/) + }) +}) + +describe('artifactPathError / assertArtifactPath', () => { + it('akzeptiert gültige relative lowercase-Pfade', () => { + for (const p of ['main.js', 'assets/icon.png', 'a/b/c/d.txt', '..js', 'manifest.json']) { + expect(artifactPathError(p), p).toBeNull() + } + }) + + it('lehnt die typischen Angriffe ab', () => { + expect(artifactPathError('')).toMatch(/leer/) + expect(artifactPathError('/abs/main.js')).toMatch(/absolut/) + expect(artifactPathError('a\\b.js')).toMatch(/Backslash/) + expect(artifactPathError('Main.js')).toMatch(/Großbuchstaben/) + expect(artifactPathError('a b.js')).toMatch(/unerlaubte Zeichen/) + expect(artifactPathError('./main.js')).toMatch(/führendes/) + expect(artifactPathError('../main.js')).toMatch(/'\.\.'-Segment/) + expect(artifactPathError('a/../b.js')).toMatch(/'\.\.'-Segment/) + expect(artifactPathError('a/./b.js')).toMatch(/'\.'-Segment/) + expect(artifactPathError('a//b.js')).toMatch(/leeres Pfadsegment/) + expect(artifactPathError('a/b/c.js/')).toMatch(/leeres Pfadsegment/) + }) + + it('erzwingt Längen-, Tiefen- und Segment-Limits', () => { + expect(artifactPathError('a'.repeat(241))).toMatch(/länger als 240/) + expect(artifactPathError('a/b/c/d/e/f/g/h/i.js')).toMatch(/Pfadtiefe/) // 9 Segmente + expect(artifactPathError('x'.repeat(101) + '.js')).toMatch(/100 ASCII-Bytes/) // Segment-Länge 104 + expect(artifactPathError('s'.repeat(101))).toMatch(/100 ASCII-Bytes/) + }) + + it('assertArtifactPath wirft ArtifactError(path-invalid)', () => { + expect(() => assertArtifactPath('../x')).toThrowError(ArtifactError) + try { + assertArtifactPath('/x') + } catch (e) { + expect((e as ArtifactError).code).toBe('path-invalid') + } + }) +}) + +const validIntegrity = (): IntegrityDoc => ({ + formatVersion: 1, + algorithm: 'sha256', + files: [ + { path: 'main.js', size: 10, sha256: 'a'.repeat(64) }, + { path: 'manifest.json', size: 20, sha256: 'b'.repeat(64) }, + ], +}) + +describe('validateIntegrityDoc', () => { + it('akzeptiert ein gültiges, sortiertes Dokument', () => { + expect(validateIntegrityDoc(validIntegrity()).files).toHaveLength(2) + }) + + it('lehnt falsche formatVersion/algorithm ab', () => { + expect(() => validateIntegrityDoc({ ...validIntegrity(), formatVersion: 2 })).toThrow(/formatVersion/) + expect(() => validateIntegrityDoc({ ...validIntegrity(), algorithm: 'sha1' })).toThrow(/algorithm/) + }) + + it('lehnt Fremd-Keys ab (top-level und Eintrag)', () => { + expect(() => validateIntegrityDoc({ ...validIntegrity(), extra: 1 })).toThrow(/erwartet/) + const bad = validIntegrity() + ;(bad.files[0] as unknown as Record).mode = 0o755 + expect(() => validateIntegrityDoc(bad)).toThrow(/erwartet \{path, size, sha256\}/) + }) + + it('lehnt ungültige size/sha256 ab', () => { + expect(() => validateIntegrityDoc({ ...validIntegrity(), files: [{ path: 'a.js', size: -1, sha256: 'a'.repeat(64) }] })).toThrow(/size/) + expect(() => validateIntegrityDoc({ ...validIntegrity(), files: [{ path: 'a.js', size: 1.5, sha256: 'a'.repeat(64) }] })).toThrow(/size/) + expect(() => validateIntegrityDoc({ ...validIntegrity(), files: [{ path: 'a.js', size: 1, sha256: 'A'.repeat(64) }] })).toThrow(/sha256/) // uppercase + expect(() => validateIntegrityDoc({ ...validIntegrity(), files: [{ path: 'a.js', size: 1, sha256: 'a'.repeat(63) }] })).toThrow(/sha256/) // zu kurz + }) + + it('lehnt unsortierte und doppelte Pfade ab', () => { + const unsorted = { ...validIntegrity(), files: [validIntegrity().files[1], validIntegrity().files[0]] } + expect(() => validateIntegrityDoc(unsorted)).toThrow(/sortiert/) + const dup = { ...validIntegrity(), files: [validIntegrity().files[0], validIntegrity().files[0]] } + expect(() => validateIntegrityDoc(dup)).toThrow(/sortiert|eindeutig/) + }) + + it('lehnt ungültige Pfade im Eintrag ab', () => { + expect(() => validateIntegrityDoc({ ...validIntegrity(), files: [{ path: '../x', size: 1, sha256: 'a'.repeat(64) }] })).toThrow(/path/) + }) +}) + +const sigB64 = (n = 64) => Buffer.alloc(n).toString('base64') +const validSig = () => ({ formatVersion: 1, algorithm: 'ed25519', keyId: 'k1', signature: sigB64() }) + +describe('validateSigEnvelope', () => { + it('akzeptiert eine gültige Hülle und liefert 64 Byte Signatur', () => { + const { envelope, signature } = validateSigEnvelope(validSig()) + expect(envelope.keyId).toBe('k1') + expect(signature).toHaveLength(64) + }) + + it('lehnt falsche formatVersion/algorithm/leere keyId ab', () => { + expect(() => validateSigEnvelope({ ...validSig(), formatVersion: 2 })).toThrow(/formatVersion/) + expect(() => validateSigEnvelope({ ...validSig(), algorithm: 'rsa' })).toThrow(/algorithm/) + expect(() => validateSigEnvelope({ ...validSig(), keyId: '' })).toThrow(/keyId/) + }) + + it('lehnt nicht-kanonisches Base64 und falsche Länge ab', () => { + expect(() => validateSigEnvelope({ ...validSig(), signature: 'not base64!!' })).toThrow(/Base64/) + expect(() => validateSigEnvelope({ ...validSig(), signature: sigB64(32) })).toThrow(/64 Bytes/) + }) + + it('lehnt Fremd-Keys ab', () => { + expect(() => validateSigEnvelope({ ...validSig(), kid: 'x' })).toThrow(/erwartet/) + }) +}) + +describe('Limits-Konstanten', () => { + it('entsprechen der ADR-Tabelle', () => { + expect(ARTIFACT_LIMITS.maxFiles).toBe(512) + expect(ARTIFACT_LIMITS.maxSegmentBytes).toBe(100) + expect(ARTIFACT_LIMITS.maxPathDepth).toBe(8) + }) +}) diff --git a/app/src/main/plugins/artifact/format.ts b/app/src/main/plugins/artifact/format.ts new file mode 100644 index 00000000..8511371d --- /dev/null +++ b/app/src/main/plugins/artifact/format.ts @@ -0,0 +1,196 @@ +// Reine Format-/Normalisierungs-Logik des Plugin-Artefakts (A0/3) — KEIN I/O, keine Krypto. +// Single-Source der Regeln, die Writer (Packer) und Verifier identisch anwenden MÜSSEN: +// kanonische JSON-Bytes, Pfad-Normalisierung, integrity.json- und .sig-Form. +// Siehe docs/plugin-artifact-format-plan.md. + +import { ArtifactError, type ArtifactErrorCode, type ArtifactLimits, ARTIFACT_LIMITS } from './limits' + +// — Kanonische JSON-Bytes (verbindlich für manifest.json, integrity.json, .sig) — + +/** `JSON.stringify(value, null, 2) + '\n'` — UTF-8/LF/kein BOM/feste Feldreihenfolge. */ +export function canonicalJsonString(value: unknown): string { + return JSON.stringify(value, null, 2) + '\n' +} + +/** Kanonische UTF-8-Bytes eines Werts (das, was gehasht/signiert wird). */ +export function canonicalJsonBytes(value: unknown): Buffer { + return Buffer.from(canonicalJsonString(value), 'utf8') +} + +/** Parst UTF-8-JSON streng: kein BOM, gültiges JSON. Wirft `ArtifactError(code)` bei Verstoß. */ +export function parseUtf8Json(buf: Buffer, code: ArtifactErrorCode, what: string): unknown { + if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { + throw new ArtifactError(code, `${what}: UTF-8-BOM nicht erlaubt`) + } + try { + return JSON.parse(buf.toString('utf8')) + } catch { + throw new ArtifactError(code, `${what}: kein gültiges JSON`) + } +} + +// — Pfad-Normalisierung (Archiv-Einträge UND integrity.files[].path, identische Regel) — + +/** + * Gibt einen Fehlertext zurück, wenn der Pfad die strengen Artefakt-Regeln verletzt, sonst null: + * lowercase ASCII POSIX (`a–z 0–9 . _ - /`), relativ, kein `./`, kein `..`/`.`-Segment, kein + * absoluter Pfad, kein Backslash; Länge/Tiefe/Segment-Bytes innerhalb der Limits. + */ +export function artifactPathError(p: unknown, limits: ArtifactLimits = ARTIFACT_LIMITS): string | null { + if (typeof p !== 'string' || p.length === 0) return 'leerer Pfad' + if (p.length > limits.maxPathLength) return `Pfad länger als ${limits.maxPathLength} Zeichen` + if (p.includes('\\')) return 'Backslash nicht erlaubt' + if (p.startsWith('/')) return 'absoluter Pfad nicht erlaubt' + if (/[A-Z]/.test(p)) return 'Großbuchstaben nicht erlaubt (nur lowercase ASCII)' + if (!/^[a-z0-9._/-]+$/.test(p)) return 'unerlaubte Zeichen (nur a–z 0–9 . _ - /)' + if (p.startsWith('./')) return "führendes './' nicht erlaubt" + const segs = p.split('/') + if (segs.length > limits.maxPathDepth) return `Pfadtiefe größer als ${limits.maxPathDepth}` + for (const s of segs) { + if (s.length === 0) return 'leeres Pfadsegment (// oder Slash am Ende)' + if (s === '.' || s === '..') return `'${s}'-Segment nicht erlaubt` + // Nur ASCII erlaubt → byteLength == length; explizit als Bytes geprüft (USTAR-Namensfeld). + if (Buffer.byteLength(s, 'utf8') > limits.maxSegmentBytes) { + return `Pfadsegment länger als ${limits.maxSegmentBytes} ASCII-Bytes` + } + } + return null +} + +/** Wirft `ArtifactError('path-invalid')`, wenn der Pfad ungültig ist. */ +export function assertArtifactPath(p: unknown, limits: ArtifactLimits = ARTIFACT_LIMITS): string { + const err = artifactPathError(p, limits) + if (err) throw new ArtifactError('path-invalid', `Ungültiger Pfad '${String(p)}': ${err}`) + return p as string +} + +// — integrity.json — + +export const INTEGRITY_FORMAT_VERSION = 1 +export const INTEGRITY_ALGORITHM = 'sha256' + +export interface IntegrityEntry { + path: string + size: number + sha256: string +} + +export interface IntegrityDoc { + formatVersion: number + algorithm: string + files: IntegrityEntry[] +} + +const SHA256_HEX = /^[0-9a-f]{64}$/ +const PLAIN_OBJECT = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v) +const hasExactKeys = (o: Record, keys: string[]): boolean => { + const k = Object.keys(o) + return k.length === keys.length && keys.every((key) => key in o) +} + +/** + * Validiert ein bereits geparstes integrity-Dokument streng und gibt es typisiert zurück. + * Regeln (ADR): formatVersion==1, algorithm=='sha256', `files` als **strikt nach path sortierte + * und eindeutige** Liste; je Eintrag genau {path,size,sha256}, path gültig, size nichtnegativer + * Safe-Integer, sha256 == 64 lowercase-hex. Keine Fremd-Keys. + */ +export function validateIntegrityDoc(value: unknown, limits: ArtifactLimits = ARTIFACT_LIMITS): IntegrityDoc { + if (!PLAIN_OBJECT(value) || !hasExactKeys(value, ['formatVersion', 'algorithm', 'files'])) { + throw new ArtifactError('integrity-invalid', 'integrity.json: erwartet {formatVersion, algorithm, files}') + } + if (value.formatVersion !== INTEGRITY_FORMAT_VERSION) { + throw new ArtifactError('integrity-invalid', `integrity.json: formatVersion muss ${INTEGRITY_FORMAT_VERSION} sein`) + } + if (value.algorithm !== INTEGRITY_ALGORITHM) { + throw new ArtifactError('integrity-invalid', `integrity.json: algorithm muss '${INTEGRITY_ALGORITHM}' sein`) + } + if (!Array.isArray(value.files)) { + throw new ArtifactError('integrity-invalid', 'integrity.json: files muss eine Liste sein') + } + let prev: string | null = null + const files: IntegrityEntry[] = value.files.map((raw, i) => { + if (!PLAIN_OBJECT(raw) || !hasExactKeys(raw, ['path', 'size', 'sha256'])) { + throw new ArtifactError('integrity-invalid', `integrity.files[${i}]: erwartet {path, size, sha256}`) + } + const pErr = artifactPathError(raw.path, limits) + if (pErr) throw new ArtifactError('integrity-invalid', `integrity.files[${i}].path: ${pErr}`) + if ( + typeof raw.size !== 'number' || + !Number.isSafeInteger(raw.size) || + raw.size < 0 + ) { + throw new ArtifactError('integrity-invalid', `integrity.files[${i}].size: nichtnegativer Safe-Integer erwartet`) + } + if (typeof raw.sha256 !== 'string' || !SHA256_HEX.test(raw.sha256)) { + throw new ArtifactError('integrity-invalid', `integrity.files[${i}].sha256: 64 lowercase-hex erwartet`) + } + const path = raw.path as string + if (prev !== null && !(path > prev)) { + throw new ArtifactError( + 'integrity-invalid', + `integrity.files: nicht strikt nach path sortiert/eindeutig ('${prev}' vor '${path}')` + ) + } + prev = path + return { path, size: raw.size, sha256: raw.sha256 } + }) + return { formatVersion: INTEGRITY_FORMAT_VERSION, algorithm: INTEGRITY_ALGORITHM, files } +} + +// — .sig-Hülle — + +export const SIG_FORMAT_VERSION = 1 +export const SIG_ALGORITHM = 'ed25519' +const ED25519_SIG_BYTES = 64 + +export interface SigEnvelope { + formatVersion: number + algorithm: string + keyId: string + signature: string +} + +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/ +/** Kanonisches Base64: re-encode der dekodierten Bytes ergibt exakt den Eingabe-String. */ +function isCanonicalBase64(s: string): boolean { + if (!BASE64.test(s)) return false + return Buffer.from(s, 'base64').toString('base64') === s +} + +/** Validiert die geparste `.sig`-Hülle streng und gibt die dekodierte 64-Byte-Signatur mit zurück. */ +export function validateSigEnvelope(value: unknown): { envelope: SigEnvelope; signature: Buffer } { + if (!PLAIN_OBJECT(value) || !hasExactKeys(value, ['formatVersion', 'algorithm', 'keyId', 'signature'])) { + throw new ArtifactError('sig-invalid', '.sig: erwartet {formatVersion, algorithm, keyId, signature}') + } + if (value.formatVersion !== SIG_FORMAT_VERSION) { + throw new ArtifactError('sig-invalid', `.sig: formatVersion muss ${SIG_FORMAT_VERSION} sein`) + } + if (value.algorithm !== SIG_ALGORITHM) { + throw new ArtifactError('sig-invalid', `.sig: algorithm muss '${SIG_ALGORITHM}' sein`) + } + if (typeof value.keyId !== 'string' || value.keyId.length === 0) { + throw new ArtifactError('sig-invalid', '.sig: keyId muss ein nichtleerer String sein') + } + if (typeof value.signature !== 'string' || !isCanonicalBase64(value.signature)) { + throw new ArtifactError('sig-invalid', '.sig: signature muss kanonisches Base64 sein') + } + const signature = Buffer.from(value.signature, 'base64') + if (signature.length !== ED25519_SIG_BYTES) { + throw new ArtifactError('sig-invalid', `.sig: signature muss nach Decode ${ED25519_SIG_BYTES} Bytes sein`) + } + return { + envelope: { + formatVersion: SIG_FORMAT_VERSION, + algorithm: SIG_ALGORITHM, + keyId: value.keyId as string, + signature: value.signature as string, + }, + signature, + } +} + +/** Reservierte Dateinamen, die NICHT in integrity.files stehen (sie tragen/sind die Integrität). */ +export const MANIFEST_FILE = 'manifest.json' +export const INTEGRITY_FILE = 'integrity.json' +export const SIG_FILE = 'integrity.json.sig' diff --git a/app/src/main/plugins/artifact/limits.ts b/app/src/main/plugins/artifact/limits.ts new file mode 100644 index 00000000..5b99d165 --- /dev/null +++ b/app/src/main/plugins/artifact/limits.ts @@ -0,0 +1,66 @@ +// Harte Grenzen + Fehlertaxonomie für das Plugin-Artefaktformat (A0/3, ADR +// docs/plugin-artifact-format-plan.md). Rein, ohne I/O — von Verifier UND (Test-)Packer geteilt, +// damit Writer und Verifier nachweislich dieselben Regeln verwenden. + +/** Archiv-/Pfadlimits gegen Archive-Bombs und Ressourcen-Erschöpfung (überschreibbar in Tests). */ +export interface ArtifactLimits { + maxFiles: number + maxArchiveBytes: number + maxTotalUnpackedBytes: number + maxFileBytes: number + maxManifestBytes: number + maxIntegrityBytes: number + maxSigBytes: number + maxPathLength: number + maxPathDepth: number + maxSegmentBytes: number +} + +/** Default-Limits = ADR-Tabelle. */ +export const ARTIFACT_LIMITS: ArtifactLimits = { + maxFiles: 512, + maxArchiveBytes: 100 * 1024 * 1024, // 100 MiB komprimiert + maxTotalUnpackedBytes: 250 * 1024 * 1024, // 250 MiB entpackt (Summe) + maxFileBytes: 100 * 1024 * 1024, // 100 MiB pro Datei + maxManifestBytes: 1024 * 1024, // 1 MiB + maxIntegrityBytes: 1024 * 1024, // 1 MiB + maxSigBytes: 16 * 1024, // 16 KiB + maxPathLength: 240, + maxPathDepth: 8, + maxSegmentBytes: 100, // USTAR-Namensfeld (kein PAX) → ≤ 100 ASCII-Bytes pro Segment +} + +/** + * Maschinenlesbarer Grund einer Artefakt-Ablehnung — analog zu `PluginErrorKind` (A0/2), aber für + * die Verpackungs-/Integritätsebene. Jeder Wert entspricht genau einem Prüfschritt im Verifier. + */ +export type ArtifactErrorCode = + | 'archive-too-large' + | 'entry-type' // kein reguläres File (Dir/Symlink/Hardlink/Device/PAX/Global-Header) + | 'path-invalid' + | 'duplicate-path' + | 'limit-files' + | 'limit-file-size' + | 'limit-total-size' + | 'json-invalid' + | 'integrity-invalid' + | 'sig-invalid' + | 'sig-unknown-key' + | 'sig-mismatch' + | 'hash-mismatch' + | 'size-mismatch' + | 'fileset-mismatch' + | 'manifest-invalid' + | 'incompatible-api' + | 'incompatible-app' + | 'entrypoint-missing' + +/** Terminaler Ablehnungsgrund eines Artefakts. `code` ist die maschinenlesbare Ursache. */ +export class ArtifactError extends Error { + readonly code: ArtifactErrorCode + constructor(code: ArtifactErrorCode, message: string) { + super(message) + this.name = 'ArtifactError' + this.code = code + } +} diff --git a/app/src/main/plugins/artifact/pack.ts b/app/src/main/plugins/artifact/pack.ts new file mode 100644 index 00000000..205a91a3 --- /dev/null +++ b/app/src/main/plugins/artifact/pack.ts @@ -0,0 +1,98 @@ +// Plugin-Artefakt-Packer (A0/3) — erzeugt ein deterministisches, signiertes `.mgxplugin` +// (tar.gz). Geteilt von Tests UND später vom Template-Build. Wendet dieselben Format-/Pfadregeln +// an wie der Verifier (format.ts). Siehe docs/plugin-artifact-format-plan.md. +// +// node-tar (ESM) wird dynamisch importiert (Repo-Muster für ESM-only Node-Deps im Main-Prozess). + +import { createHash, sign as cryptoSign, type KeyObject } from 'node:crypto' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { + canonicalJsonBytes, + assertArtifactPath, + INTEGRITY_FILE, + SIG_FILE, + MANIFEST_FILE, + SIG_FORMAT_VERSION, + SIG_ALGORITHM, + INTEGRITY_FORMAT_VERSION, + INTEGRITY_ALGORITHM, + type IntegrityDoc, + type IntegrityEntry, +} from './format' + +export interface PackFile { + path: string + content: Buffer +} + +export interface PackInput { + /** Nutzdateien inkl. `manifest.json`; OHNE integrity.json/.sig (werden hier erzeugt). */ + files: PackFile[] + /** Ed25519-Privatschlüssel (KeyObject). Test: Dev-Key; CI: Prod-Key. */ + signKey: KeyObject + keyId: string +} + +const byPath = (a: { path: string }, b: { path: string }): number => + a.path < b.path ? -1 : a.path > b.path ? 1 : 0 + +/** + * Baut ein signiertes Artefakt-Tarball als Buffer. Schritte exakt nach ADR-Build-Reihenfolge: + * Pfade prüfen → integrity.json (sortiert) → rohe Bytes signieren → deterministisches tar.gz. + */ +export async function packPluginArtifact(input: PackInput): Promise { + const seen = new Set() + for (const f of input.files) { + assertArtifactPath(f.path) + if (f.path === INTEGRITY_FILE || f.path === SIG_FILE) { + throw new Error(`'${f.path}' darf nicht als Nutzdatei übergeben werden (wird erzeugt)`) + } + if (seen.has(f.path)) throw new Error(`Doppelter Pfad '${f.path}'`) + seen.add(f.path) + } + if (!seen.has(MANIFEST_FILE)) throw new Error(`'${MANIFEST_FILE}' fehlt in den Nutzdateien`) + + const payload = [...input.files].sort(byPath) + const entries: IntegrityEntry[] = payload.map((f) => ({ + path: f.path, + size: f.content.length, + sha256: createHash('sha256').update(f.content).digest('hex'), + })) + const integrity: IntegrityDoc = { + formatVersion: INTEGRITY_FORMAT_VERSION, + algorithm: INTEGRITY_ALGORITHM, + files: entries, + } + const integrityBytes = canonicalJsonBytes(integrity) + const signature = cryptoSign(null, integrityBytes, input.signKey) + const sigBytes = canonicalJsonBytes({ + formatVersion: SIG_FORMAT_VERSION, + algorithm: SIG_ALGORITHM, + keyId: input.keyId, + signature: signature.toString('base64'), + }) + + const all: PackFile[] = [ + ...payload, + { path: INTEGRITY_FILE, content: integrityBytes }, + { path: SIG_FILE, content: sigBytes }, + ] + const dir = mkdtempSync(join(tmpdir(), 'mgxpack-')) + try { + for (const f of all) { + const abs = join(dir, f.path) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, f.content) + } + const tar = await import('tar') + const outPath = join(dir, '__artifact.tgz') + const tarPaths = all.map((f) => f.path).sort() + // portable: entfernt uid/gid/mtime-Nichtdeterminismus; explizite Dateipfade → keine Dir-Einträge. + await tar.create({ gzip: { level: 9 }, portable: true, cwd: dir, file: outPath }, tarPaths) + return readFileSync(outPath) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} diff --git a/app/src/main/plugins/artifact/verify.test.ts b/app/src/main/plugins/artifact/verify.test.ts new file mode 100644 index 00000000..01e8fbf3 --- /dev/null +++ b/app/src/main/plugins/artifact/verify.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { generateKeyPairSync, sign as cryptoSign, createHash, type KeyObject } from 'node:crypto' +import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { packPluginArtifact } from './pack' +import { verifyPluginArtifact, type Keyring } from './verify' +import { ArtifactError, ARTIFACT_LIMITS } from './limits' +import { + canonicalJsonBytes, + INTEGRITY_FILE, + SIG_FILE, + MANIFEST_FILE, + type IntegrityEntry, +} from './format' + +const KEY_ID = 'mindgraph-test-2026-01' +let priv: KeyObject +let pub: KeyObject +let keyring: Keyring +let tmpDirs: string[] = [] + +beforeEach(() => { + const kp = generateKeyPairSync('ed25519') + priv = kp.privateKey + pub = kp.publicKey + keyring = { get: (id) => (id === KEY_ID ? pub : undefined) } + tmpDirs = [] +}) +afterEach(() => { + for (const d of tmpDirs) rmSync(d, { recursive: true, force: true }) +}) + +function quarantine(): string { + const d = mkdtempSync(join(tmpdir(), 'mgx-quar-')) + tmpDirs.push(d) + return d +} + +const manifestBuf = (over: Record = {}): Buffer => + canonicalJsonBytes({ + manifestVersion: 2, + id: 'demo', + version: '1.0.0', + label: 'Demo', + description: 'x', + category: 'ai', + apiVersion: '^0.2.0', + minAppVersion: '0.8.14', + author: { name: 'Test' }, + entrypoints: { main: 'main.js' }, + capabilities: [], + ...over, + }) + +const MAIN = Buffer.from('module.exports = { id: "demo", register() {} }\n', 'utf8') + +const verifyOpts = (over: Partial[1]> = {}) => ({ + keyring, + appVersion: '0.8.14', + quarantineDir: quarantine(), + ...over, +}) + +// — Low-level Archiv-Bau (für manipulierte Fälle): packt einen Verzeichnisinhalt deterministisch. — +async function buildArchive(dir: string, paths: string[]): Promise { + const tar = await import('tar') + const out = join(dir, '__a.tgz') + await tar.create({ gzip: { level: 9 }, portable: true, cwd: dir, file: out }, [...paths].sort()) + return readFileSync(out) +} + +/** Schreibt payload + integrity.json + .sig in einen frischen Tmp-Ordner; erlaubt gezielte Tampering-Hooks. */ +function writeSignedSet(opts: { + payload: { path: string; content: Buffer }[] + signKey?: KeyObject + keyId?: string + integrityOverride?: (entries: IntegrityEntry[]) => IntegrityEntry[] + mutateStoredIntegrity?: (canonical: Buffer) => Buffer +}): { dir: string; paths: string[] } { + const dir = mkdtempSync(join(tmpdir(), 'mgx-craft-')) + tmpDirs.push(dir) + const write = (p: string, c: Buffer): void => { + const abs = join(dir, p) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, c) + } + for (const f of opts.payload) write(f.path, f.content) + + let entries: IntegrityEntry[] = [...opts.payload] + .map((f) => ({ path: f.path, size: f.content.length, sha256: createHash('sha256').update(f.content).digest('hex') })) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + if (opts.integrityOverride) entries = opts.integrityOverride(entries) + const integrityCanonical = canonicalJsonBytes({ formatVersion: 1, algorithm: 'sha256', files: entries }) + const stored = opts.mutateStoredIntegrity ? opts.mutateStoredIntegrity(integrityCanonical) : integrityCanonical + write(INTEGRITY_FILE, stored) + + // Signiert wird die CANONICAL-Form (so wie ein ehrlicher Packer signiert); bei mutateStoredIntegrity + // weicht der gespeicherte Inhalt davon ab → muss als sig-mismatch auffallen. + const signature = cryptoSign(null, integrityCanonical, opts.signKey ?? priv) + write(SIG_FILE, canonicalJsonBytes({ formatVersion: 1, algorithm: 'ed25519', keyId: opts.keyId ?? KEY_ID, signature: signature.toString('base64') })) + + return { dir, paths: [...opts.payload.map((f) => f.path), INTEGRITY_FILE, SIG_FILE] } +} + +describe('verifyPluginArtifact — Happy Path (pack → verify)', () => { + it('verifiziert ein korrektes Artefakt und schreibt die Quarantäne', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }], + signKey: priv, + keyId: KEY_ID, + }) + const opts = verifyOpts() + const result = await verifyPluginArtifact(archive, opts) + expect(result.id).toBe('demo') + expect(result.version).toBe('1.0.0') + expect(result.files.map((f) => f.path).sort()).toEqual(['main.js', 'manifest.json']) + expect(readFileSync(join(opts.quarantineDir, 'main.js'))).toEqual(MAIN) + expect(readFileSync(join(opts.quarantineDir, 'manifest.json'))).toEqual(manifestBuf()) + }) +}) + +describe('verifyPluginArtifact — Signatur', () => { + it('lehnt eine fremde Signatur ab (sig-mismatch)', async () => { + const other = generateKeyPairSync('ed25519') + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }], + signKey: other.privateKey, // mit fremdem Key signiert, Keyring hat aber pub + keyId: KEY_ID, + }) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'sig-mismatch' }) + }) + + it('lehnt eine unbekannte keyId ab (sig-unknown-key)', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }], + signKey: priv, + keyId: 'fremde-key-id', + }) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'sig-unknown-key' }) + }) + + it('erkennt nachträglich getamperte integrity.json (sig-mismatch)', async () => { + const { dir, paths } = writeSignedSet({ + payload: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }], + mutateStoredIntegrity: (b) => Buffer.from(b.toString('utf8').replace('"sha256"', '"sha256"') + ' ', 'utf8'), + }) + const archive = await buildArchive(dir, paths) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'sig-mismatch' }) + }) +}) + +describe('verifyPluginArtifact — Integrität / Dateimenge', () => { + it('erkennt einen Hash-Mismatch (gleiche Länge, anderer Inhalt)', async () => { + const { dir, paths } = writeSignedSet({ payload: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }] }) + // main.js längen-gleich überschreiben → size passt, Hash nicht. + writeFileSync(join(dir, 'main.js'), Buffer.alloc(MAIN.length, 0x41)) + const archive = await buildArchive(dir, paths) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'hash-mismatch' }) + }) + + it('erkennt einen Size-Mismatch (Länge geändert)', async () => { + const { dir, paths } = writeSignedSet({ payload: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }] }) + writeFileSync(join(dir, 'main.js'), Buffer.from('kurz', 'utf8')) + const archive = await buildArchive(dir, paths) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'size-mismatch' }) + }) + + it('erkennt eine zusätzliche, nicht gelistete Datei (fileset-mismatch)', async () => { + const { dir, paths } = writeSignedSet({ payload: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }] }) + writeFileSync(join(dir, 'extra.js'), Buffer.from('// schmuggel', 'utf8')) + const archive = await buildArchive(dir, [...paths, 'extra.js']) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'fileset-mismatch' }) + }) + + it('lehnt einen Symlink-Eintrag ab (entry-type)', async () => { + const { dir, paths } = writeSignedSet({ payload: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }] }) + symlinkSync('main.js', join(dir, 'link.js')) + const archive = await buildArchive(dir, [...paths, 'link.js']) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'entry-type' }) + }) +}) + +describe('verifyPluginArtifact — Manifest, Gates, Entrypoints', () => { + it('lehnt ein ungültiges Manifest ab (manifest-invalid)', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf({ apiVersion: undefined }) }, { path: 'main.js', content: MAIN }], + signKey: priv, + keyId: KEY_ID, + }) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'manifest-invalid' }) + }) + + it('lehnt inkompatible apiVersion ab (incompatible-api)', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf({ apiVersion: '^9.9.9' }) }, { path: 'main.js', content: MAIN }], + signKey: priv, + keyId: KEY_ID, + }) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'incompatible-api' }) + }) + + it('lehnt zu hohe minAppVersion ab (incompatible-app)', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf({ minAppVersion: '999.0.0' }) }, { path: 'main.js', content: MAIN }], + signKey: priv, + keyId: KEY_ID, + }) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'incompatible-app' }) + }) + + it('lehnt fehlendes entrypoint-Ziel ab (entrypoint-missing)', async () => { + // entrypoints.main = 'main.js', aber main.js nicht im Paket (nur manifest.json). + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf() }], + signKey: priv, + keyId: KEY_ID, + }) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toMatchObject({ code: 'entrypoint-missing' }) + }) +}) + +describe('verifyPluginArtifact — Limits', () => { + it('lehnt ein zu großes Archiv ab (archive-too-large)', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }], + signKey: priv, + keyId: KEY_ID, + }) + const opts = verifyOpts({ limits: { ...ARTIFACT_LIMITS, maxArchiveBytes: 10 } }) + await expect(verifyPluginArtifact(archive, opts)).rejects.toMatchObject({ code: 'archive-too-large' }) + }) + + it('lehnt zu viele Dateien ab (limit-files)', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }, { path: 'a.js', content: Buffer.from('a') }], + signKey: priv, + keyId: KEY_ID, + }) + const opts = verifyOpts({ limits: { ...ARTIFACT_LIMITS, maxFiles: 2 } }) + await expect(verifyPluginArtifact(archive, opts)).rejects.toMatchObject({ code: 'limit-files' }) + }) + + it('wirft ArtifactError-Instanzen (kein generischer Error)', async () => { + const archive = await packPluginArtifact({ + files: [{ path: MANIFEST_FILE, content: manifestBuf() }, { path: 'main.js', content: MAIN }], + signKey: priv, + keyId: 'unbekannt', + }) + await expect(verifyPluginArtifact(archive, verifyOpts())).rejects.toBeInstanceOf(ArtifactError) + }) +}) diff --git a/app/src/main/plugins/artifact/verify.ts b/app/src/main/plugins/artifact/verify.ts new file mode 100644 index 00000000..ac479457 --- /dev/null +++ b/app/src/main/plugins/artifact/verify.ts @@ -0,0 +1,256 @@ +// Plugin-Artefakt-Verifier (A0/3) — der Sicherheitskern. Reine Funktion: +// verify(archive, { keyring, appVersion, quarantineDir }) → VerifiedPluginPackage +// Entpackt in die Quarantäne, prüft Signatur → Limits/Hashes → Manifest + Kompat-Gates (A0/2) → +// entrypoint-Existenz und gibt das verifizierte Paket zurück. **Installiert NICHT** (atomar erst +// A1/A2). Siehe docs/plugin-artifact-format-plan.md. + +import { createHash, verify as cryptoVerify, type KeyObject } from 'node:crypto' +import { createGunzip } from 'node:zlib' +import { Readable, Writable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve, sep } from 'node:path' +import { + validateManifest, + validateManifestSemantics, + isApiCompatible, + isAppCompatible, +} from '@mindgraph/plugin-api/validation' +import type { PluginManifest } from '@mindgraph/plugin-api' +import { ARTIFACT_LIMITS, ArtifactError, type ArtifactLimits } from './limits' +import { + artifactPathError, + parseUtf8Json, + validateIntegrityDoc, + validateSigEnvelope, + MANIFEST_FILE, + INTEGRITY_FILE, + SIG_FILE, + type IntegrityEntry, +} from './format' + +/** Liefert den Public Key (Ed25519, KeyObject) zu einer keyId oder undefined. Per DI injiziert. */ +export interface Keyring { + get(keyId: string): KeyObject | undefined +} + +export interface VerifyOptions { + keyring: Keyring + /** Laufende App-Version für das App-Kompat-Gate (A0/2). */ + appVersion: string + /** Zielordner für die verifizierten Dateien (Quarantäne). Wird angelegt. */ + quarantineDir: string + limits?: ArtifactLimits +} + +/** Ergebnis einer erfolgreichen Verifikation — verifiziertes Paket in der Quarantäne (KEIN Install). */ +export interface VerifiedPluginPackage { + id: string + version: string + manifest: PluginManifest + quarantineDir: string + files: IntegrityEntry[] +} + +function perFileCap(path: string, limits: ArtifactLimits): number { + if (path === SIG_FILE) return limits.maxSigBytes + if (path === INTEGRITY_FILE) return limits.maxIntegrityBytes + if (path === MANIFEST_FILE) return limits.maxManifestBytes + return limits.maxFileBytes +} + +/** Streaming-Gunzip mit hartem Byte-Cap (Archive-Bomb-Schutz vor dem tar-Parsen). */ +async function gunzipCapped(archive: Buffer, maxOut: number): Promise { + const chunks: Buffer[] = [] + let total = 0 + const sink = new Writable({ + write(chunk: Buffer, _enc, cb) { + total += chunk.length + if (total > maxOut) { + cb(new ArtifactError('limit-total-size', `Dekomprimierter Inhalt überschreitet ${maxOut} Bytes`)) + return + } + chunks.push(chunk) + cb() + }, + }) + await pipeline(Readable.from(archive), createGunzip(), sink) + return Buffer.concat(chunks) +} + +/** Parst das (entpackte) Tar in eine Map path→Bytes; nur reguläre Files, alle Limits/Pfadregeln. */ +async function extractEntries(plainTar: Buffer, limits: ArtifactLimits): Promise> { + const tar = await import('tar') + const files = new Map() + let count = 0 + let contentTotal = 0 + let pending: ArtifactError | null = null + const fail = (e: ArtifactError): void => { + if (!pending) pending = e + } + + const parser = new tar.Parser() + parser.on('entry', (entry) => { + if (pending) { + entry.resume() + return + } + if (entry.type !== 'File') { + fail(new ArtifactError('entry-type', `Eintrag '${entry.path}' ist kein reguläres File (${entry.type})`)) + entry.resume() + return + } + const pErr = artifactPathError(entry.path, limits) + if (pErr) { + fail(new ArtifactError('path-invalid', `Eintrag '${entry.path}': ${pErr}`)) + entry.resume() + return + } + if (files.has(entry.path)) { + fail(new ArtifactError('duplicate-path', `Doppelter Eintrag '${entry.path}'`)) + entry.resume() + return + } + if (++count > limits.maxFiles) { + fail(new ArtifactError('limit-files', `Mehr als ${limits.maxFiles} Dateien`)) + entry.resume() + return + } + const cap = perFileCap(entry.path, limits) + const chunks: Buffer[] = [] + let n = 0 + entry.on('data', (d: Buffer) => { + n += d.length + contentTotal += d.length + chunks.push(d) + }) + entry.on('end', () => { + if (pending) return + if (n > cap) { + fail(new ArtifactError('limit-file-size', `'${entry.path}' überschreitet ${cap} Bytes`)) + return + } + if (contentTotal > limits.maxTotalUnpackedBytes) { + fail(new ArtifactError('limit-total-size', `Gesamtinhalt überschreitet ${limits.maxTotalUnpackedBytes} Bytes`)) + return + } + files.set(entry.path, Buffer.concat(chunks)) + }) + }) + + await new Promise((res, rej) => { + parser.on('end', res) + parser.on('error', rej) + parser.end(plainTar) + }) + if (pending) throw pending + return files +} + +function readManifest(files: Map): PluginManifest { + const buf = files.get(MANIFEST_FILE) + if (!buf) throw new ArtifactError('fileset-mismatch', `'${MANIFEST_FILE}' fehlt im Archiv`) + const value = parseUtf8Json(buf, 'manifest-invalid', MANIFEST_FILE) + const shape = validateManifest(value) + if (!shape.valid) throw new ArtifactError('manifest-invalid', `Ungültiges Manifest: ${shape.errors.join('; ')}`) + const semantics = validateManifestSemantics(value as PluginManifest) + if (!semantics.valid) throw new ArtifactError('manifest-invalid', `Ungültiges Manifest: ${semantics.errors.join('; ')}`) + return value as PluginManifest +} + +function assertCompatible(manifest: PluginManifest, appVersion: string): void { + const api = isApiCompatible(manifest.apiVersion) + if (!api.compatible) { + throw new ArtifactError(api.kind === 'incompatible-api' ? 'incompatible-api' : 'manifest-invalid', api.reason ?? 'API inkompatibel') + } + const app = isAppCompatible(manifest.minAppVersion, appVersion) + if (!app.compatible) { + throw new ArtifactError(app.kind === 'incompatible-app' ? 'incompatible-app' : 'manifest-invalid', app.reason ?? 'App inkompatibel') + } +} + +function assertEntrypointsPresent(manifest: PluginManifest, payload: Set): void { + for (const key of ['main', 'renderer', 'styles'] as const) { + const ep = manifest.entrypoints?.[key] + if (ep && !payload.has(ep)) { + throw new ArtifactError('entrypoint-missing', `entrypoints.${key} '${ep}' fehlt im Paket`) + } + } +} + +function writeQuarantine(quarantineDir: string, files: Map, entries: IntegrityEntry[]): void { + const root = resolve(quarantineDir) + mkdirSync(root, { recursive: true }) + for (const e of entries) { + const abs = resolve(root, e.path) + // Defense-in-Depth: Pfade sind bereits streng validiert (kein ..), hier nochmals einsperren. + if (abs !== root && !abs.startsWith(root + sep)) { + throw new ArtifactError('path-invalid', `Pfad verlässt die Quarantäne: '${e.path}'`) + } + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, files.get(e.path)!) + } +} + +/** + * Verifiziert ein `.mgxplugin`-Archiv vollständig und gibt ein `VerifiedPluginPackage` zurück. + * Wirft `ArtifactError(code)` bei jedem Verstoß. **Kein Install** — das Verschieben aus der + * Quarantäne ist A1/A2. + */ +export async function verifyPluginArtifact(archive: Buffer, opts: VerifyOptions): Promise { + const limits = opts.limits ?? ARTIFACT_LIMITS + if (archive.length > limits.maxArchiveBytes) { + throw new ArtifactError('archive-too-large', `Archiv überschreitet ${limits.maxArchiveBytes} Bytes`) + } + + // 1) Entpacken (bomb-sicher) → Einträge mit Limits/Pfadregeln. + const headroom = limits.maxFiles * 1024 + 2 * 512 + const plainTar = await gunzipCapped(archive, limits.maxTotalUnpackedBytes + headroom) + const files = await extractEntries(plainTar, limits) + + // 2) integrity.json + .sig vorhanden? + const integrityBytes = files.get(INTEGRITY_FILE) + const sigBytes = files.get(SIG_FILE) + if (!integrityBytes) throw new ArtifactError('fileset-mismatch', `'${INTEGRITY_FILE}' fehlt`) + if (!sigBytes) throw new ArtifactError('fileset-mismatch', `'${SIG_FILE}' fehlt`) + + // 3) Signatur über die EXAKTEN integrity.json-Bytes (Verifier serialisiert nichts neu). + const sigValue = parseUtf8Json(sigBytes, 'sig-invalid', SIG_FILE) + const { envelope, signature } = validateSigEnvelope(sigValue) + const publicKey = opts.keyring.get(envelope.keyId) + if (!publicKey) throw new ArtifactError('sig-unknown-key', `Unbekannte keyId '${envelope.keyId}'`) + if (!cryptoVerify(null, integrityBytes, publicKey, signature)) { + throw new ArtifactError('sig-mismatch', 'Signatur passt nicht zu integrity.json') + } + + // 4) integrity.json parsen + Dateimenge/Hashes/Größen prüfen. + const doc = validateIntegrityDoc(parseUtf8Json(integrityBytes, 'integrity-invalid', INTEGRITY_FILE), limits) + const payloadPaths = new Set([...files.keys()].filter((p) => p !== INTEGRITY_FILE && p !== SIG_FILE)) + const listed = new Set(doc.files.map((f) => f.path)) + for (const p of payloadPaths) { + if (!listed.has(p)) throw new ArtifactError('fileset-mismatch', `Datei '${p}' nicht in integrity.json gelistet`) + } + for (const e of doc.files) { + const buf = files.get(e.path) + if (!buf) throw new ArtifactError('fileset-mismatch', `In integrity.json gelistete Datei '${e.path}' fehlt`) + if (buf.length !== e.size) throw new ArtifactError('size-mismatch', `Größe von '${e.path}' weicht ab`) + const actual = createHash('sha256').update(buf).digest('hex') + if (actual !== e.sha256) throw new ArtifactError('hash-mismatch', `Hash von '${e.path}' weicht ab`) + } + + // 5) Manifest + Kompat-Gates (A0/2) + entrypoint-Existenz — alles noch in Quarantäne. + const manifest = readManifest(files) + assertCompatible(manifest, opts.appVersion) + assertEntrypointsPresent(manifest, payloadPaths) + + // 6) Verifizierte Nutzdateien in die Quarantäne schreiben (KEIN Install). + writeQuarantine(opts.quarantineDir, files, doc.files) + + return { + id: manifest.id, + version: manifest.version, + manifest, + quarantineDir: resolve(opts.quarantineDir), + files: doc.files, + } +} diff --git a/docs/plugin-artifact-format-plan.md b/docs/plugin-artifact-format-plan.md new file mode 100644 index 00000000..47517a2c --- /dev/null +++ b/docs/plugin-artifact-format-plan.md @@ -0,0 +1,314 @@ +# A0 · Schritt 3 — Artefaktformat, integrity.json & Signierung (Plan / ADR) + +> **Status: ENTWURF — zur Review** (Branch `feat/plugin-artifact-format`, von `master` nach Merge #26; +> als Draft-PR geführt — Implementierung läuft schrittweise in denselben PR). Letzter A0-Baustein vor +> A1 (Runtime-Loader-Spike). Folgt auf `docs/plugin-manifest-v2-plan.md` (A0/2, gemergt #26). + +## Ziel + +Ein **deterministisches, signiertes Plugin-Artefakt** + ein **Repo-Template** + eine **Build-Action**, +sodass ein extern gebautes Plugin von der App nachweisbar unverfälscht geladen werden kann. Dieser +Schritt liefert Format + Werkzeuge + Verifier-Spezifikation; der eigentliche Disk-/Runtime-Loader, +der das Artefakt im Betrieb lädt und `entrypoints` ausführt, bleibt A1. + +**Leitwert wie in ganz A0: kein sichtbares App-Verhalten ändert sich.** Die vier gebündelten Plugins +werden weiter via `import.meta.glob` geladen; das Artefaktformat betrifft nur den künftigen Disk-Pfad. + +## Kernentscheidung: Signaturwurzel = separate `integrity.json` (NICHT das Manifest) + +Bewusst gegen eine eingebettete `files`-Hash-Map im Manifest entschieden. Vorteile: + +- **Manifest v2 bleibt semantisch sauber** — kein `files`-Feld, **kein API-Bump**, keine Diskussion + über kanonische Manifest-Serialisierung. +- Semantik (was ein Plugin *ist*) und Verpackungsintegrität (welche Bytes ausgeliefert wurden) bleiben + getrennt. + +Vertrauenskette: + +``` +vertrauter Public Key → integrity.json.sig → exakte Bytes von integrity.json + └─ Hash(manifest.json) + Hash(jede Code-/Asset-Datei) +``` + +## Voraussetzung: `@mindgraph/plugin-api` auf npm veröffentlichen + +Ein echtes externes Repo-Template kann nicht gegen ein `private`-Monorepo-Paket bauen. Daher ist +die **Veröffentlichung von `@mindgraph/plugin-api@0.2.0` auf npm Voraussetzung** für A0/3 (sonst +bleibt das „Template" nur ein Monorepo-Fixture). Konkret: + +- `"private": true` entfernen, `"publishConfig": { "access": "public" }` (scoped Paket). +- **Build-Emit ergänzen**: das Paket exportiert heute `./src/*.ts` (Roh-TS). Für npm muss es + kompiliertes **JS + `.d.ts`** ausliefern (`tsc`-Emit nach `dist/`), `exports`/`types`/`files` auf + `dist/` zeigen lassen. Der `/validation`-Subpath bleibt erhalten. +- **Zwei Konsum-Modi koexistieren:** die App selbst konsumiert weiter über den **TS-Quell-Alias** + (tsconfig paths + vite alias) — unverändert; externe Plugin-Repos konsumieren das **npm-dist**. +- Versionsdisziplin: das publizierte `version`/`API_VERSION` ist die Bezugsgröße des Kompat-Gates + (A0/2). Major-Bump erst bewusst vor öffentlichem Marktplatz (A2). +- **Publish über npm Trusted Publishing (OIDC) + `npm publish --provenance`** aus einem geschützten + CI-Job — **kein langlebiger npm-Token** als Secret. Provenance bindet das Paket nachweisbar an + Repo + Workflow + Commit. + +## Bundle-ABI (A0/3: **Main-only**) + +A0/3 schließt mit einem **Main-only-Template** ab. Der Renderer-Build-Vertrag (React, dynamische +Chunks, Host-Injection in den Renderer) ist bewusst **ungeklärt und gehört zu A1** — er wird +gemeinsam mit dem Runtime-Loader-Spike definiert. Ein Main-only-Plugin ist als `entrypoints` mit +**nur `main`** (ohne `renderer`) bereits durch das v2-Schema gedeckt (at-least-one). + +**`main.js`-ABI (verbindlich) — CommonJS, NICHT ESM:** + +- **Ein einzelnes, self-contained CommonJS-Bundle**: `module.exports = pluginEntry`, wobei + `pluginEntry` ein `PluginMainEntry` ist (`{ id, register(ctx), start?, stop? }`, vgl. + `@mindgraph/plugin-api`). **Begründung:** eine heruntergeladene `.js` unter `userData/plugins/` + hat keine nahe `package.json` mit `"type":"module"` → Node behandelt sie als CommonJS; `export + default` würde dort scheitern. (Alternative `main.mjs` würde Manifest-Schema + Store-Vertrag + erneut ändern — CJS ist der gerade Weg.) +- Geladen vom (späteren A1-)Loader über `require`/`createRequire`. +- **Keine externen Imports, keine dynamischen Chunks** (genau eine Datei `main.js`). Der + `definePluginMain`-Helfer (dependency-frei) wird **in das Bundle gebündelt** — der Host injiziert + ihn nicht. Host-Dienste kommen **vorgesehenerweise** über `ctx.host` zur `register`-Zeit + (Capability-Gate). +- **Node-/Electron-Built-ins beim Build explizit verbieten** (Bundler-`external`/Plugin bricht bei + `fs`/`path`/`electron`/… ab). +- Build-Target = Node/Electron-Version des Hosts (gepinnt). +- **Sicherheits-Klarstellung (wichtig):** `ctx.host` ist die *vorgesehene* API, technisch aber + **nicht der einzige Draht nach außen** — ein Main-CJS-Bundle hat im Main-Prozess weiterhin Zugriff + auf `process`, dynamisches `require`, globale Objekte usw. Der Build-Bann ist reines **Lint / + Defense-in-Depth**, keine Isolation. **In Phase A kommt Sicherheit ausschließlich aus Signatur + + Autorvertrauen.** Echte Isolation erst mit der `utilityProcess`-Sandbox (Plugin-Roadmap Schritt 10). + +## Format + +### `integrity.json` — sortierte Liste (keine Objekt-Map) + +```json +{ + "formatVersion": 1, + "algorithm": "sha256", + "files": [ + { "path": "manifest.json", "size": 1234, "sha256": "abcdef…" }, + { "path": "main.js", "size": 5678, "sha256": "…" } + ] +} +``` + +- **Liste statt Map** → doppelte JSON-Keys sind unmöglich. +- `files` enthält `manifest.json` **und jede Nutzdatei**, aber **weder `integrity.json` noch `.sig`**. +- Signiert werden die **exakt ausgelieferten Bytes** von `integrity.json` — der Verifier serialisiert + nichts neu. +- `formatVersion` ist unabhängig von `manifestVersion`. + +### `integrity.json.sig` — versionierte Hülle (von Beginn an) + +```json +{ + "formatVersion": 1, + "algorithm": "ed25519", + "keyId": "mindgraph-official-2026-01", + "signature": "" +} +``` + +- Signiert bleiben **ausschließlich die rohen `integrity.json`-Bytes**. +- `keyId` erlaubt spätere **Key-Rotation ohne Formatänderung**. A0/3 pinnt **genau einen** Key; + Multi-Key-Trust-Store ist A2. + +### Kanonische JSON-Bytes (verbindlich für alle drei JSON-Dateien) + +`manifest.json`, `integrity.json` und `integrity.json.sig` werden **exakt** so serialisiert: + +```ts +JSON.stringify(value, null, 2) + '\n' +``` + +UTF-8, **LF**, **kein BOM**, **feste Feldreihenfolge** (Objektschlüssel in der Reihenfolge, in der +das Tool sie schreibt — beim Bauen deterministisch festgelegt). Ohne diese Festlegung ist +„deterministisch" unvollständig: dieselben Daten könnten sonst unterschiedliche Bytes — und damit +unterschiedliche Hashes/Signaturen — ergeben. + +### Archiv `-.mgxplugin` (deterministisches tar.gz) + +``` +-.mgxplugin +├── manifest.json +├── main.js +├── renderer.js (optional — Renderer-ABI erst A1; Template ist Main-only) +├── styles.css (optional — A1) +├── assets/… (optional) +├── integrity.json +└── integrity.json.sig +``` + +- **Dateiname ist reine Anzeige** — ID/Version werden ausschließlich aus dem **verifizierten Manifest** + übernommen, nie aus dem Namen. +- Container: tar `--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner` + `gzip -n`. +- Implementiert in **Node** (deterministischer tar-Writer), nicht via System-`tar` (GNU vs bsdtar + weicht ab). + +## Signatur + +**Ed25519 über Node `crypto`** — keine minisign/libsodium-Abhängigkeit. Privater **PKCS#8**-Key als +GitHub Secret (CI), eingebauter **SPKI**-Public-Key in der App. Build und Verifier teilen damit nur +Node-Bordmittel. + +## Reproduzierbarkeit (präzise) + +Ein Node-Tar-Writer **allein** garantiert wegen unterschiedlicher **Node-/zlib-Versionen** keine +global bit-identischen gzip-Bytes. Daher konkrete, gepinnte Toolchain: + +- **Node:** exakt **20.x** (eine fixe Patch-Version) via `.nvmrc` + `engines` im Template; die CI + nutzt heute das *floating* `node-version: '20'` — der Build-Action/Template **muss exakt pinnen** + (floating ist nicht reproduzierbar). +- **Tar-Writer:** **`tar` (node-tar) v7.x**, exakte Version gepinnt, mit `portable: true` (entfernt + mtime/uid/gid/atime/ctime-Nichtdeterminismus) + sortierten Einträgen — **kein** System-`tar`. +- **gzip:** fixe Kompressionsstufe (z. B. `level: 9`, `mtime: 0`). + +Wichtig: die **Signatur hängt nicht an den Archivbytes**, sondern an `integrity.json` — ein +nicht-bit-identisches Archiv bricht die Verifikation **nicht**, es schwächt nur die +Reproduzierbarkeits-Garantie. (Sicherheits-Anker bleibt `integrity.json` + `.sig`.) + +## Build-Reihenfolge (fix) + +1. Bundles deterministisch erzeugen (gepinnte Toolchain, `SOURCE_DATE_EPOCH`, stabile Dateinamen). +2. `manifest.json` schreiben. +3. Hash + Größe **aller Nutzdateien** (inkl. manifest.json) berechnen. +4. Deterministische `integrity.json` schreiben (Liste nach `path` sortiert). +5. Deren **rohe Bytes** mit Ed25519 signieren → `integrity.json.sig`-Hülle. +6. Deterministisches Archiv packen. +7. Optional: finalen Archiv-Hash als Release-Metadatum veröffentlichen. + +## Archivlimits (hart, vor/while-entpacken erzwungen) + +Schutz gegen Archive-Bombs und Ressourcen-Erschöpfung. Überschreitung ⇒ Abbruch, kein Install. + +| Grenze | Wert | +|--------|------| +| Dateien gesamt | 512 | +| Archiv komprimiert | 100 MiB | +| Inhalt entpackt (Summe) | 250 MiB | +| Pro Datei (entpackt) | 100 MiB | +| `manifest.json` / `integrity.json` je | 1 MiB | +| `integrity.json.sig` | 16 KiB | +| Pfadlänge gesamt | 240 Zeichen | +| Pfadtiefe | 8 Segmente | +| **Pro Pfadsegment** | **100 ASCII-Bytes** | + +**Nur reguläre Dateien.** Directory-, Symlink-, Hardlink-, Device-/Special- und **PAX/Global-Header**- +Einträge werden abgelehnt (nicht still übersprungen). Die entpackte Gesamtsumme wird **während** des +Entpackens mitgezählt (Streaming-Limit), nicht erst danach. + +**USTAR-Konsistenz:** Weil PAX-Header verboten sind, bleibt nur das **USTAR**-Namensfeld — daher die +harte Grenze **≤ 100 ASCII-Bytes pro Pfadsegment**. Andernfalls bräuchte der Writer PAX-Extended- +Header (verboten) oder das USTAR-`prefix`-Feld (mehrdeutig). **Writer und Verifier nutzen exakt +dieselben Pfadregeln** — was der Verifier ablehnt, kann der Writer gar nicht erst erzeugen. + +## Pfad- & Integrity-Normalisierung (streng, eindeutig) + +**Pfade (Archiv-Einträge UND `files[].path`):** +- nur **lowercase ASCII** POSIX-Pfade (`a–z 0–9 . _ - /`), Trenner `/`; +- relativ, kein führendes `./`, kein `..`-Segment, kein absoluter Pfad, kein Backslash; +- **keine doppelten und keine case-kollidierenden** Pfade (lowercase-only macht Case-Kollision + zugleich zu exakter Duplikat-Erkennung — wichtig für case-insensitive FS bei der Installation); +- Archiv-Eintragsmenge **==** `files[].path` plus `{integrity.json, integrity.json.sig}` (kein + missing, kein extra). + +**`integrity.json`-Felder:** +- `files` ist **strikt nach `path` sortiert und eindeutig**; +- `size` = nichtnegativer Safe-Integer (`0 ≤ n ≤ Number.MAX_SAFE_INTEGER`), muss der tatsächlichen + entpackten Größe entsprechen; +- `sha256` = **exakt 64 lowercase Hex-Zeichen**; +- `algorithm == "sha256"`, `formatVersion` bekannt. + +**`integrity.json.sig`-Felder:** +- `algorithm == "ed25519"`, `formatVersion` bekannt, `keyId` bekannt im Keyring; +- `signature` = **kanonisches Base64**, nach Decode **exakt 64 Bytes** (ed25519-Signaturlänge). + +## Verifier-Reihenfolge (Sicherheitskern — alles in Quarantäne) + +**A0/3 liefert genau eine reine Funktion: `verify(archive, keyring) → VerifiedPluginPackage`.** Sie +entpackt in einen Quarantäne-Ordner, prüft alles und gibt bei Erfolg ein `VerifiedPluginPackage` +zurück (verifiziertes Manifest + Quarantäne-Pfad + Dateiliste). **A0/3 installiert NICHT** — das +atomare Verschieben Quarantäne → Plugin-Verzeichnis implementiert erst **A1/A2** (Loader). Die letzte +Zeile unten ist daher als **spätere Pflicht** notiert, nicht als A0/3-Verhalten. + +``` +sicher entpacken in Quarantäne (Limits: Dateianzahl, Pro-Datei-/Gesamtgröße → Archive-Bomb-Schutz; + Ablehnung von Symlink/Hardlink/absolut/„..“/Backslash/Übergröße-Segment) +→ integrity.json + .sig-Hülle lesen + Feldform prüfen (Normalisierung s.o.) +→ Ed25519-Signatur über die EXAKTEN integrity.json-Bytes; keyId → Key aus injiziertem Keyring +→ algorithm/formatVersion prüfen +→ Eintragsmenge == files[].path (kein missing/extra) + je Datei size + sha256 +→ manifest.json parsen + validateManifest / validateManifestSemantics (A0/2) +→ API-/App-Kompatibilitäts-Gates (A0/2) +→ entrypoints gegen TATSÄCHLICHE Dateien prüfen (deklarierte main/renderer/styles existieren) +→ Rückgabe: VerifiedPluginPackage (Quarantäne) ←── ENDE A0/3 +········································································ +→ [A1/A2] atomar installieren (Quarantäne → Plugin-Verzeichnis) ←── spätere Pflicht, NICHT A0/3 +``` + +Begründung der Reihenfolge: Manifestprüfung **und** Kompat-Gates müssen **vor** einem späteren Install +laufen — ein Artefakt, das die Signatur trägt, aber ein inkompatibles/ungültiges Manifest hat, darf +nie ins Plugin-Verzeichnis gelangen. A0/3 stellt das sicher, indem ein solches Paket gar nicht erst +als `VerifiedPluginPackage` zurückkommt. + +**Keyring per DI:** Der Verifier bekommt einen **injizierten Keyring** (`keyId → Public Key`) — +keine eingebaute Konstante in der Verifier-Logik. So bleibt er standalone testbar (Fake-Keys) und +Key-Rotation/Multi-Key (A2) bleibt eine Frage der Befüllung. **Die App pinnt** beim Aufbau des +Keyrings den offiziellen `mindgraph-official-2026-01`-Key (SPKI, eingebaut). + +## CI-Keyschutz (Signier-Workflow) + +Der Produktions-Signierschlüssel ist das wertvollste Geheimnis dieses Schritts — strenge Leitplanken: + +- **Signieren nur auf geschützten Release-Tags** (`push: tags`), **niemals in `pull_request`** + (sonst signiert ein Fork-PR mit dem Prod-Key). +- Signier-Job in einem **GitHub Environment mit Required Reviewer/Freigabe**; nur dieses Environment + hält das `PLUGIN_SIGNING_KEY`-Secret. +- **Minimale Workflow-Permissions** (`contents: read`, gezielt `contents: write` nur für den + Release-Upload; kein `id-token`/breitere Scopes). +- **Produktions-Key nie für lokale Builds.** Lokales `pack` signiert nur mit einem **explizit + injizierten Development-Key** (eigener `keyId`, z. B. `dev-local`), der NICHT im App-Keyring der + Release-Builds steht → lokal gepackte Artefakte sind in der ausgelieferten App bewusst ungültig. +- Key liegt als **PKCS#8** im Secret; der Workflow schreibt ihn nur in den Job-Speicher und nie ins + Artefakt/Log. + +## Repo-Template & Build-Action (Main-only) + +- **Template** (`create-mindgraph-plugin` o. ä.): minimales v2-Manifest mit `entrypoints: { main }`, + **ein Main-Entry-Stub** gegen das **npm-`@mindgraph/plugin-api`** (`definePluginMain`), Build-Konfig + mit **gepinnter Toolchain** (`.nvmrc`/`engines`, fixe Bundler-/Writer-Version), npm-Scripts `build` + + `pack`. **Kein Renderer-Stub** (Renderer-ABI = A1). +- **Build-Action** (GitHub composite/Workflow): führt die Build-Reihenfolge oben aus und legt + `-.mgxplugin` als Release-Artefakt ab. Signierung im selben Lauf unter dem + CI-Keyschutz (s. o.) mit dem PKCS#8-Secret. + +## Scope-Grenze + +- **A0/3 verifiziert nur** (`verify() → VerifiedPluginPackage` in Quarantäne); **kein Install**, kein + Disk-/Runtime-Loader (der das Artefakt im Betrieb lädt + `entrypoints` ausführt) → A1/A2. +- **Kein Renderer-Build-Vertrag** (React, dynamische Chunks, Renderer-Host-Injection) → A1; Template + ist Main-only. +- **Kein** Multi-Key-Trust-Store / Key-Rotation im Betrieb → A2. +- **Keine** echte Laufzeit-Isolation des Main-Bundles (`utilityProcess`-Sandbox) → Roadmap-Schritt 10. +- Gebündelte Plugins bleiben unverändert auf dem Glob-Pfad. + +## Akzeptanzkriterien + +1. `@mindgraph/plugin-api@0.2.0` ist auf npm veröffentlicht (JS + `.d.ts`, public access); das + Template installiert es als externe Dependency. +2. Template baut lokal ein gültiges Main-only `.mgxplugin` (manifest + main.js + integrity.json + .sig) + und signiert mit einem injizierten **Development-Key**. +3. **CJS-Lade-Test:** `main.js` aus einem temporären Ordner **ohne `package.json`** über + `require`/`createRequire` laden und prüfen, dass `module.exports` ein gültiges `PluginMainEntry` + ist (belegt, dass kein `"type":"module"`-Kontext nötig ist). Build bricht ab, wenn das Bundle ein + Node-/Electron-Built-in referenziert. +4. `verify()` (standalone testbar, injizierter Keyring) gibt bei einem korrekten Artefakt ein + `VerifiedPluginPackage` (Quarantäne) zurück und **installiert nicht**; lehnt ab bei: kaputter + Signatur, fremdem/unbekanntem keyId, Hash-/Size-Mismatch, fehlender/zusätzlicher Datei, + doppeltem/case-kollidierendem Pfad, Übergröße-Segment, Symlink/Hardlink/Dir/PAX-Eintrag, + `..`/absolutem Pfad, jedem überschrittenen Limit, ungültigem/inkompatiblem Manifest, fehlendem + entrypoint-Ziel — je per Test. +5. Zwei Builds derselben Quelle mit gepinnter Toolchain erzeugen bit-identische `integrity.json` + (Archiv-Bit-Gleichheit ist Ziel, aber nicht sicherheitskritisch — s. Reproduzierbarkeit). +6. `npm run typecheck` + `npm run test` + `npm run build` grün; kein Verhalten der gebündelten Plugins + geändert. diff --git a/plugin-template/.gitignore b/plugin-template/.gitignore new file mode 100644 index 00000000..4ba1fd07 --- /dev/null +++ b/plugin-template/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +# Lokale Dev-Signierschlüssel — NIEMALS committen (von keygen:dev erzeugt). +*.dev-key.json diff --git a/plugin-template/.nvmrc b/plugin-template/.nvmrc new file mode 100644 index 00000000..d4b7699d --- /dev/null +++ b/plugin-template/.nvmrc @@ -0,0 +1 @@ +20.18.1 diff --git a/plugin-template/README.md b/plugin-template/README.md new file mode 100644 index 00000000..a270e6fb --- /dev/null +++ b/plugin-template/README.md @@ -0,0 +1,53 @@ +# MindGraph Notes — Plugin-Template (Main-only, A0/3) + +Starter für ein **Main-only**-Plugin. Es baut zu einem self-contained CommonJS-`main.js` und wird +als signiertes `.mgxplugin`-Artefakt verteilt. Renderer-Plugins (UI) folgen in einem späteren +Schritt (A1) — dieses Template deckt sie bewusst noch nicht ab. + +> Verbindlicher Format-/Sicherheitsvertrag: `docs/plugin-artifact-format-plan.md` im Hauptrepo. + +## Struktur + +``` +manifest.json # v2-Manifest (entrypoints.main = "main.js") +src/main.ts # Main-Entry: nur @mindgraph/plugin-api importieren +build.mjs # esbuild → dist/main.js (CJS) + kanonische dist/manifest.json +esbuild.config.mjs # geteilte Build-Optionen + Built-in-Bann +scripts/keygen-dev.mjs # erzeugt EINEN lokalen Dev-Key (git-ignoriert) +``` + +## Main-ABI (verbindlich) + +- `src/main.ts` exportiert `export default definePluginMain(...)`. +- Der Build erzeugt **self-contained CommonJS**: `require("main.js")` liefert direkt den + `PluginMainEntry`. Node lädt eine lose `.js` ohne nahe `package.json` als CommonJS. +- **Keine** Node-/Electron-Built-ins (`fs`, `path`, `electron`, …) — der Build bricht sonst ab. + Host-Dienste kommen ausschließlich über `ctx.host` (capability-gated). + +## Lokaler Ablauf + +```bash +nvm use # Node-Version aus .nvmrc (exakt gepinnt) +npm install # benötigt @mindgraph/plugin-api ab npm (>= 0.2.0) +npm run build # → dist/main.js + dist/manifest.json +npm run keygen:dev # erzeugt local.dev-key.json (git-ignoriert) +``` + +Packen + Signieren (zu `.mgxplugin`) und die Verifikation übernimmt die Pipeline / das offizielle +Toolchain-CLI; lokal wird mit dem **Dev-Key** signiert und gegen einen Test-Keyring verifiziert. +Der Dev-Key wird **nie automatisch** verwendet und **nie committet**. + +## Sicherheit / Signierung + +- **Phase A:** Sicherheit kommt aus **Signatur + Autorvertrauen**, nicht aus Laufzeit-Isolation. +- **Dev-Signierung (lokal/PR-CI):** ephemerer bzw. via `keygen:dev` erzeugter Dev-Key. +- **Release-Signierung (Produktion):** läuft **ausschließlich** im geschützten CI-Environment mit + einem dort hinterlegten Secret. Es werden hier **nur Secret-Namen** dokumentiert, niemals Werte: + - `PLUGIN_SIGNING_KEY` — Ed25519-Privatschlüssel (PKCS#8 PEM), nur im Environment `release-signing`. + - npm-Publish des API-Pakets nutzt **Trusted Publishing (OIDC)** + `--provenance` — **kein** + langlebiger npm-Token. + +## Kompatibilität + +`apiVersion` (SemVer-Range) und `minAppVersion` werden beim Laden gegen die App geprüft +(`semver.satisfies` / `semver.gte`). Inkompatible Plugins werden gar nicht erst aktiviert. diff --git a/plugin-template/build.mjs b/plugin-template/build.mjs new file mode 100644 index 00000000..57945ed0 --- /dev/null +++ b/plugin-template/build.mjs @@ -0,0 +1,20 @@ +// Plugin-Build (Main-only): bundelt src/main.ts → dist/main.js (CJS) und schreibt eine kanonische +// dist/manifest.json. Packen + Signieren übernimmt die Build-/Signier-Pipeline (siehe README). +import { build } from 'esbuild' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { PLUGIN_BUILD_OPTIONS, banBuiltins } from './esbuild.config.mjs' + +mkdirSync('dist', { recursive: true }) + +// Kanonische manifest.json (JSON.stringify(v,null,2)+LF) — deterministische Bytes (ADR). +const manifest = JSON.parse(readFileSync('manifest.json', 'utf8')) +writeFileSync('dist/manifest.json', JSON.stringify(manifest, null, 2) + '\n') + +await build({ + ...PLUGIN_BUILD_OPTIONS, + entryPoints: ['src/main.ts'], + outfile: 'dist/main.js', + plugins: [banBuiltins()], +}) + +console.log('Build fertig → dist/main.js + dist/manifest.json') diff --git a/plugin-template/esbuild.config.mjs b/plugin-template/esbuild.config.mjs new file mode 100644 index 00000000..1c0531fc --- /dev/null +++ b/plugin-template/esbuild.config.mjs @@ -0,0 +1,54 @@ +// Geteilte esbuild-Optionen für den Plugin-Main-Bundle (A0/3 Main-only-ABI). +// Wird von build.mjs (Template) UND vom In-Repo-E2E-Test importiert, damit beide IDENTISCH bauen. +// +// ABI (verbindlich): self-contained CommonJS, `module.exports = pluginEntry`. Eine heruntergeladene +// .js unter userData/plugins/ hat keine nahe package.json mit "type":"module" → Node lädt sie als +// CJS. Der footer wandelt esbuilds `export default` in ein direktes `module.exports = entry`. + +import { builtinModules } from 'node:module' + +const BANNED = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), + 'electron', +]) + +/** + * esbuild-Plugin: verbietet Node-/Electron-Built-ins im Plugin-Bundle. Leitplanke (Lint), KEINE + * Isolation — Sicherheit kommt in Phase A aus Signatur + Autorvertrauen (siehe ADR). Der einzige + * vorgesehene Draht nach außen ist `ctx.host`. + */ +export function banBuiltins() { + return { + name: 'ban-node-builtins', + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.kind !== 'import-statement' && args.kind !== 'require-call') return undefined + if (BANNED.has(args.path)) { + return { + errors: [ + { + text: `Node-/Electron-Built-in '${args.path}' ist im Plugin-Bundle verboten (nur ctx.host).`, + }, + ], + } + } + return undefined + }) + }, + } +} + +/** Basis-Optionen; Aufrufer ergänzt entryPoints/outfile (+ im Test: alias auf die Workspace-Quelle). */ +export const PLUGIN_BUILD_OPTIONS = { + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node20', + legalComments: 'none', + // esbuild emittiert bei `export default` ein `exports.default`; hier auf `module.exports = entry` + // umbiegen, damit `require(main.js)` direkt den PluginMainEntry liefert (ABI-Vertrag). + footer: { + js: '\nif (module.exports && module.exports.default !== undefined) { module.exports = module.exports.default }\n', + }, +} diff --git a/plugin-template/manifest.json b/plugin-template/manifest.json new file mode 100644 index 00000000..981c12b1 --- /dev/null +++ b/plugin-template/manifest.json @@ -0,0 +1,25 @@ +{ + "manifestVersion": 2, + "id": "example-plugin", + "version": "0.1.0", + "label": "Example Plugin", + "description": "Main-only Beispiel-Plugin für MindGraph Notes (A0/3 Template).", + "category": "ai", + "apiVersion": "^0.2.0", + "minAppVersion": "0.8.14", + "author": { + "name": "Your Name", + "url": "https://example.com" + }, + "entrypoints": { + "main": "main.js" + }, + "capabilities": [], + "actions": [ + { + "id": "example-plugin.ping", + "label": "Ping", + "requiredCapabilities": [] + } + ] +} diff --git a/plugin-template/package.json b/plugin-template/package.json new file mode 100644 index 00000000..3e24aa3c --- /dev/null +++ b/plugin-template/package.json @@ -0,0 +1,15 @@ +{ + "name": "mindgraph-plugin-example", + "version": "0.1.0", + "private": true, + "description": "Main-only Beispiel-/Starter-Plugin für MindGraph Notes (A0/3 Template).", + "type": "module", + "scripts": { + "build": "node build.mjs", + "keygen:dev": "node scripts/keygen-dev.mjs" + }, + "devDependencies": { + "@mindgraph/plugin-api": "^0.2.0", + "esbuild": "^0.25.0" + } +} diff --git a/plugin-template/scripts/keygen-dev.mjs b/plugin-template/scripts/keygen-dev.mjs new file mode 100644 index 00000000..de8f9f86 --- /dev/null +++ b/plugin-template/scripts/keygen-dev.mjs @@ -0,0 +1,35 @@ +// Erzeugt EINEN lokalen Ed25519-Dev-Schlüssel für Pack-/Verify-Tests. +// +// Sicherheitsregeln (siehe README + ADR): +// - Niemals committen. Die Ausgabedatei (*.dev-key.json) ist git-ignoriert. +// - Der Dev-Key wird NIE automatisch verwendet — Pack/Sign muss ihn explizit übergeben bekommen. +// - Der Produktions-Key wird hiermit NICHT erzeugt (der lebt ausschließlich im geschützten +// CI-Environment als Secret PLUGIN_SIGNING_KEY). +import { generateKeyPairSync } from 'node:crypto' +import { existsSync, writeFileSync } from 'node:fs' + +const out = process.argv[2] ?? 'local.dev-key.json' +if (existsSync(out)) { + console.error(`Abbruch: '${out}' existiert bereits — bestehenden Dev-Key nicht überschreiben.`) + process.exit(1) +} + +const keyId = `dev-${new Date().toISOString().slice(0, 10)}` +const { publicKey, privateKey } = generateKeyPairSync('ed25519') + +writeFileSync( + out, + JSON.stringify( + { + keyId, + algorithm: 'ed25519', + privatePkcs8Pem: privateKey.export({ type: 'pkcs8', format: 'pem' }), + publicSpkiPem: publicKey.export({ type: 'spki', format: 'pem' }), + }, + null, + 2 + ) + '\n' +) + +console.log(`Dev-Key geschrieben: ${out} (git-ignoriert). keyId=${keyId}`) +console.log('WARNUNG: niemals committen. Nur für lokale Pack-/Verify-Tests.') diff --git a/plugin-template/src/main.ts b/plugin-template/src/main.ts new file mode 100644 index 00000000..77402183 --- /dev/null +++ b/plugin-template/src/main.ts @@ -0,0 +1,13 @@ +// Main-Entry des Beispiel-Plugins (Main-only Template, A0/3). +// +// Regeln (siehe README): NUR `@mindgraph/plugin-api` importieren; KEINE Node-/Electron-Built-ins +// (der Build bricht sonst ab). Alle Host-Dienste kommen über `ctx.host` (capability-gated). +import { definePluginMain } from '@mindgraph/plugin-api' + +export default definePluginMain( + { id: 'example-plugin', capabilities: [] }, + ({ actions }) => { + // Beispiel-Action; im Manifest unter `actions` deklariert. + actions.register('example-plugin.ping', async () => ({ pong: true })) + } +)