diff --git a/.github/workflows/publish-worker.yml b/.github/workflows/publish-worker.yml index 68844f11..1353cb81 100755 --- a/.github/workflows/publish-worker.yml +++ b/.github/workflows/publish-worker.yml @@ -329,6 +329,7 @@ jobs: const PRIVATE_PROGRAM_ROLE = "private-program-consumer"; const PRIVATE_PROGRAM_METADATA_PATH = "private-program/build-metadata.json"; + const PRIVATE_PROGRAM_ABI_PATH = "private-program/abi-v2.js"; const PRIVATE_PROGRAM_CONSUMER_PATH = "private-program/consumer.js"; const PRIVATE_PROGRAM_WASM_PATH = "private-program/labcolors_private_program.wasm"; @@ -357,6 +358,7 @@ jobs: "evidence/wcag22-srgb8-q55-proof-v1.json", "evidence/point-support-reference-surplus-q55-bps-proof-v1.json", PRIVATE_PROGRAM_CONSUMER_PATH, + PRIVATE_PROGRAM_ABI_PATH, PRIVATE_PROGRAM_WASM_PATH, PRIVATE_PROGRAM_METADATA_PATH, "pkg/labcolors.js", @@ -395,11 +397,11 @@ jobs: } if ( - EXPECTED_PACKAGE_FILES.length !== 28 || - new Set(EXPECTED_PACKAGE_FILES).size !== 28 || + EXPECTED_PACKAGE_FILES.length !== 29 || + new Set(EXPECTED_PACKAGE_FILES).size !== 29 || EXPECTED_PACKAGE_FILES.some((path) => path.includes("*")) || - EXPECTED_TAR_INVENTORY.length !== 30 || - new Set(EXPECTED_TAR_INVENTORY).size !== 30 + EXPECTED_TAR_INVENTORY.length !== 31 || + new Set(EXPECTED_TAR_INVENTORY).size !== 31 ) { fail("worker package inventory allowlist is not canonical"); } @@ -1372,6 +1374,18 @@ jobs: ) { fail("packed package files declaration differs from the admitted inventory"); } + const packedPrivateAbi = packedMember( + tarball, + tarMembers, + PRIVATE_PROGRAM_ABI_PATH, + "packed private Program ABI", + ); + const taggedPrivateAbi = readFileSync( + join(workspace, "packages/colors", PRIVATE_PROGRAM_ABI_PATH), + ); + if (!packedPrivateAbi.equals(taggedPrivateAbi)) { + fail("packed private Program ABI differs from the exact tag source"); + } if (JSON.stringify(packedPackage.exports) !== JSON.stringify(EXPECTED_PACKAGE_EXPORTS)) { fail("packed package exports differ from the admitted public surface"); } diff --git a/fixtures/private-program-browser/proof.mjs b/fixtures/private-program-browser/proof.mjs index 19d4ae03..729d72c4 100644 --- a/fixtures/private-program-browser/proof.mjs +++ b/fixtures/private-program-browser/proof.mjs @@ -1,18 +1,22 @@ -const OUTPUT_BINDING = "--lab-private-program-output"; +import { + CHANGED_UPDATE_HEX, + exactWireBytes, + EXPECTED_CONTENT_IDENTITY, + EXPECTED_OUTPUT, + EXPECTED_SINK_OUTPUT, + OUTPUT_BINDING, + receiptFingerprint, + REQUEST_HEX, + UPDATE_HEX, +} from "./vectors.mjs"; + const EXPECTED_REQUEST_LENGTH = 70; const EXPECTED_UPDATE_LENGTH = 40; -const EXPECTED_OUTPUT = 17; -const EXPECTED_SINK_OUTPUT = 501; const EXPECTED_PAINT_SOURCE = Object.freeze([64, 64, 64]); const EXPECTED_PAINT_OPACITY_BITS_HEX = "3fe0000000000000"; const EXPECTED_COMPUTED_CSS = "rgba(64, 64, 64, 0.5)"; +const WORKER_RESPONSE_TIMEOUT_MS = 10_000; -const REQUEST_HEX = "4c43465102004600404040000000000000e03f00000000000050409a9999999999c93f01606060f50100001f0000000100000000000000010100000080808000000000000000"; -const UPDATE_HEX = "4c434655020028001f00000002000000000000000101020000008080800000000000000000000000"; -const CHANGED_UPDATE_HEX = - "4c434655020028001f0000000300000000000000010103000000ffffff0000000000000000000000"; -const EXPECTED_CONTENT_IDENTITY = - "4daf4731d823ba228f4189d08c76183dd8a81e8b593ff709828b7329e075a914"; const EXPECTED_CHECKS = Object.freeze([ "installed-physical-private-program", @@ -22,6 +26,7 @@ const EXPECTED_CHECKS = Object.freeze([ "exact-certified-receipt", "explicit-observation-update", "changed-observation-invalidates-certified-result", + "independent-worker-client-parity", "dispose", "post-run-dispose-idempotence", ]); @@ -51,14 +56,6 @@ function assertStableLiterals() { ); } -function exactWireBytes(hex) { - const bytes = new Uint8Array(hex.length / 2); - for (let index = 0; index < bytes.length; index += 1) { - bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); - } - return bytes; -} - async function runProof() { assertStableLiterals(); const checks = []; @@ -100,6 +97,9 @@ async function runProof() { checks.push("pre-run-dispose-idempotence"); let disposeResult; + let initialFingerprint; + let updatedFingerprint; + let changedFingerprint; try { const receipt = await consumer.run(requestBytes); assert(receipt && typeof receipt === "object", "run returns a receipt object"); @@ -129,6 +129,7 @@ async function runProof() { "certified paint opacity bits", ); equal(receipt.contentIdentity, EXPECTED_CONTENT_IDENTITY, "certified content identity"); + initialFingerprint = receiptFingerprint(receipt); checks.push("exact-certified-receipt"); const updateBytes = exactWireBytes(UPDATE_HEX); @@ -138,6 +139,7 @@ async function runProof() { equal(updated.stream, 31, "updated state retains stream identity"); equal(updated.revision, 2n, "updated state advances revision"); equal(updated.contentIdentity, EXPECTED_CONTENT_IDENTITY, "update reuses compiled identity"); + updatedFingerprint = receiptFingerprint(updated); checks.push("explicit-observation-update"); const changedUpdateBytes = exactWireBytes(CHANGED_UPDATE_HEX); @@ -148,6 +150,7 @@ async function runProof() { equal(changed.revision, 3n, "failed state advances revision"); equal(changed.output, 0, "failed state has no certified output"); equal(changed.contentIdentity, "0".repeat(64), "failed state has no certified identity"); + changedFingerprint = receiptFingerprint(changed); probe.getBoundingClientRect(); equal( getComputedStyle(probe).backgroundColor, @@ -155,6 +158,35 @@ async function runProof() { "changed observation revokes the previous certified output", ); checks.push("changed-observation-invalidates-certified-result"); + + const workerResult = await new Promise((resolve, reject) => { + const worker = new Worker("./worker-client.mjs", { type: "module" }); + const finish = (action) => { + clearTimeout(timeout); + worker.terminate(); + action(); + }; + const timeout = setTimeout(() => { + finish(() => reject(new Error("worker client response timed out"))); + }, WORKER_RESPONSE_TIMEOUT_MS); + worker.addEventListener("message", (event) => { + finish(() => { + if (event.data?.ok === true) resolve(event.data.value); + else reject(new Error(event.data?.error ?? "worker client failed without detail")); + }); + }, { once: true }); + worker.addEventListener("error", (event) => { + finish(() => reject(event.error ?? new Error(event.message))); + }, { once: true }); + worker.addEventListener("messageerror", () => { + finish(() => reject(new Error("worker client returned an uncloneable message"))); + }, { once: true }); + worker.postMessage("run"); + }); + equal(workerResult.initial, initialFingerprint, "worker initial certified outcome matches"); + equal(workerResult.updated, updatedFingerprint, "worker updated certified outcome matches"); + equal(workerResult.changed, changedFingerprint, "worker failed outcome matches"); + checks.push("independent-worker-client-parity"); } finally { disposeResult = await consumer.dispose(); } diff --git a/fixtures/private-program-browser/vectors.mjs b/fixtures/private-program-browser/vectors.mjs new file mode 100644 index 00000000..125fbd62 --- /dev/null +++ b/fixtures/private-program-browser/vectors.mjs @@ -0,0 +1,29 @@ +export const OUTPUT_BINDING = "--lab-private-program-output"; +export const EXPECTED_OUTPUT = 17; +export const EXPECTED_SINK_OUTPUT = 501; +export const REQUEST_HEX = "4c43465102004600404040000000000000e03f00000000000050409a9999999999c93f01606060f50100001f0000000100000000000000010100000080808000000000000000"; +export const UPDATE_HEX = "4c434655020028001f00000002000000000000000101020000008080800000000000000000000000"; +export const CHANGED_UPDATE_HEX = "4c434655020028001f0000000300000000000000010103000000ffffff0000000000000000000000"; +export const EXPECTED_CONTENT_IDENTITY = "4daf4731d823ba228f4189d08c76183dd8a81e8b593ff709828b7329e075a914"; + +export function exactWireBytes(hex) { + if (!/^(?:[0-9a-f]{2})+$/u.test(hex)) throw new TypeError("wire vector must be lowercase hex"); + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +export function receiptFingerprint(receipt) { + return JSON.stringify({ + output: receipt.output, + sinkOutput: receipt.sinkOutput, + state: receipt.state, + stream: receipt.stream, + revision: receipt.revision.toString(), + paintSource: receipt.paintSource, + paintOpacityBits: receipt.paintOpacityBits.toString(16).padStart(16, "0"), + contentIdentity: receipt.contentIdentity, + }); +} diff --git a/fixtures/private-program-browser/worker-client.mjs b/fixtures/private-program-browser/worker-client.mjs new file mode 100644 index 00000000..72d75233 --- /dev/null +++ b/fixtures/private-program-browser/worker-client.mjs @@ -0,0 +1,171 @@ +import { + decodeCertifiedReceipt, + DISPOSE_TOKEN_BASE_V1, + EXPORTS_V1, + HOST_CONFIRM_DISPOSED_V1, + HOST_DISPOSE_CONFIRMED_V1, + HOST_INSTALL_SUCCESS_V1, + HOST_INSTALL_V1, + HOST_MODULE_V1, + OPERATION_CONFIRM_EXACT_V1, + OPERATION_REVOKE_ALL_V1, + OPERATION_SET_ALL_V1, + REQUEST_REVISION_OFFSET, + REQUEST_SINK_OUTPUT_OFFSET, + REQUEST_STREAM_OFFSET, + REQUEST_V2_LENGTH, + RESULT_V2_LENGTH, + UPDATE_REVISION_OFFSET, + UPDATE_STREAM_OFFSET, + UPDATE_V2_LENGTH, +} from "/installed/private-program/abi-v2.js"; +import { + CHANGED_UPDATE_HEX, + exactWireBytes, + receiptFingerprint, + REQUEST_HEX, + UPDATE_HEX, +} from "./vectors.mjs"; + +const WASM_URL = "/installed/private-program/labcolors_private_program.wasm"; + +function u32(value) { + return value >>> 0; +} + +function checkedView(memory, pointer, length) { + const offset = u32(pointer); + if (offset > memory.buffer.byteLength || length > memory.buffer.byteLength - offset) { + throw new RangeError("private Program worker memory range is invalid"); + } + return new Uint8Array(memory.buffer, offset, length); +} + +async function runLifecycle() { + let exports; + let installedOutput = 0; + let installedSinkOutput = 0; + let expectedSinkOutput = 0; + let sequence = 0n; + let generation = 0; + let disposeConfirmed = false; + + function install( + rawGeneration, + rawOperation, + _revisionLow, + _revisionHigh, + expectedLow, + expectedHigh, + desiredLow, + desiredHigh, + rawOutput, + rawSinkOutput, + ) { + generation = u32(rawGeneration); + const operation = u32(rawOperation); + const expected = BigInt(u32(expectedLow)) | (BigInt(u32(expectedHigh)) << 32n); + const desired = BigInt(u32(desiredLow)) | (BigInt(u32(desiredHigh)) << 32n); + const output = u32(rawOutput); + const sinkOutput = u32(rawSinkOutput); + if (operation === OPERATION_CONFIRM_EXACT_V1) { + return expected === sequence && desired === sequence && output === installedOutput && sinkOutput === installedSinkOutput + ? HOST_INSTALL_SUCCESS_V1 + : 0; + } + if (expected !== sequence || desired !== sequence + 1n) return 0; + if (operation === OPERATION_SET_ALL_V1) { + if (output === 0 || sinkOutput !== expectedSinkOutput) return 0; + installedOutput = output; + installedSinkOutput = sinkOutput; + } else if (operation === OPERATION_REVOKE_ALL_V1) { + if (output !== 0 || sinkOutput !== 0) return 0; + installedOutput = 0; + installedSinkOutput = 0; + } else { + return 0; + } + sequence = desired; + return HOST_INSTALL_SUCCESS_V1; + } + + function confirmDisposed(rawGeneration, rawToken) { + disposeConfirmed = u32(rawGeneration) === generation && u32(rawToken) === generation; + return disposeConfirmed ? HOST_DISPOSE_CONFIRMED_V1 : 0; + } + + const response = await fetch(WASM_URL); + if (response.ok !== true) throw new Error(`private Program WASM fetch failed: ${response.status}`); + const source = await response.arrayBuffer(); + const instance = await WebAssembly.instantiate(source, { + [HOST_MODULE_V1]: { + [HOST_INSTALL_V1]: install, + [HOST_CONFIRM_DISPOSED_V1]: confirmDisposed, + }, + }); + exports = instance.instance.exports; + const memory = exports.memory; + + function invoke(bytes, pointerName, length, operationName, minimumRevision) { + checkedView(memory, exports[pointerName](), length).set(bytes); + const status = u32(exports[operationName]()); + if (status !== 0) throw new Error(`${operationName} failed with status ${status}`); + return decodeCertifiedReceipt({ + bytes: new Uint8Array(checkedView(memory, exports[EXPORTS_V1.resultPointer](), RESULT_V2_LENGTH)), + expectedSinkOutput, + installedOutput, + installedSinkOutput, + expectedStream: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32( + operationName === EXPORTS_V1.run ? REQUEST_STREAM_OFFSET : UPDATE_STREAM_OFFSET, + true, + ), + minimumRevision, + }); + } + + const request = exactWireBytes(REQUEST_HEX); + expectedSinkOutput = new DataView(request.buffer).getUint32(REQUEST_SINK_OUTPUT_OFFSET, true); + const initial = invoke( + request, + EXPORTS_V1.requestPointer, + REQUEST_V2_LENGTH, + EXPORTS_V1.run, + new DataView(request.buffer).getBigUint64(REQUEST_REVISION_OFFSET, true), + ); + const update = exactWireBytes(UPDATE_HEX); + const updated = invoke( + update, + EXPORTS_V1.updatePointer, + UPDATE_V2_LENGTH, + EXPORTS_V1.update, + new DataView(update.buffer).getBigUint64(UPDATE_REVISION_OFFSET, true), + ); + const changedUpdate = exactWireBytes(CHANGED_UPDATE_HEX); + const changed = invoke( + changedUpdate, + EXPORTS_V1.updatePointer, + UPDATE_V2_LENGTH, + EXPORTS_V1.update, + new DataView(changedUpdate.buffer).getBigUint64(UPDATE_REVISION_OFFSET, true), + ); + + const token = u32(exports[EXPORTS_V1.beginDispose]()); + if (token !== generation + DISPOSE_TOKEN_BASE_V1) throw new Error("dispose token mismatch"); + if (u32(exports[EXPORTS_V1.commitDispose](token)) !== 0 || !disposeConfirmed) { + throw new Error("dispose confirmation mismatch"); + } + return Object.freeze({ + initial: receiptFingerprint(initial), + updated: receiptFingerprint(updated), + changed: receiptFingerprint(changed), + }); +} + +self.addEventListener("message", async (event) => { + if (event.data !== "run") return; + try { + self.postMessage({ ok: true, value: await runLifecycle() }); + } catch (error) { + self.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) }); + } +}); diff --git a/packages/colors/package.json b/packages/colors/package.json index 6410d4f8..4f90c87e 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -58,6 +58,7 @@ "evidence/wcag22-srgb8-q55-proof-v1.json", "evidence/point-support-reference-surplus-q55-bps-proof-v1.json", "private-program/consumer.js", + "private-program/abi-v2.js", "private-program/labcolors_private_program.wasm", "private-program/build-metadata.json", "pkg/labcolors.js", diff --git a/packages/colors/private-program/abi-v2.js b/packages/colors/private-program/abi-v2.js new file mode 100644 index 00000000..31d8bae6 --- /dev/null +++ b/packages/colors/private-program/abi-v2.js @@ -0,0 +1,136 @@ +export const REQUEST_V2_LENGTH = 70; +export const REQUEST_SINK_OUTPUT_OFFSET = 39; +export const REQUEST_STREAM_OFFSET = 43; +export const REQUEST_REVISION_OFFSET = 47; +export const UPDATE_V2_LENGTH = 40; +export const UPDATE_STREAM_OFFSET = 8; +export const UPDATE_REVISION_OFFSET = 12; +export const RESULT_V2_LENGTH = 72; +export const RESULT_V2_MAGIC = Object.freeze([0x4c, 0x43, 0x46, 0x52]); +export const ABI_V2 = 2; + +export const RESULT_STATE_OFFSET = 8; +export const RESULT_STREAM_OFFSET = 9; +export const RESULT_REVISION_OFFSET = 13; +export const RESULT_OUTPUT_OFFSET = 21; +export const RESULT_SINK_OUTPUT_OFFSET = 25; +export const RESULT_RGB_OFFSET = 29; +export const RESULT_OPACITY_OFFSET = 32; +export const RESULT_CONTENT_IDENTITY_OFFSET = 40; +export const IDENTITY_LENGTH = 32; + +export const HOST_MODULE_V1 = "labcolors_private_fixture_host_v1"; +export const HOST_INSTALL_V1 = "labcolors_private_fixture_host_install_v1"; +export const HOST_CONFIRM_DISPOSED_V1 = "labcolors_private_fixture_host_confirm_disposed_v1"; +export const HOST_INSTALL_SUCCESS_V1 = 0x4c43_0001; +export const HOST_DISPOSE_CONFIRMED_V1 = 0x4c43_0002; + +export const OPERATION_SET_ALL_V1 = 1; +export const OPERATION_REVOKE_ALL_V1 = 2; +export const OPERATION_CONFIRM_EXACT_V1 = 3; +export const STATE_WAITING_V2 = 1; +export const STATE_READY_V2 = 2; +export const STATE_STALE_V2 = 3; +export const STATE_FAILED_V2 = 4; +export const DISPOSE_BEGIN_BUSY_V1 = 0xffff_ffff; +export const DISPOSE_TOKEN_BASE_V1 = 0x1000_0000; +export const DISPOSE_TOKEN_ENCODED_END_V1 = 2 * DISPOSE_TOKEN_BASE_V1 - 1; + +export const EXPORTS_V1 = Object.freeze({ + requestPointer: "labcolors_private_fixture_request_v1_ptr", + requestLength: "labcolors_private_fixture_request_v1_len", + resultPointer: "labcolors_private_fixture_result_v1_ptr", + resultLength: "labcolors_private_fixture_result_v1_len", + run: "labcolors_private_fixture_run_v1", + updatePointer: "labcolors_private_fixture_update_v2_ptr", + updateLength: "labcolors_private_fixture_update_v2_len", + update: "labcolors_private_fixture_update_v2", + beginDispose: "labcolors_private_fixture_begin_dispose_v1", + abortDispose: "labcolors_private_fixture_abort_dispose_v1", + commitDispose: "labcolors_private_fixture_commit_dispose_v1", +}); + +function lowercaseHex(bytes) { + let value = ""; + for (const byte of bytes) value += byte.toString(16).padStart(2, "0"); + return value; +} + +function allZero(bytes) { + return bytes.every((byte) => byte === 0); +} + +export function decodeCertifiedReceipt({ + bytes, + expectedSinkOutput, + installedOutput, + installedSinkOutput, + expectedStream, + minimumRevision, + protocolError = (detail) => new TypeError(detail), +}) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength !== RESULT_V2_LENGTH) { + throw protocolError(`private Program result must be ${RESULT_V2_LENGTH} bytes`); + } + for (let index = 0; index < RESULT_V2_MAGIC.length; index += 1) { + if (bytes[index] !== RESULT_V2_MAGIC[index]) throw protocolError("result has invalid magic"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint16(4, true) !== ABI_V2) { + throw protocolError("result has an unsupported ABI version"); + } + if (view.getUint16(6, true) !== RESULT_V2_LENGTH) { + throw protocolError("result declares an invalid length"); + } + + const state = view.getUint8(RESULT_STATE_OFFSET); + if (![STATE_WAITING_V2, STATE_READY_V2, STATE_STALE_V2, STATE_FAILED_V2].includes(state)) { + throw protocolError("result has an invalid lifecycle state"); + } + const stream = view.getUint32(RESULT_STREAM_OFFSET, true); + const revision = view.getBigUint64(RESULT_REVISION_OFFSET, true); + if (stream !== expectedStream || revision < minimumRevision) { + throw protocolError("result observation provenance does not match the active attachment"); + } + const output = view.getUint32(RESULT_OUTPUT_OFFSET, true); + const sinkOutput = view.getUint32(RESULT_SINK_OUTPUT_OFFSET, true); + const paintSource = Object.freeze([ + bytes[RESULT_RGB_OFFSET], + bytes[RESULT_RGB_OFFSET + 1], + bytes[RESULT_RGB_OFFSET + 2], + ]); + const paintOpacityBits = view.getBigUint64(RESULT_OPACITY_OFFSET, true); + const identityBytes = bytes.subarray( + RESULT_CONTENT_IDENTITY_OFFSET, + RESULT_CONTENT_IDENTITY_OFFSET + IDENTITY_LENGTH, + ); + if (state === STATE_READY_V2) { + if ( + output === 0 || + sinkOutput !== expectedSinkOutput || + sinkOutput !== installedSinkOutput || + output !== installedOutput || + allZero(identityBytes) + ) { + throw protocolError("Ready result lacks an identity-matching certified output"); + } + } else if ( + output !== 0 || + sinkOutput !== 0 || + !allZero(paintSource) || + paintOpacityBits !== 0n || + !allZero(identityBytes) + ) { + throw protocolError("no-output result carries forbidden certified output data"); + } + return Object.freeze({ + output, + sinkOutput, + state, + stream, + revision, + paintSource, + paintOpacityBits, + contentIdentity: lowercaseHex(identityBytes), + }); +} diff --git a/packages/colors/private-program/consumer.js b/packages/colors/private-program/consumer.js index 28298e10..7597b87d 100644 --- a/packages/colors/private-program/consumer.js +++ b/packages/colors/private-program/consumer.js @@ -1,64 +1,38 @@ import { acquireOutputLease } from "../output-sink.js"; +import { + decodeCertifiedReceipt, + DISPOSE_BEGIN_BUSY_V1, + DISPOSE_TOKEN_BASE_V1, + DISPOSE_TOKEN_ENCODED_END_V1, + EXPORTS_V1, + HOST_CONFIRM_DISPOSED_V1, + HOST_DISPOSE_CONFIRMED_V1, + HOST_INSTALL_SUCCESS_V1, + HOST_INSTALL_V1, + HOST_MODULE_V1, + OPERATION_CONFIRM_EXACT_V1, + OPERATION_REVOKE_ALL_V1, + OPERATION_SET_ALL_V1, + REQUEST_REVISION_OFFSET, + REQUEST_SINK_OUTPUT_OFFSET, + REQUEST_STREAM_OFFSET, + REQUEST_V2_LENGTH, + RESULT_V2_LENGTH, + UPDATE_REVISION_OFFSET, + UPDATE_STREAM_OFFSET, + UPDATE_V2_LENGTH, +} from "./abi-v2.js"; const PRIVATE_PROGRAM_WASM_URL = new URL("./labcolors_private_program.wasm", import.meta.url); -const REQUEST_V2_LENGTH = 70; -const REQUEST_SINK_OUTPUT_OFFSET = 39; -const REQUEST_STREAM_OFFSET = 43; -const UPDATE_V2_LENGTH = 40; -const UPDATE_STREAM_OFFSET = 8; -const RESULT_V2_LENGTH = 72; -const RESULT_V1_MAGIC = Object.freeze([0x4c, 0x43, 0x46, 0x52]); -const ABI_V2 = 2; - -const RESULT_STATE_OFFSET = 8; -const RESULT_STREAM_OFFSET = 9; -const RESULT_REVISION_OFFSET = 13; -const RESULT_OUTPUT_OFFSET = 21; -const RESULT_SINK_OUTPUT_OFFSET = 25; -const RESULT_RGB_OFFSET = 29; -const RESULT_OPACITY_OFFSET = 32; -const RESULT_CONTENT_IDENTITY_OFFSET = 40; -const IDENTITY_LENGTH = 32; - -const HOST_MODULE_V1 = "labcolors_private_fixture_host_v1"; -const HOST_INSTALL_V1 = "labcolors_private_fixture_host_install_v1"; -const HOST_CONFIRM_DISPOSED_V1 = "labcolors_private_fixture_host_confirm_disposed_v1"; -const HOST_INSTALL_SUCCESS_V1 = 0x4c43_0001; -const HOST_DISPOSE_CONFIRMED_V1 = 0x4c43_0002; - -const OPERATION_SET_ALL_V1 = 1; -const OPERATION_REVOKE_ALL_V1 = 2; -const OPERATION_CONFIRM_EXACT_V1 = 3; -const STATE_WAITING_V2 = 1; -const STATE_READY_V2 = 2; -const STATE_STALE_V2 = 3; -const STATE_FAILED_V2 = 4; -const DISPOSE_BEGIN_BUSY_V1 = 0xffff_ffff; // Live dispose tokens live in [DISPOSE_TOKEN_BASE_V1, 2 * DISPOSE_TOKEN_BASE_V1 - 1], // disjoint from every Core status code (1..=15), the Vacant sentinel 0, and the // Busy sentinel, so a begin-dispose result can be classified without ambiguity. -const DISPOSE_TOKEN_BASE_V1 = 0x1000_0000; -const DISPOSE_TOKEN_ENCODED_END_V1 = 2 * DISPOSE_TOKEN_BASE_V1 - 1; const I32_MIN = -0x8000_0000; const I32_MAX = 0x7fff_ffff; const MAX_CANONICAL_RGBA_V1_LENGTH = "rgba(255,255,255,)".length + expandShortestDecimal(Number.MIN_VALUE).length; const CANONICAL_RGBA_V1 = /^rgba\(([0-9]{1,3}),([0-9]{1,3}),([0-9]{1,3}),(0|1|0\.[0-9]+)\)$/u; -const EXPORTS_V1 = Object.freeze({ - requestPointer: "labcolors_private_fixture_request_v1_ptr", - requestLength: "labcolors_private_fixture_request_v1_len", - resultPointer: "labcolors_private_fixture_result_v1_ptr", - resultLength: "labcolors_private_fixture_result_v1_len", - run: "labcolors_private_fixture_run_v1", - updatePointer: "labcolors_private_fixture_update_v2_ptr", - updateLength: "labcolors_private_fixture_update_v2_len", - update: "labcolors_private_fixture_update_v2", - beginDispose: "labcolors_private_fixture_begin_dispose_v1", - abortDispose: "labcolors_private_fixture_abort_dispose_v1", - commitDispose: "labcolors_private_fixture_commit_dispose_v1", -}); - const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); class PrivateProgramConsumerError extends Error { @@ -240,94 +214,6 @@ function readRequestSinkOutput(bytes) { ); } -function lowercaseHex(bytes) { - let value = ""; - for (const byte of bytes) value += byte.toString(16).padStart(2, "0"); - return value; -} - -function allZero(bytes) { - return bytes.every((byte) => byte === 0); -} - -function decodeReceipt( - bytes, - expectedSinkOutput, - installedOutput, - installedSinkOutput, - expectedStream, - minimumRevision, -) { - for (let index = 0; index < RESULT_V1_MAGIC.length; index++) { - if (bytes[index] !== RESULT_V1_MAGIC[index]) { - throw protocolError("result has invalid magic"); - } - } - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (view.getUint16(4, true) !== ABI_V2) { - throw protocolError("result has an unsupported ABI version"); - } - if (view.getUint16(6, true) !== RESULT_V2_LENGTH) { - throw protocolError("result declares an invalid length"); - } - - const state = view.getUint8(RESULT_STATE_OFFSET); - if ( - state !== STATE_WAITING_V2 && - state !== STATE_READY_V2 && - state !== STATE_STALE_V2 && - state !== STATE_FAILED_V2 - ) { - throw protocolError("result has an invalid lifecycle state"); - } - const stream = view.getUint32(RESULT_STREAM_OFFSET, true); - const revision = view.getBigUint64(RESULT_REVISION_OFFSET, true); - if (stream !== expectedStream || revision < minimumRevision) { - throw protocolError("result observation provenance does not match the active attachment"); - } - const output = view.getUint32(RESULT_OUTPUT_OFFSET, true); - const sinkOutput = view.getUint32(RESULT_SINK_OUTPUT_OFFSET, true); - const paintSource = Object.freeze([ - bytes[RESULT_RGB_OFFSET], - bytes[RESULT_RGB_OFFSET + 1], - bytes[RESULT_RGB_OFFSET + 2], - ]); - const paintOpacityBits = view.getBigUint64(RESULT_OPACITY_OFFSET, true); - const identityBytes = bytes.subarray( - RESULT_CONTENT_IDENTITY_OFFSET, - RESULT_CONTENT_IDENTITY_OFFSET + IDENTITY_LENGTH, - ); - if (state === STATE_READY_V2) { - if ( - output === 0 || - sinkOutput !== expectedSinkOutput || - sinkOutput !== installedSinkOutput || - output !== installedOutput || - allZero(identityBytes) - ) { - throw protocolError("Ready result lacks an identity-matching certified output"); - } - } else if ( - output !== 0 || - sinkOutput !== 0 || - !allZero(paintSource) || - paintOpacityBits !== 0n || - !allZero(identityBytes) - ) { - throw protocolError("no-output result carries forbidden certified output data"); - } - return Object.freeze({ - output, - sinkOutput, - state, - stream, - revision, - paintSource, - paintOpacityBits, - contentIdentity: lowercaseHex(identityBytes), - }); -} - function frozenPublication(outputBinding, css) { const publication = Object.create(null); Object.defineProperty(publication, outputBinding, { @@ -793,7 +679,10 @@ export async function createPrivateProgramConsumer(options) { REQUEST_STREAM_OFFSET, true, ); - observationRevision = new DataView(request.buffer, request.byteOffset, request.byteLength).getBigUint64(47, true); + observationRevision = new DataView(request.buffer, request.byteOffset, request.byteLength).getBigUint64( + REQUEST_REVISION_OFFSET, + true, + ); generation = null; installedOutput = null; installedSinkOutput = null; @@ -825,14 +714,15 @@ export async function createPrivateProgramConsumer(options) { } let receipt; try { - receipt = decodeReceipt( - copyResult(), - authoredSinkOutput, + receipt = decodeCertifiedReceipt({ + bytes: copyResult(), + expectedSinkOutput: authoredSinkOutput, installedOutput, installedSinkOutput, - observationStream, - observationRevision, - ); + expectedStream: observationStream, + minimumRevision: observationRevision, + protocolError, + }); } catch (cause) { return probeAndCleanupFailedRun(asError(cause, "result decoding threw a non-Error value")); } @@ -847,7 +737,10 @@ export async function createPrivateProgramConsumer(options) { UPDATE_STREAM_OFFSET, true, ); - const incomingRevision = new DataView(update.buffer, update.byteOffset, update.byteLength).getBigUint64(12, true); + const incomingRevision = new DataView(update.buffer, update.byteOffset, update.byteLength).getBigUint64( + UPDATE_REVISION_OFFSET, + true, + ); if (incomingStream !== observationStream) { throw protocolError("observation update stream does not match the active attachment"); } @@ -871,14 +764,15 @@ export async function createPrivateProgramConsumer(options) { } let receipt; try { - receipt = decodeReceipt( - copyResult(), - requestSinkOutput, + receipt = decodeCertifiedReceipt({ + bytes: copyResult(), + expectedSinkOutput: requestSinkOutput, installedOutput, installedSinkOutput, - observationStream, - incomingRevision, - ); + expectedStream: observationStream, + minimumRevision: incomingRevision, + protocolError, + }); } catch (cause) { return probeAndCleanupFailedRun( asError(cause, "update result decoding threw a non-Error value"), diff --git a/packages/colors/test/private-program-mutation-contract.test.mjs b/packages/colors/test/private-program-mutation-contract.test.mjs index 02976f5e..4f5062ea 100644 --- a/packages/colors/test/private-program-mutation-contract.test.mjs +++ b/packages/colors/test/private-program-mutation-contract.test.mjs @@ -73,7 +73,15 @@ const EXPECTED_MUTATIONS = Object.freeze([ "javascript", "packages/colors/private-program/consumer.js", "2bde4eb3906f4d293a313e3037afc5e40841cda76603175397c19061cb43ea1a", - "Error: private Program browser fixture: computed background is the exact expected CSS literal; expected \"rgba(64, 64, 64, 0.5)\", got \"rgba(0, 0, 0, 0)\"", + "Error: private Program browser fixture: computed background is the exact expected CSS literal; expected \"rgba(64, 64, 64, 0.5)\", got \"rgba(0, 0, 0, 0)\"", + ], + [ + "second-client-result-suppression", + "semantic", + "fixture-javascript", + "fixtures/private-program-browser/worker-client.mjs", + "dcd81c18eb206457f4d48b67a2d75cd53222a20f4806989412b5584f74b49317", + "Error: second client result suppressed", ], ]); @@ -96,7 +104,7 @@ async function assertTreeContainsNoLinks(root) { } } -test("private Program mutation IDs bind seven exact source transformations", () => { +test("private Program mutation IDs bind eight exact source transformations", () => { assert.deepEqual( PRIVATE_PROGRAM_MUTATION_CASES.map( ({ id, proof, artifact, sourcePath, search, replacement, expectedBrowserAssertion }) => [ @@ -377,7 +385,7 @@ test("a mutation kill requires its real-browser assertion and semantic marker", () => assertMutationSpecificBrowserFailure(definition, { ...killed, - stdout: "LAB_COLORS_PRIVATE_PROGRAM_BROWSER_PASS v1 checks=9", + stdout: "LAB_COLORS_PRIVATE_PROGRAM_BROWSER_PASS v1 checks=10", }), /emitted the browser PASS receipt/u, ); diff --git a/packages/colors/test/private-program-publish-contract.test.mjs b/packages/colors/test/private-program-publish-contract.test.mjs index b4edd1c7..7b30092c 100644 --- a/packages/colors/test/private-program-publish-contract.test.mjs +++ b/packages/colors/test/private-program-publish-contract.test.mjs @@ -123,8 +123,8 @@ function assertPrivateProgramPublishContract(workflow) { `"${TAR_INSPECTOR_SHA256}";`, "publish must pin the exact canonical tar inspector bytes", ], - ["EXPECTED_PACKAGE_FILES.length !== 28", "publish must pin all declared package files"], - ["EXPECTED_TAR_INVENTORY.length !== 30", "publish must pin the exact npm tar inventory"], + ["EXPECTED_PACKAGE_FILES.length !== 29", "publish must pin all declared package files"], + ["EXPECTED_TAR_INVENTORY.length !== 31", "publish must pin the exact npm tar inventory"], [ "if (sha256(inspectorBytes) !== TAR_INSPECTOR_SHA256)", "publish must verify the tar inspector before invoking it", diff --git a/packages/colors/test/private-program-second-client-contract.test.mjs b/packages/colors/test/private-program-second-client-contract.test.mjs new file mode 100644 index 00000000..9c6565f9 --- /dev/null +++ b/packages/colors/test/private-program-second-client-contract.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("private Program second client shares the ABI and vectors without importing the first client", async () => { + const [consumer, workerClient, proof, packageManifest] = await Promise.all([ + readFile(new URL("../private-program/consumer.js", import.meta.url), "utf8"), + readFile(new URL("../../../fixtures/private-program-browser/worker-client.mjs", import.meta.url), "utf8"), + readFile(new URL("../../../fixtures/private-program-browser/proof.mjs", import.meta.url), "utf8"), + readFile(new URL("../package.json", import.meta.url), "utf8"), + ]); + + assert.match(consumer, /from "\.\/abi-v2\.js"/u); + assert.match(workerClient, /from "\/installed\/private-program\/abi-v2\.js"/u); + assert.doesNotMatch(workerClient, /consumer\.js/u); + assert.match(proof, /from "\.\/vectors\.mjs"/u); + assert.match(workerClient, /from "\.\/vectors\.mjs"/u); + assert.match(workerClient, /if \(response\.ok !== true\)/u); + assert.match(proof, /worker\.addEventListener\("messageerror"/u); + const manifest = JSON.parse(packageManifest); + assert.equal(manifest.files.includes("private-program/abi-v2.js"), true); + assert.equal(Object.hasOwn(manifest.exports, "./private-program/abi-v2.js"), false); +}); + +test("differential acceptance cannot pass without executing the independent worker", async () => { + const proof = await readFile( + new URL("../../../fixtures/private-program-browser/proof.mjs", import.meta.url), + "utf8", + ); + assert.match(proof, /const worker = new Worker\("\.\/worker-client\.mjs", \{ type: "module" \}\)/u); + assert.match(proof, /worker\.postMessage\("run"\)/u); + assert.match(proof, /equal\(workerResult\.initial, initialFingerprint/u); + assert.match(proof, /equal\(workerResult\.updated, updatedFingerprint/u); + assert.match(proof, /equal\(workerResult\.changed, changedFingerprint/u); + assert.match(proof, /checks\.push\("independent-worker-client-parity"\)/u); +}); diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index 3b0a9054..08276bff 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -353,6 +353,7 @@ test("the private Program artifact is packed without becoming a public subpath", packageJson.files.filter((path) => path.startsWith("private-program/")), [ "private-program/consumer.js", + "private-program/abi-v2.js", "private-program/labcolors_private_program.wasm", "private-program/build-metadata.json", ], @@ -546,6 +547,7 @@ test("the private Program artifact is packed without becoming a public subpath", /packageSmokeIndex\s*>=\s*0[\s\S]*smokePackedPackage\(tarball\)[\s\S]*:\s*verifyPackageRelease\(\)/u, ); for (const path of [ + "private-program/abi-v2.js", "private-program/consumer.js", "private-program/labcolors_private_program.wasm", "private-program/build-metadata.json", diff --git a/scripts/test-private-program-browser.mjs b/scripts/test-private-program-browser.mjs index efb0da58..b1e91775 100644 --- a/scripts/test-private-program-browser.mjs +++ b/scripts/test-private-program-browser.mjs @@ -31,6 +31,7 @@ import { fileURLToPath } from "node:url"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const REPO_ROOT = resolve(dirname(SCRIPT_PATH), ".."); const FIXTURE_ROOT = resolve(REPO_ROOT, "fixtures/private-program-browser"); +const FIXTURE_ROOT_ENV = "LAB_COLORS_PRIVATE_PROGRAM_FIXTURE_ROOT"; const LOOPBACK_HOST = "127.0.0.1"; const DRIVER_READY_POLL_MS = 50; const PROCESS_STOP_TIMEOUT_MS = 1_000; @@ -43,6 +44,7 @@ const EXPECTED_CHECKS = Object.freeze([ "exact-certified-receipt", "explicit-observation-update", "changed-observation-invalidates-certified-result", + "independent-worker-client-parity", "dispose", "post-run-dispose-idempotence", ]); @@ -51,12 +53,18 @@ export const PRIVATE_PROGRAM_BROWSER_PASS_RECEIPT = PASS_RECEIPT; const EXTERNAL_FIXTURE_FILES = Object.freeze({ "/": Object.freeze({ path: "index.html", type: "text/html; charset=utf-8" }), "/proof.mjs": Object.freeze({ path: "proof.mjs", type: "text/javascript; charset=utf-8" }), + "/vectors.mjs": Object.freeze({ path: "vectors.mjs", type: "text/javascript; charset=utf-8" }), + "/worker-client.mjs": Object.freeze({ path: "worker-client.mjs", type: "text/javascript; charset=utf-8" }), }); const INSTALLED_PACKAGE_FILES = Object.freeze({ "/installed/private-program/consumer.js": Object.freeze({ path: "private-program/consumer.js", type: "text/javascript; charset=utf-8", }), + "/installed/private-program/abi-v2.js": Object.freeze({ + path: "private-program/abi-v2.js", + type: "text/javascript; charset=utf-8", + }), "/installed/private-program/labcolors_private_program.wasm": Object.freeze({ path: "private-program/labcolors_private_program.wasm", type: "application/wasm", @@ -469,12 +477,22 @@ async function closeServer(server, timeoutMilliseconds = PROCESS_STOP_TIMEOUT_MS } } -async function materializeFixture(externalRoot) { +async function admittedFixtureRoot() { + const configured = process.env[FIXTURE_ROOT_ENV]?.trim(); + if (!configured) return FIXTURE_ROOT; + if (!isAbsolute(configured)) fail(`${FIXTURE_ROOT_ENV} must name an absolute directory`); + const physical = await realpath(configured); + if (!(await stat(physical)).isDirectory()) fail(`${FIXTURE_ROOT_ENV} is not a directory`); + return physical; +} + +async function materializeFixture(externalRoot, sourceRoot) { const fixtureRoot = resolve(externalRoot, "fixture"); await mkdir(fixtureRoot, { recursive: true }); await Promise.all( Object.values(EXTERNAL_FIXTURE_FILES).map(async ({ path }) => { - const bytes = await readFile(resolve(FIXTURE_ROOT, path)); + const source = await assertNoLinkPath(sourceRoot, path, "file", `fixture source ${path}`); + const bytes = await readFile(source); await writeFile(resolve(fixtureRoot, path), bytes); }), ); @@ -526,7 +544,7 @@ function proofServer(allowedFiles) { }; if (pathname === "/") { headers["content-security-policy"] = - "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; " + + "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; " + "style-src 'unsafe-inline'; img-src 'none'; font-src 'none'; object-src 'none'; " + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; } @@ -556,7 +574,7 @@ async function main() { resolve(externalRoot, "package.json"), `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, ); - const externalFixtureRoot = await materializeFixture(externalRoot); + const externalFixtureRoot = await materializeFixture(externalRoot, await admittedFixtureRoot()); installExactTarball(verifiedTarball, externalRoot, timeoutMilliseconds); const installedPackageRoot = resolve(externalRoot, "node_modules/@labpics/colors"); const physicalInstalledPackageRoot = await inspectInstalledPackage( diff --git a/scripts/test-private-program-mutations.mjs b/scripts/test-private-program-mutations.mjs index b1b0e525..0196abd0 100644 --- a/scripts/test-private-program-mutations.mjs +++ b/scripts/test-private-program-mutations.mjs @@ -68,7 +68,7 @@ const BROWSER_ASSERTION_PREFIX = "private Program browser proof: browser assertion failed:"; const BROWSER_CLEANUP_FAILURE = "private Program browser proof or cleanup failed"; const MUTATION_RECEIPT = "LAB_COLORS_PRIVATE_PROGRAM_MUTATIONS_PASS v1"; -const EXPECTED_SEMANTIC_MUTATION_COUNT = 7; +const EXPECTED_SEMANTIC_MUTATION_COUNT = 8; const EXPECTED_BINARY_DIFFERENTIAL_COUNT = 1; const MUTATION_TIMEOUT_ENV = "LAB_COLORS_PRIVATE_MUTATION_TIMEOUT_MS"; const CHILD_TIMEOUT_ENV = "LAB_COLORS_PRIVATE_MUTATION_CHILD_TIMEOUT_MS"; @@ -175,6 +175,22 @@ export const PRIVATE_PROGRAM_MUTATION_CASES = Object.freeze([ expectedBrowserAssertion: "Error: private Program browser fixture: computed background is the exact expected CSS literal; expected \"rgba(64, 64, 64, 0.5)\", got \"rgba(0, 0, 0, 0)\"", }), + mutation({ + id: "second-client-result-suppression", + proof: "semantic", + artifact: "fixture-javascript", + sourcePath: "fixtures/private-program-browser/worker-client.mjs", + search: lines( + " return Object.freeze({", + " initial: receiptFingerprint(initial),", + " updated: receiptFingerprint(updated),", + " changed: receiptFingerprint(changed),", + " });", + ).slice(0, -1), + replacement: ' throw new Error("second client result suppressed");', + expectedBrowserAssertion: + "Error: second client result suppressed", + }), ]); function fail(message, options) { @@ -1014,6 +1030,33 @@ async function packMutation(context, definition, wasm) { } async function packageAndKillMutation(context, definition, wasm) { + if (definition.artifact === "fixture-javascript") { + const fixtureRoot = resolve(context.root, "fixtures", definition.id); + await cp(resolve(REPO_ROOT, "fixtures/private-program-browser"), fixtureRoot, { + recursive: true, + dereference: false, + preserveTimestamps: true, + }); + const sourcePath = resolve(fixtureRoot, basename(definition.sourcePath)); + const source = await readFile(sourcePath, "utf8"); + await writeFile(sourcePath, applyExactMutation(source, definition), "utf8"); + const browserResult = await context.runner.run( + process.execPath, + [PROOF_SCRIPT, context.immutableTarball, context.expectedSha256], + { + cwd: REPO_ROOT, + env: { + ...process.env, + LAB_COLORS_PRIVATE_PROGRAM_FIXTURE_ROOT: fixtureRoot, + }, + label: `real-browser proof for ${definition.id}`, + allowNonzero: true, + }, + ); + assertMutationSpecificBrowserFailure(definition, browserResult); + process.stdout.write(`private Program mutant killed: ${definition.id} fixture-only\n`); + return; + } const packed = await packMutation(context, definition, wasm); const browserResult = await context.runner.run( process.execPath, @@ -1106,6 +1149,8 @@ async function executeMutationProof({ tarball, expectedSha256 }, policy) { root, runner, packageShell, + immutableTarball, + expectedSha256, baselineConsumer, baselineWasm, }); diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index f92d71cd..099c078f 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -1413,6 +1413,7 @@ await assert.rejects( (error) => error?.code === "ERR_PACKAGE_PATH_NOT_EXPORTED", ); for (const privateSubpath of [ + "@labpics/colors/private-program/abi-v2.js", "@labpics/colors/private-program/consumer.js", "@labpics/colors/private-program/labcolors_private_program.wasm", "@labpics/colors/private-program/build-metadata.json",