feat(m4): verify signed surface bundles across hosts
This commit is contained in:
+53
-1
@@ -16,7 +16,10 @@ import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport
|
||||
import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers";
|
||||
import { SurfaceRegistry } from "./runtime/surface-registry";
|
||||
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager";
|
||||
import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host";
|
||||
import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "./runtime/isolated-surface-host";
|
||||
import { ProductionSurfaceManifestRegistry } from "./runtime/production-surface-manifest";
|
||||
import { ProductionSurfacePolicy } from "./runtime/production-surface-policy";
|
||||
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "./runtime/surface-bundle-cache";
|
||||
import { CapabilityRegistry } from "./runtime/capability-registry";
|
||||
import { ClientInventoryPublisher } from "./runtime/client-inventory";
|
||||
import { ArtifactState } from "./runtime/artifact-state";
|
||||
@@ -35,6 +38,17 @@ type Session = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly env: Readonly<{ DEV: boolean }>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
/** Development-only M4 signed-bundle browser acceptance controller. */
|
||||
__lineupM4Fixture?: Readonly<{ patch: () => void; close: () => void }>;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_API = "http://127.0.0.1:8090";
|
||||
let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false };
|
||||
let transport: TransportAdapter | undefined;
|
||||
@@ -104,6 +118,43 @@ const surfaceHost = new IsolatedSurfaceHost(messages, {
|
||||
});
|
||||
$<HTMLInputElement>("#api").value = session.api;
|
||||
|
||||
/**
|
||||
* A pre-signed public fixture used only by the headed M4 acceptance route.
|
||||
* It contains no private key, is excluded from production builds, and forces
|
||||
* the actual Ed25519 → size → SHA-256 → cache → exact resolver path.
|
||||
*/
|
||||
async function mountM4SignedFixture(): Promise<void> {
|
||||
if (!import.meta.env.DEV || !new URLSearchParams(location.search).has("m4-signed-surface-e2e")) return;
|
||||
const encodedBundle = "PG1haW4-PGgyIGlkPSJtNC1zaWduZWQiPk00IOW3suetvuWQjSBTdXJmYWNlPC9oMj48cCBpZD0ic3RhdGUiPuetieW-heeKtuaAgTwvcD48c2NyaXB0PndpbmRvdy5hZGRFdmVudExpc3RlbmVyKCJtZXNzYWdlIixlPT57Y29uc3QgZD1lLmRhdGE7aWYoZCYmZC50eXBlPT09ImxpbmV1cC5zdXJmYWNlLnYxLnN0YXRlIilkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCIjc3RhdGUiKS50ZXh0Q29udGVudD1TdHJpbmcoZC5zdGF0ZT8uc3RhdHVzfHwi6L-Q6KGM5LitIil9KTt3aW5kb3cucGFyZW50LnBvc3RNZXNzYWdlKHt2OjEsdHlwZToibGluZXVwLnN1cmZhY2UudjEucmVhZHkiLGluc3RhbmNlX2lkOndpbmRvdy5fX0xJTkVVUF9TVVJGQUNFX0lOU1RBTkNFX199LCIqIik8L3NjcmlwdD48L21haW4-";
|
||||
const standardBase64 = encodedBundle.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const bundle = Uint8Array.from(atob(standardBase64 + "=".repeat((4 - standardBase64.length % 4) % 4)), value => value.charCodeAt(0));
|
||||
const manifest = {
|
||||
v: 1, app_id: "lineup.m4-signed-fixture", version: "1.0.0",
|
||||
artifact: { artifact_id: "m4-signed-fixture", sha256: "98cbcf29d38cf08ae6385539b3ac7359710309a1b752a14b60c8cfc8a39f1a37", size_bytes: 396 },
|
||||
min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "m4-e2e-key",
|
||||
signature: "dCORfPL6LTxR8CB5mVss5fEeGozTdfN3L7Td27BYgxJ518yNWAmI4FEhfrjEoc_Wovwn8u8HI5BLBjWSUxZ6Dg",
|
||||
} as const;
|
||||
const cache = new VerifiedSurfaceBundleCache(new WebCryptoEd25519SurfaceManifestSignatureVerifier({ "m4-e2e-key": "AKx_a1NVmsJKbqY9Jw55hLp8r01TcCadjZNp1u2POLI" }));
|
||||
const policy = new ProductionSurfacePolicy(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: ["m4-e2e-key"] }), cache);
|
||||
const downloader = new TrustedSurfaceBundleDownloader("https://m4-e2e.lineup.invalid/release", async url => ({ ok: true, url: url.toString(), async bytes() { return bundle; } }));
|
||||
if ((await policy.downloadAndEnable(manifest, downloader)).disposition !== "installed") throw new Error("M4 signed fixture verification failed.");
|
||||
const instances = new SurfaceInstanceManager(policy);
|
||||
const host = new IsolatedSurfaceHost(messages, {
|
||||
ready(instanceID) { instances.ready(instanceID, new Date().toISOString()); },
|
||||
event() { /* fixture grants no outbound capabilities or transport events */ },
|
||||
}, new CachedVerifiedSurfaceDocumentResolver(cache));
|
||||
const opened = instances.open({ instance_id: "m4:signed-fixture", app: { app_id: manifest.app_id, version: manifest.version }, state: { status: "签名验证后已挂载" } }, currentConversationKey(), new Date().toISOString());
|
||||
if (opened.disposition !== "accepted" || !opened.instance) throw new Error(`M4 signed fixture open rejected: ${opened.disposition}`);
|
||||
host.mount(opened.instance);
|
||||
window.__lineupM4Fixture = {
|
||||
patch() {
|
||||
const result = instances.patch("m4:signed-fixture", { status: "已通过可信 patch 更新" }, new Date().toISOString());
|
||||
if (result.disposition === "accepted" && result.instance) host.update(result.instance);
|
||||
},
|
||||
close() { host.unmount("m4:signed-fixture"); instances.close("m4:signed-fixture"); },
|
||||
};
|
||||
}
|
||||
|
||||
function newLocalID(prefix: string): string {
|
||||
// `crypto.randomUUID` is absent from older Safari / WKWebView versions.
|
||||
// A local item id needs uniqueness within this browser store, not a
|
||||
@@ -738,6 +789,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
}
|
||||
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
|
||||
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
|
||||
await mountM4SignedFixture();
|
||||
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop();
|
||||
} catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
|
||||
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeConversationItem, type ConversationItem, type JsonObject } from "../protocol";
|
||||
import { InteractionKernel } from "./interaction-kernel";
|
||||
import fixture from "./golden/lineup-v1.json";
|
||||
|
||||
type GoldenExpected = Readonly<{
|
||||
item_kind: ConversationItem["kind"];
|
||||
fallback?: "invalid_payload" | "unsupported_version";
|
||||
kernel_tool_status?: "pending" | "completed";
|
||||
}>;
|
||||
type GoldenTransition = Readonly<{ after_envelope: number; kind: "submit_tool_call"; call_id: string; submission: JsonObject }>;
|
||||
type GoldenCase = Readonly<{ id: string; expected: GoldenExpected; envelopes: readonly unknown[]; transitions?: readonly GoldenTransition[] }>;
|
||||
|
||||
const cases = fixture.cases as readonly GoldenCase[];
|
||||
|
||||
describe("LineUp v1 host-neutral golden contract", () => {
|
||||
it("uses a versioned portable fixture", () => {
|
||||
expect(fixture.contract).toBe("lineup-v1-golden-1");
|
||||
expect(cases.map(test => test.id)).toEqual([
|
||||
"tool-call-confirm", "tool-result-completes-call", "surface-open-is-data-only",
|
||||
"surface-source-injection-rejected", "app-call-is-a-consent-request", "unknown-protocol-version-rejected",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const test of cases) {
|
||||
it(`${test.id}: accepts only the documented projection`, () => {
|
||||
const kernel = new InteractionKernel();
|
||||
let item: ConversationItem | undefined;
|
||||
for (const [index, envelope] of test.envelopes.entries()) {
|
||||
const raw = JSON.stringify(envelope);
|
||||
item = decodeConversationItem(raw);
|
||||
kernel.ingest({ raw, source: "live", received_at: "2026-08-04T00:00:00.000Z" });
|
||||
for (const transition of test.transitions?.filter(entry => entry.after_envelope === index) ?? []) {
|
||||
kernel.submitToolCall(transition.call_id, "2026-08-04T00:00:01.000Z", transition.submission);
|
||||
}
|
||||
}
|
||||
expect(item?.kind).toBe(test.expected.item_kind);
|
||||
if (test.expected.fallback) {
|
||||
expect(item).toMatchObject({ kind: "fallback", reason: test.expected.fallback });
|
||||
expect(kernel.snapshot().tool_calls).toEqual([]);
|
||||
}
|
||||
if (test.expected.kernel_tool_status) {
|
||||
expect(kernel.snapshot().tool_calls).toMatchObject([{ call_id: test.id === "tool-call-confirm" ? "golden-confirm" : "golden-result", status: test.expected.kernel_tool_status }]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"contract": "lineup-v1-golden-1",
|
||||
"description": "Host-neutral acceptance and safe-fallback cases. Every Host must produce the stated item kind and must never execute a rejected input.",
|
||||
"cases": [
|
||||
{
|
||||
"id": "tool-call-confirm",
|
||||
"expected": { "item_kind": "tool-call", "kernel_tool_status": "pending" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-tool-call", "type": "lineup.v1.tool.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-confirm", "tool": "confirm", "title": "确认操作", "prompt": "继续吗?", "data": { "confirm": { "approve_label": "继续", "cancel_label": "取消" } } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tool-result-completes-call",
|
||||
"expected": { "item_kind": "tool-result", "kernel_tool_status": "completed" },
|
||||
"transitions": [
|
||||
{ "after_envelope": 0, "kind": "submit_tool_call", "call_id": "golden-result", "submission": { "approved": true } }
|
||||
],
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-tool-open", "type": "lineup.v1.tool.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-result", "tool": "confirm", "title": "确认操作", "prompt": "继续吗?", "data": { "confirm": {} } } },
|
||||
{ "v": 1, "id": "golden-tool-result", "type": "lineup.v1.tool.result", "conversation_id": "golden-conversation", "sender": { "kind": "human", "id": "golden-human" }, "payload": { "call_id": "golden-result", "status": "completed", "result": { "approved": true } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "surface-open-is-data-only",
|
||||
"expected": { "item_kind": "surface" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-surface-open", "type": "lineup.v1.ui.open", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "instance_id": "golden:surface", "app": { "app_id": "lineup.task-dashboard", "version": "0.1.0" }, "state": { "title": "同步中", "percent": 42 } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "surface-source-injection-rejected",
|
||||
"expected": { "item_kind": "fallback", "fallback": "invalid_payload" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-surface-injection", "type": "lineup.v1.ui.open", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "instance_id": "golden:injection", "app": { "app_id": "lineup.task-dashboard", "version": "0.1.0" }, "html": "<script>evil()</script>" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "app-call-is-a-consent-request",
|
||||
"expected": { "item_kind": "app-call" },
|
||||
"envelopes": [
|
||||
{ "v": 1, "id": "golden-app-call", "type": "lineup.v1.app.call", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "call_id": "golden-capability", "capability": "app.open_url", "reason": "打开帮助页面", "expires_at": "2026-08-04T01:00:00.000Z", "arguments": { "url": "https://example.com/help" } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unknown-protocol-version-rejected",
|
||||
"expected": { "item_kind": "fallback", "fallback": "unsupported_version" },
|
||||
"envelopes": [
|
||||
{ "v": 2, "id": "golden-v2", "type": "lineup.v1.text", "conversation_id": "golden-conversation", "sender": { "kind": "agent", "id": "golden-agent" }, "payload": { "text": "must not render as trusted v1" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseReadyMessage, surfaceDocument } from "./isolated-surface-host";
|
||||
import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "./isolated-surface-host";
|
||||
import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache";
|
||||
|
||||
describe("isolated Surface bridge contract", () => {
|
||||
it("accepts only the exact minimal ready message", () => {
|
||||
@@ -17,4 +18,25 @@ describe("isolated Surface bridge contract", () => {
|
||||
expect(document).toContain("lineup.surface.v1.ready");
|
||||
expect(document).not.toContain("https://");
|
||||
});
|
||||
|
||||
it("wraps only an exact verified production bundle in the host default-deny CSP", async () => {
|
||||
// Digest mismatch behavior belongs to the cache suite. This test isolates
|
||||
// the resolver's exact-version lookup and Host-owned CSP wrapping.
|
||||
const digest: SurfaceBundleDigest = { async sha256() { return "b".repeat(64); } };
|
||||
const signatures: SurfaceManifestSignatureVerifier = { async verify() { return true; } };
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
const bundle = new TextEncoder().encode("<main><script>window.ok=1<\/script></main>");
|
||||
await cache.install({
|
||||
v: 1, app_id: "lineup.task-dashboard", version: "1.0.0",
|
||||
artifact: { artifact_id: "dashboard-prod", sha256: "b".repeat(64), size_bytes: bundle.length },
|
||||
min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86),
|
||||
}, bundle);
|
||||
const resolver = new CachedVerifiedSurfaceDocumentResolver(cache);
|
||||
const document = resolver.documentFor({ instance_id: "surface:001", conversation_id: "conversation", app_id: "lineup.task-dashboard", version: "1.0.0", state: {}, phase: "opening", opened_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:00.000Z" });
|
||||
expect(document).toContain("default-src 'none'");
|
||||
expect(document).toContain("window.__LINEUP_SURFACE_INSTANCE__=\"surface:001\"");
|
||||
expect(document).toContain("window.ok=1");
|
||||
expect(resolver.documentFor({ instance_id: "surface:002", conversation_id: "conversation", app_id: "lineup.task-dashboard", version: "9.9.9", state: {}, phase: "opening", opened_at: "2026-08-04T00:00:00.000Z", updated_at: "2026-08-04T00:00:00.000Z" })).toBeUndefined();
|
||||
expect(productionSurfaceDocument("surface:003", "<script src=\"https://attacker.invalid/x.js\"><\/script>")).toContain("script-src 'unsafe-inline'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { JsonObject } from "../protocol";
|
||||
import type { SurfaceInstance } from "./surface-instance-manager";
|
||||
import type { VerifiedSurfaceBundleCache } from "./surface-bundle-cache";
|
||||
|
||||
export type SurfaceHostCallbacks = Readonly<{
|
||||
ready: (instanceID: string) => void;
|
||||
event: (instanceID: string, event: "cancel") => void;
|
||||
}>;
|
||||
|
||||
/** Resolves only host-verified bytes to an iframe document; it has no bridge authority. */
|
||||
export interface VerifiedSurfaceDocumentResolver {
|
||||
documentFor(instance: SurfaceInstance): string | undefined;
|
||||
}
|
||||
|
||||
type HostedSurface = {
|
||||
readonly instance: SurfaceInstance;
|
||||
readonly frame: HTMLIFrameElement;
|
||||
@@ -26,7 +32,7 @@ const STATE_TYPE = "lineup.surface.v1.state";
|
||||
export class IsolatedSurfaceHost {
|
||||
private readonly surfaces = new Map<string, HostedSurface>();
|
||||
|
||||
public constructor(private readonly mountPoint: HTMLElement, private readonly callbacks: SurfaceHostCallbacks) {
|
||||
public constructor(private readonly mountPoint: HTMLElement, private readonly callbacks: SurfaceHostCallbacks, private readonly documents?: VerifiedSurfaceDocumentResolver) {
|
||||
window.addEventListener("message", this.receiveMessage);
|
||||
}
|
||||
|
||||
@@ -37,15 +43,18 @@ export class IsolatedSurfaceHost {
|
||||
if (current.ready) this.sendState(current, instance.state);
|
||||
return;
|
||||
}
|
||||
// Development uses the static dashboard. A production resolver can return
|
||||
// only bytes that were already signature/hash verified by M4-02; a missing
|
||||
// exact version is a safe no-op rather than a fallback to unverified code.
|
||||
const resolvedDocument = this.documents ? this.documents.documentFor(instance) : surfaceDocument(instance.instance_id);
|
||||
if (!resolvedDocument) return;
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "isolated-surface-frame";
|
||||
frame.title = `LineUp Surface ${instance.app_id}`;
|
||||
frame.setAttribute("sandbox", "allow-scripts");
|
||||
frame.setAttribute("referrerpolicy", "no-referrer");
|
||||
frame.setAttribute("aria-label", "受限扩展界面");
|
||||
// srcdoc comes only from this module's static source. No Envelope field,
|
||||
// manifest field, Agent content or remote response can alter it.
|
||||
frame.srcdoc = surfaceDocument(instance.instance_id);
|
||||
frame.srcdoc = resolvedDocument;
|
||||
const hosted: HostedSurface = { instance, frame, ready: false, cancelSent: false };
|
||||
this.surfaces.set(instance.instance_id, hosted);
|
||||
this.mountPoint.append(frame);
|
||||
@@ -95,6 +104,25 @@ export class IsolatedSurfaceHost {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an exact, verified bundle into a host-CSP-wrapped opaque document.
|
||||
* The bundle itself is untrusted code: it gets no origin, network, navigation,
|
||||
* forms or privileged bridge. A bundle-provided CSP can only further restrict
|
||||
* this host policy, never relax it.
|
||||
*/
|
||||
export class CachedVerifiedSurfaceDocumentResolver implements VerifiedSurfaceDocumentResolver {
|
||||
public constructor(private readonly cache: VerifiedSurfaceBundleCache) {}
|
||||
|
||||
public documentFor(instance: SurfaceInstance): string | undefined {
|
||||
const entry = this.cache.bundle(instance.app_id, instance.version);
|
||||
if (!entry || !entry.bytes.byteLength) return undefined;
|
||||
try {
|
||||
const source = new TextDecoder("utf-8", { fatal: true }).decode(entry.bytes);
|
||||
return productionSurfaceDocument(instance.instance_id, source);
|
||||
} catch { return undefined; }
|
||||
}
|
||||
}
|
||||
|
||||
type SurfaceReadyMessage = Readonly<{ v: 1; type: typeof READY_TYPE; instance_id: string }>;
|
||||
type SurfaceEventMessage = Readonly<{ v: 1; type: "lineup.surface.v1.event"; instance_id: string; event: "cancel" }>;
|
||||
|
||||
@@ -117,6 +145,14 @@ export function surfaceDocument(instanceID: string): string {
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'"><style>html,body{margin:0;background:#15231c;color:#e7f0eb;font:14px system-ui,sans-serif}main{padding:12px;display:grid;gap:10px}h2,p{margin:0}progress{width:100%}ul{margin:0;padding-left:20px}#logs{max-height:64px;overflow:auto;color:#b8cfc0;font-size:12px}button{justify-self:start;border:0;border-radius:7px;padding:8px 10px;background:#a9dfc4;color:#102018;font-weight:700}</style></head><body><main><h2 id="title">任务面板</h2><progress id="progress" max="100"></progress><p id="status">等待状态</p><ul id="steps"></ul><div id="logs" aria-label="日志摘要"></div><button id="cancel" type="button">请求取消</button></main><script>"use strict";const instanceId=${safeID},q=id=>document.getElementById(id),list=(id,items)=>q(id).replaceChildren(...(Array.isArray(items)?items.slice(0,12).map(x=>{const n=document.createElement(id==="logs"?"p":"li");n.textContent=typeof x==="string"?x:"";return n}):[]));q("cancel").onclick=()=>window.parent.postMessage({v:1,type:"lineup.surface.v1.event",instance_id:instanceId,event:"cancel"},"*");window.addEventListener("message",event=>{if(event.source!==window.parent)return;const d=event.data;if(!d||d.v!==1||d.type!=="lineup.surface.v1.state"||d.instance_id!==instanceId||!d.state||typeof d.state!=="object"||Array.isArray(d.state))return;const s=d.state;q("title").textContent=typeof s.title==="string"?s.title:"任务面板";q("progress").value=typeof s.percent==="number"?Math.max(0,Math.min(100,s.percent)):0;q("status").textContent=typeof s.status==="string"?s.status:"进行中";list("steps",s.steps);list("logs",s.logs);});window.parent.postMessage({v:1,type:"lineup.surface.v1.ready",instance_id:instanceId},"*");</script></body></html>`;
|
||||
}
|
||||
|
||||
export function productionSurfaceDocument(instanceID: string, verifiedBundle: string): string {
|
||||
const safeID = JSON.stringify(instanceID);
|
||||
const csp = "default-src 'none'; connect-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; navigate-to 'none'";
|
||||
// The content is an already signature/hash-verified artifact, never an
|
||||
// Envelope/source patch. Its own CSP is additive; this one remains in force.
|
||||
return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="${csp}"><script>"use strict";window.__LINEUP_SURFACE_INSTANCE__=${safeID};</script>${verifiedBundle}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalManifestPayload, compareSemver, parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry } from "./production-surface-manifest";
|
||||
|
||||
const manifest = {
|
||||
v: 1, app_id: "lineup.task-dashboard", version: "1.2.0",
|
||||
artifact: { artifact_id: "task-dashboard-1.2.0", sha256: "a".repeat(64), size_bytes: 4096 },
|
||||
min_host_version: "0.2.0", permissions: ["surface.event.cancel"], key_id: "lineup-release-2026", signature: "A".repeat(86),
|
||||
};
|
||||
|
||||
describe("ProductionSurfaceManifest", () => {
|
||||
it("admits only an immutable, signed-shape allowlisted manifest", () => {
|
||||
const registry = new ProductionSurfaceManifestRegistry({ host_version: "0.3.0", trusted_key_ids: ["lineup-release-2026"] });
|
||||
expect(registry.admit(manifest)).toMatchObject({ disposition: "accepted", manifest: { app_id: "lineup.task-dashboard" } });
|
||||
expect(registry.admit(manifest)).toMatchObject({ disposition: "duplicate" });
|
||||
expect(registry.admit({ ...manifest, artifact: { ...manifest.artifact, sha256: "b".repeat(64) } })).toEqual({ disposition: "invalid_manifest" });
|
||||
const copy = registry.get("lineup.task-dashboard", "1.2.0")!;
|
||||
(copy.artifact as { sha256: string }).sha256 = "c".repeat(64);
|
||||
expect(registry.get("lineup.task-dashboard", "1.2.0")?.artifact.sha256).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
it("rejects unknown fields, untrusted keys, unsupported hosts and undeclared permissions", () => {
|
||||
expect(parseProductionSurfaceManifest({ ...manifest, url: "https://attacker.invalid/bundle" })).toBeUndefined();
|
||||
expect(parseProductionSurfaceManifest({ ...manifest, permissions: ["device.pick_file"] })).toBeUndefined();
|
||||
expect(new ProductionSurfaceManifestRegistry({ host_version: "0.3.0", trusted_key_ids: [] }).admit(manifest)).toEqual({ disposition: "untrusted_key" });
|
||||
expect(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: [manifest.key_id] }).admit(manifest)).toEqual({ disposition: "unsupported_host" });
|
||||
});
|
||||
|
||||
it("uses one stable signed payload and semver ordering across hosts", () => {
|
||||
expect(canonicalManifestPayload(parseProductionSurfaceManifest(manifest)!)).toBe('{"app_id":"lineup.task-dashboard","artifact":{"artifact_id":"task-dashboard-1.2.0","sha256":"' + "a".repeat(64) + '","size_bytes":4096},"key_id":"lineup-release-2026","min_host_version":"0.2.0","permissions":["surface.event.cancel"],"v":1,"version":"1.2.0"}');
|
||||
expect(compareSemver("1.0.0", "1.0.0")).toBe(0);
|
||||
expect(compareSemver("1.0.0", "1.0.0-beta.1")).toBe(1);
|
||||
expect(compareSemver("1.0.0-beta.1", "1.0.0")).toBe(-1);
|
||||
expect(compareSemver("1.2.0", "1.10.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* M4 production Surface declaration. A manifest is only immutable metadata;
|
||||
* it contains neither a bundle URL nor executable source. Download, signature
|
||||
* verification and cache installation are deliberately separate M4-02 steps.
|
||||
*/
|
||||
export type SurfacePermission = "surface.event.cancel";
|
||||
|
||||
export type ProductionSurfaceManifest = Readonly<{
|
||||
v: 1;
|
||||
app_id: string;
|
||||
version: string;
|
||||
artifact: Readonly<{
|
||||
artifact_id: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
}>;
|
||||
min_host_version: string;
|
||||
permissions: readonly SurfacePermission[];
|
||||
key_id: string;
|
||||
/** Base64url Ed25519 signature of canonicalManifestPayload(manifest). */
|
||||
signature: string;
|
||||
}>;
|
||||
|
||||
export type ProductionManifestDisposition =
|
||||
| "accepted"
|
||||
| "invalid_manifest"
|
||||
| "unsupported_host"
|
||||
| "untrusted_key"
|
||||
| "duplicate";
|
||||
|
||||
export type ProductionManifestResult = Readonly<{
|
||||
disposition: ProductionManifestDisposition;
|
||||
manifest?: ProductionSurfaceManifest;
|
||||
}>;
|
||||
|
||||
const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
||||
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const ARTIFACT_ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const KEY_ID = /^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const BASE64URL_SIGNATURE = /^[A-Za-z0-9_-]{86}$/;
|
||||
const PERMISSIONS = new Set<SurfacePermission>(["surface.event.cancel"]);
|
||||
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Host-owned allowlist for production manifests. Remote messages cannot add a
|
||||
* key, weaken the minimum host version, mutate a previously accepted artifact
|
||||
* or cause a bundle to run; M4-02 must verify the signature and digest before
|
||||
* a cached bundle is made active.
|
||||
*/
|
||||
export class ProductionSurfaceManifestRegistry {
|
||||
private readonly manifests = new Map<string, ProductionSurfaceManifest>();
|
||||
private readonly trustedKeys: ReadonlySet<string>;
|
||||
|
||||
public constructor(options: Readonly<{ host_version: string; trusted_key_ids: readonly string[] }>) {
|
||||
if (!validVersion(options.host_version)) throw new Error("host_version must be semver.");
|
||||
this.hostVersion = options.host_version;
|
||||
this.trustedKeys = new Set(options.trusted_key_ids.filter(validKeyID));
|
||||
}
|
||||
|
||||
private readonly hostVersion: string;
|
||||
|
||||
public admit(value: unknown): ProductionManifestResult {
|
||||
const parsed = parseProductionSurfaceManifest(value);
|
||||
if (!parsed) return { disposition: "invalid_manifest" };
|
||||
if (!this.trustedKeys.has(parsed.key_id)) return { disposition: "untrusted_key" };
|
||||
if (compareSemver(this.hostVersion, parsed.min_host_version) < 0) return { disposition: "unsupported_host" };
|
||||
const key = manifestKey(parsed.app_id, parsed.version);
|
||||
const existing = this.manifests.get(key);
|
||||
if (existing) return sameManifest(existing, parsed)
|
||||
? { disposition: "duplicate", manifest: copyManifest(existing) }
|
||||
: { disposition: "invalid_manifest" };
|
||||
const immutable = freezeManifest(parsed);
|
||||
this.manifests.set(key, immutable);
|
||||
return { disposition: "accepted", manifest: copyManifest(immutable) };
|
||||
}
|
||||
|
||||
public get(appID: string, version: string): ProductionSurfaceManifest | undefined {
|
||||
const manifest = this.manifests.get(manifestKey(appID, version));
|
||||
return manifest && copyManifest(manifest);
|
||||
}
|
||||
|
||||
public snapshot(): readonly ProductionSurfaceManifest[] {
|
||||
return [...this.manifests.values()].sort((left, right) => manifestKey(left.app_id, left.version).localeCompare(manifestKey(right.app_id, right.version))).map(copyManifest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strict parse used before signature verification and any cache/network work. */
|
||||
export function parseProductionSurfaceManifest(value: unknown): ProductionSurfaceManifest | undefined {
|
||||
if (!plainObject(value)) return undefined;
|
||||
const keys = ["v", "app_id", "version", "artifact", "min_host_version", "permissions", "key_id", "signature"];
|
||||
if (Object.keys(value).length !== keys.length || Object.keys(value).some(key => !keys.includes(key))) return undefined;
|
||||
if (value.v !== 1 || !validAppID(value.app_id) || !validVersion(value.version) || !validVersion(value.min_host_version)
|
||||
|| !validKeyID(value.key_id) || typeof value.signature !== "string" || !BASE64URL_SIGNATURE.test(value.signature)
|
||||
|| !Array.isArray(value.permissions) || value.permissions.length < 1 || value.permissions.length > PERMISSIONS.size
|
||||
|| new Set(value.permissions).size !== value.permissions.length || value.permissions.some(permission => typeof permission !== "string" || !PERMISSIONS.has(permission as SurfacePermission))
|
||||
|| !plainObject(value.artifact) || Object.keys(value.artifact).length !== 3 || Object.keys(value.artifact).some(key => !["artifact_id", "sha256", "size_bytes"].includes(key))) return undefined;
|
||||
const artifact = value.artifact as Record<string, unknown>;
|
||||
if (typeof artifact.artifact_id !== "string" || !ARTIFACT_ID.test(artifact.artifact_id)
|
||||
|| typeof artifact.sha256 !== "string" || !SHA256.test(artifact.sha256)
|
||||
|| typeof artifact.size_bytes !== "number" || !Number.isSafeInteger(artifact.size_bytes) || artifact.size_bytes < 1 || artifact.size_bytes > MAX_BUNDLE_BYTES) return undefined;
|
||||
return {
|
||||
v: 1, app_id: value.app_id, version: value.version,
|
||||
artifact: { artifact_id: artifact.artifact_id, sha256: artifact.sha256, size_bytes: artifact.size_bytes },
|
||||
min_host_version: value.min_host_version,
|
||||
permissions: [...value.permissions] as SurfacePermission[],
|
||||
key_id: value.key_id, signature: value.signature,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable UTF-8 payload used by every Host before Ed25519 verification. */
|
||||
export function canonicalManifestPayload(manifest: ProductionSurfaceManifest): string {
|
||||
return JSON.stringify({
|
||||
app_id: manifest.app_id,
|
||||
artifact: { artifact_id: manifest.artifact.artifact_id, sha256: manifest.artifact.sha256, size_bytes: manifest.artifact.size_bytes },
|
||||
key_id: manifest.key_id,
|
||||
min_host_version: manifest.min_host_version,
|
||||
permissions: [...manifest.permissions],
|
||||
v: manifest.v,
|
||||
version: manifest.version,
|
||||
});
|
||||
}
|
||||
|
||||
export function compareSemver(left: string, right: string): number {
|
||||
const leftCore = left.split(/[+-]/, 1)[0].split(".").map(Number);
|
||||
const rightCore = right.split(/[+-]/, 1)[0].split(".").map(Number);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftCore[index] !== rightCore[index]) return leftCore[index] > rightCore[index] ? 1 : -1;
|
||||
}
|
||||
const leftPre = left.includes("-") ? left.slice(left.indexOf("-") + 1).split("+", 1)[0] : undefined;
|
||||
const rightPre = right.includes("-") ? right.slice(right.indexOf("-") + 1).split("+", 1)[0] : undefined;
|
||||
if (!leftPre && !rightPre) return 0;
|
||||
if (!leftPre) return 1;
|
||||
if (!rightPre) return -1;
|
||||
return leftPre === rightPre ? 0 : leftPre.localeCompare(rightPre);
|
||||
}
|
||||
|
||||
function validAppID(value: unknown): value is string { return typeof value === "string" && value.length <= 96 && APP_ID.test(value); }
|
||||
function validVersion(value: unknown): value is string { return typeof value === "string" && value.length <= 64 && VERSION.test(value); }
|
||||
function validKeyID(value: unknown): value is string { return typeof value === "string" && KEY_ID.test(value); }
|
||||
function manifestKey(appID: string, version: string): string { return `${appID}@${version}`; }
|
||||
function plainObject(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); }
|
||||
function copyManifest(manifest: ProductionSurfaceManifest): ProductionSurfaceManifest { return { ...manifest, artifact: { ...manifest.artifact }, permissions: [...manifest.permissions] }; }
|
||||
function freezeManifest(manifest: ProductionSurfaceManifest): ProductionSurfaceManifest { return Object.freeze({ ...manifest, artifact: Object.freeze({ ...manifest.artifact }), permissions: Object.freeze([...manifest.permissions]) }); }
|
||||
function sameManifest(left: ProductionSurfaceManifest, right: ProductionSurfaceManifest): boolean { return canonicalManifestPayload(left) === canonicalManifestPayload(right) && left.signature === right.signature; }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProductionSurfaceManifestRegistry } from "./production-surface-manifest";
|
||||
import { ProductionSurfacePolicy } from "./production-surface-policy";
|
||||
import { SurfaceInstanceManager } from "./surface-instance-manager";
|
||||
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache";
|
||||
|
||||
const bytes = new TextEncoder().encode("<main>verified</main>");
|
||||
const digest: SurfaceBundleDigest = { async sha256() { return "a".repeat(64); } };
|
||||
const signatures: SurfaceManifestSignatureVerifier = { async verify() { return true; } };
|
||||
const candidate = {
|
||||
v: 1, app_id: "lineup.production-dashboard", version: "1.0.0",
|
||||
artifact: { artifact_id: "dashboard-1", sha256: "a".repeat(64), size_bytes: bytes.byteLength },
|
||||
min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86),
|
||||
};
|
||||
|
||||
describe("ProductionSurfacePolicy", () => {
|
||||
it("admits an instance only after an exact signed/verified bundle exists", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
const policy = new ProductionSurfacePolicy(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: ["release-key"] }), cache);
|
||||
const manager = new SurfaceInstanceManager(policy);
|
||||
const request = { instance_id: "prod:001", app: { app_id: candidate.app_id, version: candidate.version }, state: { title: "安全运行" } };
|
||||
expect(manager.open(request, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "unknown_app" });
|
||||
const downloader = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/releases", async () => ({ ok: true, url: "https://bundles.lineup.example/releases/artifacts/dashboard-1.bundle", async bytes() { return bytes; } }));
|
||||
await expect(policy.downloadAndEnable(candidate, downloader)).resolves.toEqual({ disposition: "installed" });
|
||||
expect(manager.open(request, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "accepted", instance: { app_id: candidate.app_id, version: candidate.version } });
|
||||
expect(manager.open({ ...request, instance_id: "prod:injected", html: "<script>evil()</script>" }, "conversation", "2026-08-04T00:00:00.000Z")).toMatchObject({ disposition: "bundle_override_forbidden" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { JsonObject } from "../protocol";
|
||||
import { parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry, type ProductionManifestDisposition } from "./production-surface-manifest";
|
||||
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache } from "./surface-bundle-cache";
|
||||
import type { SurfaceAdmissionPolicy, SurfaceRegistryDisposition, SurfaceRegistryResult } from "./surface-registry";
|
||||
|
||||
export type ProductionSurfacePreparation = Readonly<{
|
||||
disposition: ProductionManifestDisposition | "installed" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch" | "invalid_manifest";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Host-owned production admission bridge. An app/version becomes openable
|
||||
* only after its manifest is locally allowed *and* exact verified bytes exist
|
||||
* in cache. It intentionally exposes no bundle bytes or remote URLs.
|
||||
*/
|
||||
export class ProductionSurfacePolicy implements SurfaceAdmissionPolicy {
|
||||
private readonly enabled = new Set<string>();
|
||||
|
||||
public constructor(
|
||||
private readonly manifests: ProductionSurfaceManifestRegistry,
|
||||
private readonly cache: VerifiedSurfaceBundleCache,
|
||||
private readonly maxStateBytes = 64 * 1024,
|
||||
) {}
|
||||
|
||||
public async downloadAndEnable(candidate: unknown, downloader: TrustedSurfaceBundleDownloader): Promise<ProductionSurfacePreparation> {
|
||||
const admitted = this.manifests.admit(candidate);
|
||||
if (admitted.disposition !== "accepted" && admitted.disposition !== "duplicate") return { disposition: admitted.disposition };
|
||||
const parsed = parseProductionSurfaceManifest(candidate);
|
||||
if (!parsed) return { disposition: "invalid_manifest" };
|
||||
const installed = await this.cache.downloadAndInstall(parsed, downloader);
|
||||
if ((installed.disposition === "installed" || installed.disposition === "duplicate") && this.cache.bundle(parsed.app_id, parsed.version)) {
|
||||
this.enabled.add(key(parsed.app_id, parsed.version));
|
||||
return { disposition: "installed" };
|
||||
}
|
||||
return { disposition: installed.disposition };
|
||||
}
|
||||
|
||||
public validateOpenRequest(payload: unknown): SurfaceRegistryResult {
|
||||
if (!plainObject(payload) || hasSource(payload) || !plainObject(payload.app) || typeof payload.instance_id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(payload.instance_id)
|
||||
|| typeof payload.app.app_id !== "string" || typeof payload.app.version !== "string" || (payload.state !== undefined && !plainObject(payload.state))) return { disposition: "invalid_state" };
|
||||
return this.validateState(payload.app.app_id, payload.app.version, payload.state ?? {});
|
||||
}
|
||||
|
||||
public validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult {
|
||||
const manifest = this.manifests.get(appID, version);
|
||||
if (!manifest) return { disposition: "unknown_app" };
|
||||
if (!this.enabled.has(key(appID, version)) || !this.cache.bundle(appID, version)) return { disposition: "disabled" };
|
||||
if (!plainObject(state)) return { disposition: "invalid_state" };
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(state as JsonObject)).byteLength <= this.maxStateBytes
|
||||
? { disposition: "accepted" }
|
||||
: { disposition: "state_too_large" };
|
||||
} catch { return { disposition: "invalid_state" }; }
|
||||
}
|
||||
}
|
||||
|
||||
function key(appID: string, version: string): string { return `${appID}@${version}`; }
|
||||
function plainObject(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); }
|
||||
function hasSource(value: Record<string, unknown>): boolean { return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in value); }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache";
|
||||
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const digest: SurfaceBundleDigest = { async sha256(value) { return value.join("").padEnd(64, "0"); } };
|
||||
const signatures: SurfaceManifestSignatureVerifier = { async verify(_payload, signature, keyID) { return signature === "A".repeat(86) && keyID === "release-key"; } };
|
||||
function manifest(version: string, artifactID = `task-${version}`) {
|
||||
return { v: 1, app_id: "lineup.task-dashboard", version, artifact: { artifact_id: artifactID, sha256: "1234".padEnd(64, "0"), size_bytes: 4 }, min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "release-key", signature: "A".repeat(86) };
|
||||
}
|
||||
|
||||
describe("VerifiedSurfaceBundleCache", () => {
|
||||
it("performs real Ed25519 public-key verification without a client signing key", async () => {
|
||||
const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
|
||||
const payload = new TextEncoder().encode("canonical-manifest-payload");
|
||||
const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, pair.privateKey, payload));
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
||||
const encode = (value: Uint8Array) => btoa(String.fromCharCode(...value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
const verifier = new WebCryptoEd25519SurfaceManifestSignatureVerifier({ "release-key": encode(publicKey) });
|
||||
await expect(verifier.verify(payload, encode(signature), "release-key")).resolves.toBe(true);
|
||||
await expect(verifier.verify(new TextEncoder().encode("tampered"), encode(signature), "release-key")).resolves.toBe(false);
|
||||
await expect(verifier.verify(payload, encode(signature), "unknown-key")).resolves.toBe(false);
|
||||
await expect(verifier.verify(payload, "not-a-signature", "release-key")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("verifies signature, exact size and digest before atomically activating bytes", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await expect(cache.install({ ...manifest("1.0.0"), signature: "B".repeat(86) }, bytes)).resolves.toEqual({ disposition: "signature_invalid" });
|
||||
await expect(cache.install(manifest("1.0.0"), new Uint8Array([1]))).resolves.toEqual({ disposition: "size_mismatch" });
|
||||
await expect(cache.install({ ...manifest("1.0.0"), artifact: { ...manifest("1.0.0").artifact, sha256: "f".repeat(64) } }, bytes)).resolves.toEqual({ disposition: "digest_mismatch" });
|
||||
expect(cache.activeBundle("lineup.task-dashboard")).toBeUndefined();
|
||||
await expect(cache.install(manifest("1.0.0"), bytes)).resolves.toMatchObject({ disposition: "installed", entry: { manifest: { version: "1.0.0" } } });
|
||||
expect(cache.activeBundle("lineup.task-dashboard")?.bytes).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("keeps a verified predecessor and rolls back without re-downloading bytes", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await cache.install(manifest("1.0.0"), bytes);
|
||||
await cache.install(manifest("1.1.0"), bytes);
|
||||
expect(cache.activeBundle("lineup.task-dashboard")?.manifest.version).toBe("1.1.0");
|
||||
expect(cache.rollback("lineup.task-dashboard")).toMatchObject({ disposition: "rolled_back", entry: { manifest: { version: "1.0.0" } } });
|
||||
expect(cache.activeBundle("lineup.task-dashboard")?.manifest.version).toBe("1.0.0");
|
||||
expect(cache.invalidate("lineup.task-dashboard", "task-1.0.0")).toMatchObject({ disposition: "rolled_back", entry: { manifest: { version: "1.1.0" } } });
|
||||
});
|
||||
|
||||
it("does not expose bytes through its public cache snapshot", async () => {
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await cache.install(manifest("1.0.0"), bytes);
|
||||
expect(cache.snapshot()).toEqual([{ app_id: "lineup.task-dashboard", version: "1.0.0", artifact_id: "task-1.0.0", active: true }]);
|
||||
expect(JSON.stringify(cache.snapshot())).not.toContain("1234");
|
||||
});
|
||||
|
||||
it("downloads only from a configured HTTPS source before installing", async () => {
|
||||
const fetched: string[] = [];
|
||||
const downloader = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/release", async url => {
|
||||
fetched.push(url.toString());
|
||||
return { ok: true, url: "https://bundles.lineup.example/release/artifacts/task-1.0.0.bundle", async bytes() { return bytes; } };
|
||||
});
|
||||
const cache = new VerifiedSurfaceBundleCache(signatures, digest);
|
||||
await expect(cache.downloadAndInstall(manifest("1.0.0"), downloader)).resolves.toMatchObject({ disposition: "installed" });
|
||||
expect(fetched).toEqual(["https://bundles.lineup.example/release/artifacts/task-1.0.0.bundle"]);
|
||||
const redirected = new TrustedSurfaceBundleDownloader("https://bundles.lineup.example/release", async () => ({ ok: true, url: "https://attacker.invalid/bundle", async bytes() { return bytes; } }));
|
||||
await expect(new VerifiedSurfaceBundleCache(signatures, digest).downloadAndInstall(manifest("1.0.0"), redirected)).resolves.toEqual({ disposition: "download_failed" });
|
||||
expect(() => new TrustedSurfaceBundleDownloader("http://bundles.lineup.example", async () => ({ ok: false, url: "", async bytes() { return bytes; } }))).toThrow("HTTPS");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { canonicalManifestPayload, parseProductionSurfaceManifest, type ProductionSurfaceManifest } from "./production-surface-manifest";
|
||||
|
||||
export type BundleVerificationDisposition = "installed" | "duplicate" | "invalid_manifest" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch";
|
||||
export type BundleInstallResult = Readonly<{ disposition: BundleVerificationDisposition; entry?: InstalledSurfaceBundle }>;
|
||||
export type BundleRollbackResult = Readonly<{ disposition: "rolled_back" | "unavailable"; entry?: InstalledSurfaceBundle }>;
|
||||
|
||||
export type InstalledSurfaceBundle = Readonly<{
|
||||
manifest: ProductionSurfaceManifest;
|
||||
bytes: Uint8Array;
|
||||
}>;
|
||||
|
||||
/** Implemented by every Host with its platform trust store. */
|
||||
export interface SurfaceManifestSignatureVerifier {
|
||||
verify(payload: Uint8Array, signature: string, keyID: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Ed25519 verifier. A Host receives only public keys, keyed by
|
||||
* the immutable manifest `key_id`; a missing/invalid key is indistinguishable
|
||||
* from a bad signature. No signing key and no remote key enrollment exists
|
||||
* in the client.
|
||||
*/
|
||||
export class WebCryptoEd25519SurfaceManifestSignatureVerifier implements SurfaceManifestSignatureVerifier {
|
||||
private readonly publicKeys: ReadonlyMap<string, Uint8Array>;
|
||||
|
||||
public constructor(trustedPublicKeys: Readonly<Record<string, string>>) {
|
||||
const keys = new Map<string, Uint8Array>();
|
||||
for (const [keyID, encoded] of Object.entries(trustedPublicKeys)) {
|
||||
const bytes = base64urlBytes(encoded);
|
||||
// Raw Ed25519 public keys are exactly 32 bytes.
|
||||
if (bytes?.byteLength === 32) keys.set(keyID, bytes);
|
||||
}
|
||||
this.publicKeys = keys;
|
||||
}
|
||||
|
||||
public async verify(payload: Uint8Array, signature: string, keyID: string): Promise<boolean> {
|
||||
const publicKey = this.publicKeys.get(keyID);
|
||||
const signatureBytes = base64urlBytes(signature);
|
||||
if (!publicKey || !signatureBytes || signatureBytes.byteLength !== 64) return false;
|
||||
try {
|
||||
const key = await crypto.subtle.importKey("raw", Uint8Array.from(publicKey).buffer, { name: "Ed25519" }, false, ["verify"]);
|
||||
return await crypto.subtle.verify({ name: "Ed25519" }, key, Uint8Array.from(signatureBytes).buffer, Uint8Array.from(payload).buffer);
|
||||
} catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/** SHA-256 implementation boundary; no caller may supply a precomputed digest. */
|
||||
export interface SurfaceBundleDigest {
|
||||
sha256(bytes: Uint8Array): Promise<string>;
|
||||
}
|
||||
|
||||
export type TrustedBundleDownloadResponse = Readonly<{
|
||||
ok: boolean;
|
||||
/** Final URL after redirects, used to reject an origin switch. */
|
||||
url: string;
|
||||
bytes(): Promise<Uint8Array>;
|
||||
}>;
|
||||
|
||||
export type TrustedBundleFetcher = (url: URL) => Promise<TrustedBundleDownloadResponse>;
|
||||
|
||||
/**
|
||||
* Download URL construction is Host configuration, never a field in a remote
|
||||
* manifest or ui.open envelope. HTTPS and same-origin final responses prevent
|
||||
* an artifact id from becoming a general-purpose network primitive.
|
||||
*/
|
||||
export class TrustedSurfaceBundleDownloader {
|
||||
private readonly baseURL: URL;
|
||||
|
||||
public constructor(baseURL: string, private readonly fetcher: TrustedBundleFetcher) {
|
||||
const parsed = new URL(baseURL);
|
||||
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) throw new Error("Bundle source must be a clean HTTPS origin/base path.");
|
||||
this.baseURL = parsed.toString().endsWith("/") ? parsed : new URL(`${parsed.toString()}/`);
|
||||
}
|
||||
|
||||
public async download(manifest: ProductionSurfaceManifest): Promise<Uint8Array | undefined> {
|
||||
const destination = new URL(`artifacts/${encodeURIComponent(manifest.artifact.artifact_id)}.bundle`, this.baseURL);
|
||||
let response: TrustedBundleDownloadResponse;
|
||||
try { response = await this.fetcher(destination); } catch { return undefined; }
|
||||
if (!response.ok) return undefined;
|
||||
try {
|
||||
const finalURL = new URL(response.url);
|
||||
if (finalURL.protocol !== "https:" || finalURL.origin !== this.baseURL.origin) return undefined;
|
||||
return new Uint8Array(await response.bytes());
|
||||
} catch { return undefined; }
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser/Tauri WebCrypto implementation; Android/Wails use the same hex format. */
|
||||
export class WebCryptoSurfaceBundleDigest implements SurfaceBundleDigest {
|
||||
public async sha256(bytes: Uint8Array): Promise<string> {
|
||||
// Copy to an owned ArrayBuffer: TypeScript permits a Uint8Array backed by
|
||||
// SharedArrayBuffer, while WebCrypto's browser contract is ArrayBuffer.
|
||||
const owned = Uint8Array.from(bytes);
|
||||
const digest = await crypto.subtle.digest("SHA-256", owned.buffer);
|
||||
return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-owned verified cache. It accepts bytes only after manifest signature,
|
||||
* exact length and SHA-256 validation succeed. A failed candidate never
|
||||
* mutates the current active bundle, making rollback an atomic pointer swap.
|
||||
* Bundle bytes are intentionally inaccessible to conversation/protocol code.
|
||||
*/
|
||||
export class VerifiedSurfaceBundleCache {
|
||||
private readonly entries = new Map<string, InstalledSurfaceBundle>();
|
||||
private readonly active = new Map<string, string>();
|
||||
private readonly previous = new Map<string, string>();
|
||||
|
||||
public constructor(
|
||||
private readonly signatureVerifier: SurfaceManifestSignatureVerifier,
|
||||
private readonly digest: SurfaceBundleDigest = new WebCryptoSurfaceBundleDigest(),
|
||||
) {}
|
||||
|
||||
public async install(candidate: unknown, bytes: Uint8Array): Promise<BundleInstallResult> {
|
||||
const manifest = parseProductionSurfaceManifest(candidate);
|
||||
if (!manifest) return { disposition: "invalid_manifest" };
|
||||
const payload = new TextEncoder().encode(canonicalManifestPayload(manifest));
|
||||
if (!await this.signatureVerifier.verify(payload, manifest.signature, manifest.key_id)) return { disposition: "signature_invalid" };
|
||||
if (bytes.byteLength !== manifest.artifact.size_bytes) return { disposition: "size_mismatch" };
|
||||
if (await this.digest.sha256(bytes) !== manifest.artifact.sha256) return { disposition: "digest_mismatch" };
|
||||
|
||||
const app = appKey(manifest);
|
||||
const key = bundleKey(manifest);
|
||||
const existing = this.entries.get(key);
|
||||
if (existing) return { disposition: "duplicate", entry: copy(existing) };
|
||||
|
||||
// Every validation above completes before the first cache mutation.
|
||||
const entry = freezeEntry({ manifest, bytes: new Uint8Array(bytes) });
|
||||
const oldActive = this.active.get(app);
|
||||
this.entries.set(key, entry);
|
||||
this.active.set(app, key);
|
||||
if (oldActive && oldActive !== key) this.previous.set(app, oldActive);
|
||||
return { disposition: "installed", entry: copy(entry) };
|
||||
}
|
||||
|
||||
public async downloadAndInstall(candidate: unknown, downloader: TrustedSurfaceBundleDownloader): Promise<BundleInstallResult> {
|
||||
const manifest = parseProductionSurfaceManifest(candidate);
|
||||
if (!manifest) return { disposition: "invalid_manifest" };
|
||||
const bytes = await downloader.download(manifest);
|
||||
return bytes ? this.install(manifest, bytes) : { disposition: "download_failed" };
|
||||
}
|
||||
|
||||
public activeBundle(appID: string): InstalledSurfaceBundle | undefined {
|
||||
const key = this.active.get(appID);
|
||||
const entry = key ? this.entries.get(key) : undefined;
|
||||
return entry && copy(entry);
|
||||
}
|
||||
|
||||
/** Exact version lookup for a persisted Surface instance; never falls forward. */
|
||||
public bundle(appID: string, version: string): InstalledSurfaceBundle | undefined {
|
||||
const entry = [...this.entries.values()].find(candidate => candidate.manifest.app_id === appID && candidate.manifest.version === version);
|
||||
return entry && copy(entry);
|
||||
}
|
||||
|
||||
/** Reverts only to a previously verified bundle for the same app. */
|
||||
public rollback(appID: string): BundleRollbackResult {
|
||||
const prior = this.previous.get(appID);
|
||||
const current = this.active.get(appID);
|
||||
const entry = prior ? this.entries.get(prior) : undefined;
|
||||
if (!current || !prior || !entry) return { disposition: "unavailable" };
|
||||
this.active.set(appID, prior);
|
||||
this.previous.set(appID, current);
|
||||
return { disposition: "rolled_back", entry: copy(entry) };
|
||||
}
|
||||
|
||||
/** An invalidated active artifact falls back only to a verified predecessor. */
|
||||
public invalidate(appID: string, artifactID: string): BundleRollbackResult {
|
||||
const current = this.active.get(appID);
|
||||
const currentEntry = current ? this.entries.get(current) : undefined;
|
||||
if (!current || !currentEntry || currentEntry.manifest.artifact.artifact_id !== artifactID) return { disposition: "unavailable" };
|
||||
return this.rollback(appID);
|
||||
}
|
||||
|
||||
public snapshot(): readonly Readonly<{ app_id: string; version: string; artifact_id: string; active: boolean }>[] {
|
||||
return [...this.entries.entries()].map(([key, entry]) => ({
|
||||
app_id: entry.manifest.app_id, version: entry.manifest.version, artifact_id: entry.manifest.artifact.artifact_id,
|
||||
active: this.active.get(appKey(entry.manifest)) === key,
|
||||
})).sort((left, right) => `${left.app_id}@${left.version}`.localeCompare(`${right.app_id}@${right.version}`));
|
||||
}
|
||||
}
|
||||
|
||||
function appKey(manifest: ProductionSurfaceManifest): string { return manifest.app_id; }
|
||||
function bundleKey(manifest: ProductionSurfaceManifest): string { return `${manifest.app_id}@${manifest.version}#${manifest.artifact.sha256}`; }
|
||||
function copy(entry: InstalledSurfaceBundle): InstalledSurfaceBundle { return { manifest: { ...entry.manifest, artifact: { ...entry.manifest.artifact }, permissions: [...entry.manifest.permissions] }, bytes: new Uint8Array(entry.bytes) }; }
|
||||
function freezeEntry(entry: InstalledSurfaceBundle): InstalledSurfaceBundle { return Object.freeze({ manifest: Object.freeze({ ...entry.manifest, artifact: Object.freeze({ ...entry.manifest.artifact }), permissions: Object.freeze([...entry.manifest.permissions]) }), bytes: new Uint8Array(entry.bytes) }); }
|
||||
|
||||
function base64urlBytes(value: string): Uint8Array | undefined {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return undefined;
|
||||
try {
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
} catch { return undefined; }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JsonObject } from "../protocol";
|
||||
import {
|
||||
parseSurfaceOpenRequest,
|
||||
type SurfaceRegistry,
|
||||
type SurfaceAdmissionPolicy,
|
||||
type SurfaceRegistryDisposition,
|
||||
} from "./surface-registry";
|
||||
|
||||
@@ -45,7 +45,7 @@ export type SurfaceInstanceSnapshot = Readonly<{
|
||||
export class SurfaceInstanceManager {
|
||||
private readonly instances = new Map<string, MutableSurfaceInstance>();
|
||||
|
||||
public constructor(private readonly registry: SurfaceRegistry) {}
|
||||
public constructor(private readonly registry: SurfaceAdmissionPolicy) {}
|
||||
|
||||
/**
|
||||
* A repeated open is deliberately a no-op. The first accepted state wins;
|
||||
|
||||
@@ -48,6 +48,12 @@ export type SurfaceOpenRequest = Readonly<{
|
||||
state?: JsonObject;
|
||||
}>;
|
||||
|
||||
/** Shared contract for development and production Surface admission policies. */
|
||||
export interface SurfaceAdmissionPolicy {
|
||||
validateOpenRequest(payload: unknown): SurfaceRegistryResult;
|
||||
validateState(appID: string, version: string, state: unknown): SurfaceRegistryResult;
|
||||
}
|
||||
|
||||
const APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
||||
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const BUNDLE_ID = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/;
|
||||
|
||||
Reference in New Issue
Block a user