Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
8b22fe9
docs: plan automatic update parity
lidge-jun Nov 7, 2026
9c6f2d3
Extract offline minisign verification primitives
lidge-jun Nov 7, 2026
d7a6d1f
Generate proposed update trust root from pinned key
lidge-jun Nov 7, 2026
312682a
Define signed update candidate identities
lidge-jun Nov 7, 2026
bdf4f11
Add bounded unverified update JSON parser
lidge-jun Nov 7, 2026
d61963f
Bound aggregate update JSON allocations
lidge-jun Nov 7, 2026
70b6141
Compare signed update versions exactly
lidge-jun Nov 7, 2026
7c18101
Verify offline update signature thresholds
lidge-jun Nov 7, 2026
640e342
Validate closed signed update document schemas
lidge-jun Nov 7, 2026
4f01dbe
Bind generated update root digest into runtime
lidge-jun Nov 7, 2026
81827af
Authenticate the embedded update trust root
lidge-jun Nov 7, 2026
ad12405
Snapshot update root bytes before verification
lidge-jun Nov 7, 2026
1cbc6c5
Verify signed releases and select bound targets
lidge-jun Nov 7, 2026
0ea7f1e
Check signed update comment versions
lidge-jun Nov 7, 2026
80492a4
Add Node update primitive interoperability harness
lidge-jun Nov 7, 2026
56545c8
Separate root transition and release evidence
lidge-jun Nov 7, 2026
d6476c7
Verify signed update artifact evidence
lidge-jun Nov 7, 2026
d56a122
Cover artifact delegation and byte mismatches
lidge-jun Nov 7, 2026
6789830
Clarify signed root transition envelope contract
lidge-jun Nov 7, 2026
978b512
Bind update metadata evidence to signed targets
lidge-jun Nov 7, 2026
3dc5bc4
Cover signed metadata binding failures
lidge-jun Nov 7, 2026
652bd8c
Verify root transition signer unions
lidge-jun Nov 7, 2026
ba8244e
Exercise transition envelope in runtime harness
lidge-jun Nov 7, 2026
b9d3b33
Exercise signed transition evidence across runtimes
lidge-jun Nov 7, 2026
ca13618
Expose root transition envelope parser
lidge-jun Nov 7, 2026
4efe230
Allow bounded generated update asset names
lidge-jun Nov 7, 2026
6b2cee3
Exercise signed supply chain on Node 18
lidge-jun Nov 7, 2026
577dda3
Check schema byte caps across runtimes
lidge-jun Nov 7, 2026
a5e8371
Check root expiry across supported runtimes
lidge-jun Nov 7, 2026
3c498c4
Test schema field limits across runtimes
lidge-jun Nov 7, 2026
6176db6
Exercise malformed Unicode across runtimes
lidge-jun Nov 7, 2026
ba39709
Prove malformed Unicode rejection is observable
lidge-jun Nov 7, 2026
cc1c4f3
Bind verifier keys and parser input snapshots
lidge-jun Nov 7, 2026
8c927ba
Exercise full root thresholds on Node 18
lidge-jun Nov 7, 2026
04b14a1
Exercise signed version and legacy adapter parity
lidge-jun Nov 7, 2026
01dd17f
Exercise signed evidence for every target tuple
lidge-jun Nov 7, 2026
25d0822
Verify evidence across supported channels
lidge-jun Nov 7, 2026
8e52a10
Expose detached signed supply evidence snapshots
lidge-jun Nov 7, 2026
19f8b8a
Verify all evidence snapshot bindings
lidge-jun Nov 7, 2026
5734a3d
Add unverified checkpoint payload codec
lidge-jun Nov 7, 2026
a07c771
Bound checkpoint descriptor capture
lidge-jun Nov 7, 2026
c13fa19
Protect checkpoint encoder collection limits
lidge-jun Nov 7, 2026
64036bf
Merge origin/dev into codex/auto-update-kernel-19f8b8aa1e
lidge-jun Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 8 additions & 54 deletions desktop/scripts/verify-release-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
* version and commit before it uploads anything, so publication can only ever
* consume the verified bundle.
*/
import { createHash, createPublicKey, verify as ed25519Verify, type KeyObject } from "node:crypto";
import { createHash, type KeyObject } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { decodePublicKey, verifyLegacyMinisignBytes } from "../../src/update/minisign.mjs";
import {
standaloneArchiveName,
standaloneTargets as sharedStandaloneTargets,
Expand Down Expand Up @@ -122,8 +123,6 @@ export function verifyChecksums(dir: string): number {
return checksumFiles.length;
}

const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");

export interface MinisignPublicKey {
keyId: string;
publicKey: KeyObject;
Expand All @@ -146,31 +145,10 @@ function decodeBox(text: string, what: string): string {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(payload);
}

function boxLines(text: string): string[] {
// Accept minisign text with LF or CRLF and an optional terminal newline;
// signatures authenticate decoded bytes/comments, not transport line endings.
return text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
}

/** minisign public key: base64 of algorithm ("Ed") || key id (8) || raw key (32). */
export function parseMinisignPublicKey(text: string): MinisignPublicKey {
const lines = boxLines(text);
if (lines.length !== 2 || !lines[0]!.startsWith("untrusted comment: ")) {
throw new Error("Malformed minisign public key box");
}
const payload = decodeBase64(lines[1]!, "minisign public key", 42);
const algorithm = payload.subarray(0, 2).toString("utf8");
if (algorithm !== "Ed") {
throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`);
}
return {
keyId: payload.subarray(2, 10).toString("hex"),
publicKey: createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, payload.subarray(10, 42)]),
format: "der",
type: "spki",
}),
};
const { keyId, publicKey } = decodePublicKey(text);
return { keyId, publicKey };
}

/** The updater public key pinned in the Tauri configuration. */
Expand All @@ -187,34 +165,10 @@ export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey {
export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void {
const signaturePath = `${filePath}.sig`;
if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`);
const lines = boxLines(decodeBox(readFileSync(signaturePath, "utf8"), "Tauri signature"));
const trustedPrefix = "trusted comment: ";
if (lines.length !== 4 || !lines[0]!.startsWith("untrusted comment: ")
|| !lines[2]!.startsWith(trustedPrefix)) {
throw new Error(`Malformed signature box in ${signaturePath}`);
}
const payload = decodeBase64(lines[1]!, "signature packet", 74);
const globalSignature = decodeBase64(lines[3]!, "comment signature", 64);
const algorithm = payload.subarray(0, 2).toString("utf8");
if (algorithm !== "ED") {
throw new Error(`Unsupported signature algorithm in ${signaturePath}: ${JSON.stringify(algorithm)}`);
}
const keyId = payload.subarray(2, 10).toString("hex");
if (keyId !== key.keyId) {
throw new Error(`Signature ${signaturePath} was made by key ${keyId}, not the pinned updater key ${key.keyId}`);
}
const signature = payload.subarray(10, 74);
// ED is ordinary Ed25519 over the BLAKE2b-512 digest, not Ed25519ph.
const digest = createHash("blake2b512").update(readFileSync(filePath)).digest();
if (!ed25519Verify(null, digest, key.publicKey, signature)) {
throw new Error(`Signature verification failed for ${filePath}`);
}
// minisign signs the raw signature + trimmed trusted comment, without its
// prefix or line terminator. The original filename may differ after collection.
const trustedComment = lines[2]!.slice(trustedPrefix.length).trim();
const message = Buffer.concat([signature, Buffer.from(trustedComment, "utf8")]);
if (!ed25519Verify(null, message, key.publicKey, globalSignature)) {
throw new Error(`Comment signature verification failed for ${filePath}`);
try {
verifyLegacyMinisignBytes(readFileSync(filePath), readFileSync(signaturePath, "utf8"), key);
} catch (error) {
throw new Error(`${error instanceof Error ? error.message : String(error)}: ${filePath}`);
}
}

Expand Down
38 changes: 38 additions & 0 deletions desktop/src-tauri/update-trust-root.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"schema": "ocx.update.root/1",
"revision": 1,
"expiresAt": "2028-11-07T00:00:00Z",
"rootKeys": [
{
"keyId": "eae4c0180e8ca39a844b3c0e1ec28842261e85628d6dee77ceff5dd081853799",
"algorithm": "minisign-ed25519-blake2b512/1",
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNzZCMDg0NkVCRUJGODEKUldTQnY3NXVoTEIyckhRUXJMOXJRUDR0aHQ2L3pLVHVweXFSc1lzS24vWDNiSUJ5MXJIZmpyb2sK"
}
],
"rootThreshold": 1,
"releaseKeys": [
{
"keyId": "eae4c0180e8ca39a844b3c0e1ec28842261e85628d6dee77ceff5dd081853799",
"algorithm": "minisign-ed25519-blake2b512/1",
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNzZCMDg0NkVCRUJGODEKUldTQnY3NXVoTEIyckhRUXJMOXJRUDR0aHQ2L3pLVHVweXFSc1lzS24vWDNiSUJ5MXJIZmpyb2sK",
"channels": [
"latest",
"preview",
"nightly"
],
"roles": [
"release",
"tree",
"desktop-artifact",
"standalone-artifact",
"guardian-artifact"
]
}
],
"releaseThreshold": 1,
"channels": [
"latest",
"preview",
"nightly"
]
}
62 changes: 62 additions & 0 deletions scripts/generate-update-trust-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/** Generate the offline update root from the already-pinned Tauri public key. */
import { readFileSync, renameSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { decodePublicKey } from "../src/update/minisign.mjs";

const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const configPath = resolve(repo, "desktop/src-tauri/tauri.conf.json");
const rootOutputs = [
resolve(repo, "src/update/trust-root.json"),
resolve(repo, "desktop/src-tauri/update-trust-root.json"),
];
const channels = ["latest", "preview", "nightly"];
const roles = ["release", "tree", "desktop-artifact", "standalone-artifact", "guardian-artifact"];

export function generateUpdateTrustRoot(config: unknown): string {
const box = (config as { plugins?: { updater?: { pubkey?: unknown } } })?.plugins?.updater?.pubkey;
if (typeof box !== "string" || !box || box.length > 4096) throw new Error("Invalid pinned updater public key");
const encoded = Buffer.from(box, "base64");
if (encoded.toString("base64") !== box) throw new Error("Updater public key must be canonical base64");
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(encoded);
const key = decodePublicKey(text);
const entry = { keyId: key.fingerprint, algorithm: "minisign-ed25519-blake2b512/1", publicKey: box };
return `${JSON.stringify({
schema: "ocx.update.root/1",
revision: 1,
expiresAt: "2028-11-07T00:00:00Z",
rootKeys: [entry],
rootThreshold: 1,
releaseKeys: [{ ...entry, channels, roles }],
releaseThreshold: 1,
channels,
}, null, 2)}\n`;
}

function main(): void {
const expected = generateUpdateTrustRoot(JSON.parse(readFileSync(configPath, "utf8")));
const fingerprint = createHash("sha256").update(expected, "utf8").digest("hex");
const outputs = [
...rootOutputs.map((path) => ({ path, contents: expected })),
{ path: resolve(repo, "src/update/update-trust-anchor.mjs"), contents: `// Generated by scripts/generate-update-trust-root.ts.\nexport const EMBEDDED_ROOT_SHA256 = "${fingerprint}";\n` },
];
if (process.argv.slice(2).some((arg) => arg !== "--check") || process.argv.slice(2).length > 1) {
throw new Error("Usage: generate-update-trust-root.ts [--check]");
}
if (process.argv[2] === "--check") {
for (const output of outputs) {
if (readFileSync(output.path, "utf8") !== output.contents) throw new Error("Generated update trust root is out of date");
}
process.stdout.write("Generated update trust roots match the pinned public key\n");
return;
}
for (const output of outputs) {
const temporary = `${output.path}.${process.pid}.tmp`;
writeFileSync(temporary, output.contents, { flag: "wx" });
renameSync(temporary, output.path);
}
process.stdout.write("Generated update trust roots\n");
}

if (import.meta.main) main();
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1837,7 +1837,7 @@
"umans-provider.test.ts": "providers",
"uninstall.test.ts": "cli",
"update-async-routes.test.ts": "server",
"update-badge.test.ts": "update", "update-bun-ownership-lease.test.ts": "update",
"update-badge.test.ts": "update", "update-bounded-json.test.ts": "update", "update-checkpoint-codec.test.ts": "update", "update-contracts.test.ts": "update", "update-candidate-id.test.ts": "update", "update-external-metadata.test.ts": "update", "update-minisign.test.ts": "update", "update-root-snapshot.test.ts": "update", "update-signed-supply.test.ts": "update", "update-signed-version.test.ts": "update", "update-trust-root.test.ts": "update", "update-bun-ownership-lease.test.ts": "update",
"update-desktop-badge.test.ts": "update", "update-desktop-owner.test.ts": "update", "update-supervised-recovery.test.ts": "update",
"update-job.test.ts": "update", "update-recovery-host-free.test.ts": "update", "update-job-supervised-restart.test.ts": "update", "update-standalone.test.ts": "update", "update-worker-launch.test.ts": "update",
"update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update",
Expand Down
17 changes: 17 additions & 0 deletions src/update/minisign.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { KeyObject } from "node:crypto";

export interface MinisignKey {
keyId: string;
publicKey: KeyObject;
fingerprint: string;
}

export interface MinisignVerification {
keyId: string;
fingerprint: string;
trustedComment: string;
}

export function decodePublicKey(text: string): MinisignKey;
export function verifyMinisignBytes(bytes: Uint8Array, signatureBox: string, key: MinisignKey): MinisignVerification;
export function verifyLegacyMinisignBytes(bytes: Uint8Array, signatureBox: string, key: Pick<MinisignKey, "keyId" | "publicKey">): Omit<MinisignVerification, "fingerprint">;
88 changes: 88 additions & 0 deletions src/update/minisign.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Offline minisign primitives. These results prove only cryptographic validity;
// update authority is assigned separately by the signed-supply policy.
import { createHash, createPublicKey, verify } from "node:crypto";
import { snapshotUpdateBytes } from "./update-bytes.mjs";

const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
const TRUSTED_PREFIX = "trusted comment: ";

function decodeBase64(text, what, expectedBytes) {
const payload = Buffer.from(text, "base64");
if (!text || payload.toString("base64") !== text
|| (expectedBytes !== undefined && payload.length !== expectedBytes)) {
throw new Error(`Malformed ${what}: invalid base64 or decoded length`);
}
return payload;
}

function boxLines(text) {
return text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
}

/** Decode the text form of a minisign public key; this does not establish trust. */
export function decodePublicKey(text) {
const lines = boxLines(text);
if (lines.length !== 2 || !lines[0].startsWith("untrusted comment: ")) {
throw new Error("Malformed minisign public key box");
}
const packet = decodeBase64(lines[1], "minisign public key", 42);
const algorithm = packet.subarray(0, 2).toString("utf8");
if (algorithm !== "Ed") {
throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`);
}
const raw = packet.subarray(10);
return Object.freeze({
keyId: packet.subarray(2, 10).toString("hex"),
publicKey: createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: "der", type: "spki" }),
fingerprint: createHash("sha256").update(raw).digest("hex"),
});
}

function verifyBox(bytes, signatureBox, key, legacy) {
// The historical release adapter accepts surrounding transport whitespace.
const outer = legacy ? signatureBox.trim() : signatureBox;
if (!legacy && outer.length > 5464) throw new Error("Malformed Tauri signature: box exceeds limit");
const encoded = decodeBase64(outer, "Tauri signature");
if (!legacy && encoded.length > 4096) throw new Error("Malformed Tauri signature: box exceeds limit");
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(encoded);
const lines = boxLines(text);
if (lines.length !== 4 || !lines[0].startsWith("untrusted comment: ")
|| !lines[2].startsWith(TRUSTED_PREFIX)) {
throw new Error("Malformed signature box");
}
const packet = decodeBase64(lines[1], "signature packet", 74);
const commentSignature = decodeBase64(lines[3], "comment signature", 64);
const algorithm = packet.subarray(0, 2).toString("utf8");
if (algorithm !== "ED") throw new Error(`Unsupported signature algorithm: ${JSON.stringify(algorithm)}`);
const keyId = packet.subarray(2, 10).toString("hex");
const expectedKeyId = key.keyId;
if (keyId !== expectedKeyId) throw new Error(`Signature was made by key ${keyId}, not the pinned updater key ${expectedKeyId}`);
// Capture one public-key representation, then reconstruct a private verifier
// so caller-controlled accessors cannot change the signer between checks.
let publicKeyDer;
try { publicKeyDer = snapshotUpdateBytes(key.publicKey.export({ format: "der", type: "spki" }), 44); }
catch { throw new Error("Malformed minisign public key"); }
if (publicKeyDer.length !== 44 || !Buffer.from(publicKeyDer.subarray(0, 12)).equals(ED25519_SPKI_PREFIX)) {
throw new Error("Malformed minisign public key");
}
const publicKey = createPublicKey({ key: Buffer.from(publicKeyDer), format: "der", type: "spki" });
const signature = packet.subarray(10);
const digest = createHash("blake2b512").update(bytes).digest();
if (!verify(null, digest, publicKey, signature)) throw new Error("Signature verification failed");
const exactComment = lines[2].slice(TRUSTED_PREFIX.length);
const trustedComment = legacy ? exactComment.trim() : exactComment;
const message = Buffer.concat([signature, Buffer.from(trustedComment, "utf8")]);
if (!verify(null, message, publicKey, commentSignature)) throw new Error("Comment signature verification failed");
const fingerprint = createHash("sha256").update(publicKeyDer.subarray(-32)).digest("hex");
return { keyId, fingerprint, trustedComment };
}

/** Verify both signatures using the exact trusted-comment bytes. */
export function verifyMinisignBytes(bytes, signatureBox, key) {
return verifyBox(bytes, signatureBox, key, false);
}

/** Compatibility-only adapter for the existing release verifier's trimmed comment. */
export function verifyLegacyMinisignBytes(bytes, signatureBox, key) {
return verifyBox(bytes, signatureBox, key, true);
}
Loading
Loading