diff --git a/.changeset/installer-shared-verification.md b/.changeset/installer-shared-verification.md new file mode 100644 index 0000000000..85fa76428a --- /dev/null +++ b/.changeset/installer-shared-verification.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/registry-verification": minor +--- + +Updates registry plugin installation and updates to apply canonical artifact integrity verification while retaining legacy checksum compatibility. diff --git a/packages/core/src/api/handlers/marketplace.ts b/packages/core/src/api/handlers/marketplace.ts index 1bfde39f51..9a4a3020d5 100644 --- a/packages/core/src/api/handlers/marketplace.ts +++ b/packages/core/src/api/handlers/marketplace.ts @@ -164,34 +164,6 @@ async function resolveVersionMetadata( return versions.find((v) => v.version === version) ?? null; } -function validateBundleIdentity( - bundle: PluginBundle, - pluginId: string, - version: string, -): ApiResult | null { - if (bundle.manifest.id !== pluginId) { - return { - success: false, - error: { - code: "MANIFEST_MISMATCH", - message: `Bundle manifest ID (${bundle.manifest.id}) does not match requested plugin (${pluginId})`, - }, - }; - } - - if (bundle.manifest.version !== version) { - return { - success: false, - error: { - code: "MANIFEST_VERSION_MISMATCH", - message: `Bundle manifest version (${bundle.manifest.version}) does not match requested version (${version})`, - }, - }; - } - - return null; -} - /** Store a plugin bundle's files in site-local R2 storage */ /** * Storage source for an installed plugin bundle. Determines the R2 @@ -445,9 +417,6 @@ export async function handleMarketplaceInstall( }; } - const bundleIdentityError = validateBundleIdentity(bundle, pluginId, version); - if (bundleIdentityError) return bundleIdentityError; - // Store bundle in site-local R2 await storeBundleInR2(storage, pluginId, version, bundle); @@ -627,9 +596,6 @@ export async function handleMarketplaceUpdate( }; } - const bundleIdentityError = validateBundleIdentity(bundle, pluginId, newVersion); - if (bundleIdentityError) return bundleIdentityError; - // Diff capabilities and route visibility against old version const oldBundle = await loadBundleFromR2(storage, pluginId, oldVersion); const oldCaps = oldBundle?.manifest.capabilities ?? []; diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index 138bd40ee5..79aafc9077 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -42,10 +42,16 @@ import { ClientResponseError, ClientValidationError } from "@atcute/client"; import type { Did } from "@atcute/lexicons"; import { checkEnvCompatibility, findSkippedEnvConstraints } from "@emdash-cms/registry-client/env"; import type { HostEnv } from "@emdash-cms/registry-client/env"; +import { + compareDigestBytes, + computeMultihash, + decodeMultihash, + verifyMultihash, +} from "@emdash-cms/registry-verification/checksum"; import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; -import { extractBundle } from "../../plugins/marketplace.js"; +import { extractBundle, MarketplaceError } from "../../plugins/marketplace.js"; import type { PluginBundle } from "../../plugins/marketplace.js"; import type { SandboxRunner } from "../../plugins/sandbox/types.js"; import { PluginStateRepository } from "../../plugins/state.js"; @@ -141,42 +147,6 @@ export interface RegistryInstallResult { /** Matches a bare 64-character lowercase/uppercase hex SHA-256 digest. */ const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/i; -/** Compute the SHA-256 of `bytes` as a lowercase hex string. */ -async function sha256Hex(bytes: Uint8Array): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Uint8Array is a valid BufferSource at runtime - const buf = await crypto.subtle.digest("SHA-256", bytes as unknown as BufferSource); - const arr = new Uint8Array(buf); - return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -/** multihash code for sha2-256 (single-byte varint). */ -const MULTIHASH_SHA256_CODE = 0x12; -/** sha2-256 digest length in bytes (single-byte varint). */ -const MULTIHASH_SHA256_LENGTH = 0x20; - -/** - * Compute the multibase-multihash sha2-256 checksum of `bytes`, in the - * same `b` shape the registry CLI publishes - * (`packages/plugin-cli/src/multihash.ts`). Returns a 56-character - * string starting with `b`. - * - * The trust contract is: if both sides produce the same string for - * the same bytes, the bytes are unchanged. We don't decode the - * publisher-supplied checksum -- we just re-encode our own and compare, - * which is equivalent and avoids needing a base32 decoder. - */ -async function sha256MultibaseMultihash(bytes: Uint8Array): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Uint8Array is a valid BufferSource at runtime - const digestBuf = await crypto.subtle.digest("SHA-256", bytes as unknown as BufferSource); - const digest = new Uint8Array(digestBuf); - const multihash = new Uint8Array(2 + digest.length); - multihash[0] = MULTIHASH_SHA256_CODE; - multihash[1] = MULTIHASH_SHA256_LENGTH; - multihash.set(digest, 2); - const { toBase32 } = await import("@atcute/multibase"); - return `b${toBase32(multihash)}`; -} - /** * Verify that a checksum string from a release record's * `artifact.checksum` field corresponds to the SHA-256 of the given @@ -195,25 +165,81 @@ async function sha256MultibaseMultihash(bytes: Uint8Array): Promise { */ export async function verifyChecksum(bytes: Uint8Array, checksum: string): Promise { if (SHA256_HEX_PATTERN.test(checksum)) { - const actual = await sha256Hex(bytes); - return checksum.toLowerCase() === actual; + const computed = await computeMultihash(bytes); + if (!computed.success) return false; + const decoded = decodeMultihash(computed.value); + if (!decoded.success) return false; + const expected = new Uint8Array(32); + for (let offset = 0; offset < checksum.length; offset += 2) { + expected[offset / 2] = Number.parseInt(checksum.slice(offset, offset + 2), 16); + } + return compareDigestBytes(decoded.value.digest, expected); } - // Multibase-base32 multihash with sha2-256. We re-encode our own - // digest in the same shape and compare strings -- equivalent to - // decoding and comparing bytes, but doesn't need a base32 decoder. + // Multibase-base32 multihash with sha2-256. Preserve the installer's + // case-insensitive base32 compatibility at this legacy boundary. // 56 chars = 'b' + base32(34 bytes) = 'b' + 55 chars. if (checksum.length === 56 && checksum.startsWith("b")) { - const actual = await sha256MultibaseMultihash(bytes); - // Case-insensitive: multibase 'b' is lowercase by convention but - // some emitters use uppercase. RFC 4648 base32 alphabets are - // case-insensitive. - return actual.toLowerCase() === checksum.toLowerCase(); + return (await verifyMultihash(bytes, checksum.toLowerCase())).success; } return false; } +type RegistryArtifactOperation = "install" | "update"; + +/** + * Adapt shared bundle verification to the installer API's established error + * codes and messages. A mismatch is parsed once more without expectations so + * core can retain its dynamic messages and version-before-identity precedence. + */ +export async function validateRegistryArtifactBundle( + artifactBytes: Uint8Array, + expectedSlug: string, + expectedVersion: string, + operation: RegistryArtifactOperation, +): Promise> { + try { + return { + success: true, + data: await extractBundle(artifactBytes, { expectedSlug, expectedVersion }), + }; + } catch (error) { + if ( + error instanceof MarketplaceError && + (error.code === "MANIFEST_MISMATCH" || error.code === "MANIFEST_VERSION_MISMATCH") + ) { + const bundle = await extractBundle(artifactBytes); + if (bundle.manifest.version !== expectedVersion) { + return { + success: false, + error: { + code: operation === "install" ? "MANIFEST_VERSION_MISMATCH" : "BUNDLE_VERSION_MISMATCH", + message: `Bundle manifest version (${bundle.manifest.version}) does not match release version (${expectedVersion})`, + }, + }; + } + return { + success: false, + error: { + code: operation === "install" ? "MANIFEST_ID_MISMATCH" : "BUNDLE_IDENTITY_MISMATCH", + message: `Bundle manifest id (${bundle.manifest.id}) does not match registry slug (${expectedSlug})`, + }, + }; + } + if (operation === "install") { + return { + success: false, + error: { + code: "INVALID_BUNDLE", + message: error instanceof Error ? error.message : "Failed to extract plugin bundle", + }, + }; + } + throw error; + } +} + /** * Bytes-per-artifact cap on the gzipped tarball we'll download before * decompression. RFC 0001 caps a sandboxed plugin bundle at 256 KiB @@ -970,47 +996,15 @@ export async function handleRegistryInstall( }; } - // Step 6: extract the bundle. - let bundle: PluginBundle; - try { - bundle = await extractBundle(artifactBytes); - } catch (err) { - return { - success: false, - error: { - code: "INVALID_BUNDLE", - message: err instanceof Error ? err.message : "Failed to extract plugin bundle", - }, - }; - } - - // Manifest sanity: declared version must match the release's version. - if (bundle.manifest.version !== version) { - return { - success: false, - error: { - code: "MANIFEST_VERSION_MISMATCH", - message: `Bundle manifest version (${bundle.manifest.version}) does not match release version (${version})`, - }, - }; - } - - // Manifest identity: the bundle's `manifest.id` is the publisher's - // natural plugin id (their slug). It MUST equal the slug the - // install was requested for; otherwise a malicious registry bundle - // could declare `manifest.id: "audit-log"` and confuse the sandbox - // bridge, which uses `manifest.id` as the trust key for - // per-plugin storage, cron schedules, and bridge-scoped - // operations. - if (bundle.manifest.id !== slug) { - return { - success: false, - error: { - code: "MANIFEST_ID_MISMATCH", - message: `Bundle manifest id (${bundle.manifest.id}) does not match registry slug (${slug})`, - }, - }; - } + // Step 6: extract and verify the bundle manifest against this release. + const bundleResult = await validateRegistryArtifactBundle( + artifactBytes, + slug, + version, + "install", + ); + if (!bundleResult.success) return bundleResult; + const bundle = bundleResult.data; // Rewrite the manifest's id to the derived opaque pluginId before // it reaches R2 storage or the sandbox loader. The sandbox uses @@ -1510,26 +1504,14 @@ export async function handleRegistryUpdate( }; } - const bundle: PluginBundle = await extractBundle(artifactBytes); - - if (bundle.manifest.version !== newVersion) { - return { - success: false, - error: { - code: "BUNDLE_VERSION_MISMATCH", - message: `Bundle manifest version (${bundle.manifest.version}) does not match release version (${newVersion})`, - }, - }; - } - if (bundle.manifest.id !== slug) { - return { - success: false, - error: { - code: "BUNDLE_IDENTITY_MISMATCH", - message: `Bundle manifest id (${bundle.manifest.id}) does not match registry slug (${slug})`, - }, - }; - } + const bundleResult = await validateRegistryArtifactBundle( + artifactBytes, + slug, + newVersion, + "update", + ); + if (!bundleResult.success) return bundleResult; + const bundle = bundleResult.data; // Rewrite manifest.id to the opaque pluginId so the sandbox loader // and R2 layout stay in sync across install and update. diff --git a/packages/core/src/plugins/marketplace.ts b/packages/core/src/plugins/marketplace.ts index f47cd2e106..3d0ef43b68 100644 --- a/packages/core/src/plugins/marketplace.ts +++ b/packages/core/src/plugins/marketplace.ts @@ -10,6 +10,7 @@ import { validatePluginBundle, type ValidatePluginBundleOptions, } from "@emdash-cms/registry-verification/bundle"; +import { computeMultihash, decodeMultihash } from "@emdash-cms/registry-verification/checksum"; import type { PluginManifest } from "./types.js"; @@ -397,11 +398,25 @@ export async function extractBundle( throw new MarketplaceError(result.error.message, undefined, code); } - // Compute SHA-256 checksum of the tarball for verification - // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Uint8Array is a valid BufferSource at runtime; TS lib mismatch - const hashBuffer = await crypto.subtle.digest("SHA-256", tarballBytes as unknown as BufferSource); - const hashArray = new Uint8Array(hashBuffer); - const checksum = Array.from(hashArray, (b) => b.toString(16).padStart(2, "0")).join(""); + const multihash = await computeMultihash(tarballBytes); + if (!multihash.success) { + throw new MarketplaceError( + "Failed to compute plugin bundle checksum", + undefined, + "INVALID_BUNDLE", + ); + } + const decoded = decodeMultihash(multihash.value); + if (!decoded.success) { + throw new MarketplaceError( + "Failed to compute plugin bundle checksum", + undefined, + "INVALID_BUNDLE", + ); + } + const checksum = Array.from(decoded.value.digest, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); return { // Canonical validation uses the shared wire type. Its schema restricts diff --git a/packages/core/tests/unit/plugins/marketplace-client.test.ts b/packages/core/tests/unit/plugins/marketplace-client.test.ts index 674716d6df..29b0eeee83 100644 --- a/packages/core/tests/unit/plugins/marketplace-client.test.ts +++ b/packages/core/tests/unit/plugins/marketplace-client.test.ts @@ -9,6 +9,8 @@ * - reportInstall (fire-and-forget) */ +import { createHash } from "node:crypto"; + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { @@ -21,7 +23,6 @@ import { type MarketplaceSearchResult, } from "../../../src/plugins/marketplace.js"; -const HEX_64_PATTERN = /^[a-f0-9]{64}$/; const HEX_16_PATTERN = /^[a-f0-9]{16}$/; // ── Helpers ───────────���──────────────────────────────────────────── @@ -320,7 +321,7 @@ describe("MarketplaceClient", () => { expect(bundle.manifest.id).toBe("test-seo"); expect(bundle.manifest.version).toBe("1.0.0"); expect(bundle.backendCode).toContain("hello"); - expect(bundle.checksum).toMatch(HEX_64_PATTERN); + expect(bundle.checksum).toBe(createHash("sha256").update(gzipped).digest("hex")); }); it("extracts optional admin.js", async () => { diff --git a/packages/core/tests/unit/registry/artifact-verification.test.ts b/packages/core/tests/unit/registry/artifact-verification.test.ts new file mode 100644 index 0000000000..ca5f65adb2 --- /dev/null +++ b/packages/core/tests/unit/registry/artifact-verification.test.ts @@ -0,0 +1,251 @@ +import { gzipSync } from "node:zlib"; + +import { + MAX_BUNDLE_COMPRESSED_BYTES, + MAX_BUNDLE_FILE_BYTES, + MAX_BUNDLE_TAR_ENTRY_COUNT, +} from "@emdash-cms/registry-verification"; +import { computeMultihash } from "@emdash-cms/registry-verification/checksum"; +import { packTar, type TarEntry } from "modern-tar"; +import { describe, expect, it } from "vitest"; + +import { + enforcedAccessEqual, + validateRegistryArtifactBundle, + verifyChecksum, +} from "../../../src/api/handlers/registry.js"; + +const encoder = new TextEncoder(); +const manifest = { + id: "test-plugin", + version: "1.0.0", + capabilities: ["content:read"], + allowedHosts: [], + storage: {}, + hooks: [], + routes: [], + admin: {}, +}; + +function file(name: string, body: string | Uint8Array): TarEntry { + const bytes = typeof body === "string" ? encoder.encode(body) : body; + return { header: { name, size: bytes.byteLength, type: "file" }, body: bytes }; +} + +async function bundle(entries: TarEntry[]): Promise { + return new Uint8Array(gzipSync(await packTar(entries))); +} + +async function canonicalBundle( + overrides: Partial = {}, + extraEntries: TarEntry[] = [], +): Promise { + return bundle([ + file("manifest.json", JSON.stringify({ ...manifest, ...overrides })), + file("backend.js", "export default {};"), + ...extraEntries, + ]); +} + +describe("registry artifact verification", () => { + it("accepts a valid canonical bundle", async () => { + const result = await validateRegistryArtifactBundle( + await canonicalBundle(), + "test-plugin", + "1.0.0", + "install", + ); + + expect(result).toMatchObject({ + success: true, + data: { + manifest: { id: "test-plugin", version: "1.0.0" }, + backendCode: "export default {};", + }, + }); + }); + + it.each([ + [async () => encoder.encode("not a gzip archive"), "The plugin bundle is not valid gzip data."], + [ + () => + bundle([ + file("../manifest.json", JSON.stringify(manifest)), + file("backend.js", "export default {};"), + ]), + "The plugin bundle contains an unsafe path.", + ], + [ + () => + bundle([ + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "export default {};"), + { header: { name: "link", size: 0, type: "symlink" } }, + ]), + "The plugin bundle contains an unsupported archive entry type.", + ], + ] as const)( + "preserves install rejection for malformed and unsafe archives", + async (makeBytes, message) => { + const result = await validateRegistryArtifactBundle( + await makeBytes(), + "test-plugin", + "1.0.0", + "install", + ); + expect(result).toEqual({ success: false, error: { code: "INVALID_BUNDLE", message } }); + }, + ); + + it("enforces compressed, file-size, and archive-entry limits", async () => { + const compressed = await validateRegistryArtifactBundle( + new Uint8Array(MAX_BUNDLE_COMPRESSED_BYTES + 1), + "test-plugin", + "1.0.0", + "install", + ); + expect(compressed).toMatchObject({ + success: false, + error: { code: "INVALID_BUNDLE", message: expect.stringContaining("compressed") }, + }); + + const oversizedEntry = await validateRegistryArtifactBundle( + await canonicalBundle({}, [file("large.bin", new Uint8Array(MAX_BUNDLE_FILE_BYTES + 1))]), + "test-plugin", + "1.0.0", + "install", + ); + expect(oversizedEntry).toMatchObject({ + success: false, + error: { code: "INVALID_BUNDLE", message: expect.stringContaining("per-file") }, + }); + + const directories: TarEntry[] = Array.from( + { length: MAX_BUNDLE_TAR_ENTRY_COUNT - 1 }, + (_, index) => ({ header: { name: `dir-${index}/`, size: 0, type: "directory" } }), + ); + const tooManyEntries = await validateRegistryArtifactBundle( + await canonicalBundle({}, directories), + "test-plugin", + "1.0.0", + "install", + ); + expect(tooManyEntries).toMatchObject({ + success: false, + error: { code: "INVALID_BUNDLE", message: expect.stringContaining("archive entries") }, + }); + }); + + it.each([ + [[file("backend.js", "export default {};")], "The plugin bundle is missing manifest.json."], + [ + [ + file("manifest.json", JSON.stringify(manifest)), + file("manifest.json", JSON.stringify(manifest)), + file("backend.js", "export default {};"), + ], + "The plugin bundle contains duplicate or ambiguous paths.", + ], + [ + [file("manifest.json", "{"), file("backend.js", "export default {};")], + "The plugin bundle manifest is not valid JSON.", + ], + [ + [file("manifest.json", JSON.stringify({ id: "test-plugin" })), file("backend.js", "x")], + "The plugin bundle manifest failed schema validation.", + ], + ] satisfies [TarEntry[], string][])( + "preserves manifest rejection: %s", + async (entries, message) => { + const result = await validateRegistryArtifactBundle( + await bundle(entries), + "test-plugin", + "1.0.0", + "install", + ); + expect(result).toEqual({ success: false, error: { code: "INVALID_BUNDLE", message } }); + }, + ); + + it("preserves install manifest identity and version errors", async () => { + const identity = await validateRegistryArtifactBundle( + await canonicalBundle({ id: "other-plugin" }), + "test-plugin", + "1.0.0", + "install", + ); + expect(identity).toEqual({ + success: false, + error: { + code: "MANIFEST_ID_MISMATCH", + message: "Bundle manifest id (other-plugin) does not match registry slug (test-plugin)", + }, + }); + + const version = await validateRegistryArtifactBundle( + await canonicalBundle({ version: "2.0.0" }), + "test-plugin", + "1.0.0", + "install", + ); + expect(version).toEqual({ + success: false, + error: { + code: "MANIFEST_VERSION_MISMATCH", + message: "Bundle manifest version (2.0.0) does not match release version (1.0.0)", + }, + }); + + const both = await validateRegistryArtifactBundle( + await canonicalBundle({ id: "other-plugin", version: "2.0.0" }), + "test-plugin", + "1.0.0", + "install", + ); + expect(both.error?.code).toBe("MANIFEST_VERSION_MISMATCH"); + }); + + it("preserves update manifest identity and version errors", async () => { + const identity = await validateRegistryArtifactBundle( + await canonicalBundle({ id: "other-plugin" }), + "test-plugin", + "1.0.0", + "update", + ); + expect(identity.error?.code).toBe("BUNDLE_IDENTITY_MISMATCH"); + + const version = await validateRegistryArtifactBundle( + await canonicalBundle({ version: "2.0.0" }), + "test-plugin", + "1.0.0", + "update", + ); + expect(version.error?.code).toBe("BUNDLE_VERSION_MISMATCH"); + }); + + it("preserves legacy hex and multihash checksum compatibility", async () => { + const bytes = await canonicalBundle(); + const multihash = await computeMultihash(bytes); + expect(multihash.success).toBe(true); + if (!multihash.success) return; + + expect(await verifyChecksum(bytes, multihash.value)).toBe(true); + expect(await verifyChecksum(bytes, `b${multihash.value.slice(1).toUpperCase()}`)).toBe(true); + expect(await verifyChecksum(bytes, "0".repeat(64))).toBe(false); + }); + + it("preserves enforced access consistency semantics", () => { + expect( + enforcedAccessEqual( + { network: { request: { allowedHosts: ["a.example", "b.example"] } } }, + { network: { request: { allowedHosts: ["b.example", "a.example"] } } }, + ), + ).toBe(true); + expect( + enforcedAccessEqual( + { network: { request: { allowedHosts: ["a.example"] } } }, + { network: { request: {} } }, + ), + ).toBe(false); + }); +}); diff --git a/packages/registry-verification/package.json b/packages/registry-verification/package.json index be67f331f9..85f4646e2b 100644 --- a/packages/registry-verification/package.json +++ b/packages/registry-verification/package.json @@ -13,6 +13,10 @@ "types": "./dist/bundle.d.ts", "default": "./dist/bundle.js" }, + "./checksum": { + "types": "./dist/checksum.d.ts", + "default": "./dist/checksum.js" + }, "./fetch": { "types": "./dist/fetch-entry.d.ts", "default": "./dist/fetch-entry.js" diff --git a/packages/registry-verification/scripts/check-packed-output.mjs b/packages/registry-verification/scripts/check-packed-output.mjs index 3d7311e722..21cc8a6ca0 100644 --- a/packages/registry-verification/scripts/check-packed-output.mjs +++ b/packages/registry-verification/scripts/check-packed-output.mjs @@ -31,6 +31,10 @@ try { join(extracted, "package", "dist", "bundle.js"), "utf8", ); + const publishedChecksumOutput = await readFile( + join(extracted, "package", "dist", "checksum.js"), + "utf8", + ); const publishedFetchOutput = await readFile( join(extracted, "package", "dist", "fetch-entry.js"), "utf8", @@ -38,6 +42,8 @@ try { if ( publishedBundleOutput.includes("createRequire") || publishedBundleOutput.includes("@sigstore") || + publishedChecksumOutput.includes("createRequire") || + publishedChecksumOutput.includes("@sigstore") || publishedFetchOutput.includes("createRequire") || publishedFetchOutput.includes("@sigstore") ) { diff --git a/packages/registry-verification/tsdown.config.ts b/packages/registry-verification/tsdown.config.ts index 2d55fba8d4..8298fae396 100644 --- a/packages/registry-verification/tsdown.config.ts +++ b/packages/registry-verification/tsdown.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsdown"; export default defineConfig([ { - entry: ["src/bundle.ts", "src/fetch-entry.ts"], + entry: ["src/bundle.ts", "src/checksum.ts", "src/fetch-entry.ts"], format: ["esm"], outExtensions: () => ({ js: ".js" }), dts: true,