Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ jobs:
# dist/release, where the artifact download lands these files flat; the
# checksum therefore records the bare file name, which sha256sum takes
# verbatim from its argument.
if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip.sha256"
else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz.sha256"
fi

- name: Upload standalone release
Expand Down
66 changes: 46 additions & 20 deletions desktop/scripts/verify-release-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,22 +129,36 @@ export interface MinisignPublicKey {
publicKey: KeyObject;
}

function minisignPayload(text: string, expectedBytes: number, what: string): Buffer {
const encoded = text
.split("\n")
.filter(line => line.trim().length > 0 && !line.trimStart().startsWith("untrusted comment:"))
.join("")
.trim();
const payload = Buffer.from(encoded, "base64");
if (payload.length !== expectedBytes) {
throw new Error(`Malformed ${what}: expected ${expectedBytes} decoded bytes, got ${payload.length}`);
function decodeBase64(text: string, what: string, expectedBytes?: number): Buffer {
const payload = Buffer.from(text, "base64");
// Buffer.from is intentionally permissive; release metadata must be canonical.
if (!text || payload.toString("base64") !== text
|| (expectedBytes !== undefined && payload.length !== expectedBytes)) {
throw new Error(`Malformed ${what}: invalid base64 or decoded length`);
}
return payload;
}

function decodeBox(text: string, what: string): string {
// Transport whitespace is harmless (the updater manifest also trims it).
// The encoded payload itself must still be canonical and valid UTF-8.
const payload = decodeBase64(text.trim(), what);
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 payload = minisignPayload(text, 42, "minisign public key");
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)}`);
Expand All @@ -166,30 +180,42 @@ export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey {
};
const pubkey = conf.plugins?.updater?.pubkey;
if (!pubkey) throw new Error(`No plugins.updater.pubkey in ${tauriConfPath}`);
return parseMinisignPublicKey(Buffer.from(pubkey, "base64").toString("utf8"));
return parseMinisignPublicKey(decodeBox(pubkey, "Tauri public key"));
}

/**
* minisign signature: base64 of algorithm || key id (8) || signature (64).
* "Ed" is a pure Ed25519 signature over the raw file bytes — the form the Tauri
* bundler emits. "ED" (BLAKE2b-prehashed) or anything else fails loudly rather
* than being silently mis-verified.
*/
/** Tauri CLI 2.11.1 wraps a minisign 0.7.3 prehashed signature box in base64. */
export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void {
const signaturePath = `${filePath}.sig`;
if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`);
const payload = minisignPayload(readFileSync(signaturePath, "utf8"), 74, `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") {
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}`);
}
if (!ed25519Verify(null, readFileSync(filePath), key.publicKey, payload.subarray(10, 74))) {
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}`);
}
}

function parseBackManifest(manifestPath: string, options: VerifyReleaseAssetsOptions): string[] {
Expand Down
3 changes: 3 additions & 0 deletions structure/desktop-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@ packaging matrices, verifies every checksum and every updater signature, and wri
platforms to have updater signatures. Publication waits for that verification, and the
attachment job uploads the verified bundle only after the verification receipt names
the same version and commit.
Updater signature verification decodes Tauri’s outer-base64 minisign box, checks the
`ED` signature over the BLAKE2b-512 digest against the pinned key, and verifies the
trusted-comment signature. Missing or malformed fields fail before publication.
On macOS, in-app updates download `OpenCodex-<version>-macos.app.tar.gz`; the DMG is for
the first installation.

Expand Down
71 changes: 66 additions & 5 deletions tests/ci-workflows/release-desktop-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,17 +530,44 @@ describe("release asset verification", () => {
function makeMinisignKeypair(keyIdHex: string): {
pubkeyText: string;
keyId: Buffer;
signPayload: (payload: Buffer) => string;
signPayload: (payload: Buffer, rawBytes?: boolean) => string;
} {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const raw = Buffer.from(publicKey.export({ format: "der", type: "spki" })).subarray(-32);
const keyId = Buffer.from(keyIdHex, "hex");
const pubkeyText = `untrusted comment: test public key\n${Buffer.concat([Buffer.from("Ed"), keyId, raw]).toString("base64")}\n`;
const signPayload = (payload: Buffer): string =>
`untrusted comment: test signature\n${Buffer.concat([Buffer.from("Ed"), keyId, ed25519Sign(null, payload, privateKey)]).toString("base64")}\n`;
const signPayload = (payload: Buffer, rawBytes = false): string => {
const signed = rawBytes ? payload : createHash("blake2b512").update(payload).digest();
const signature = ed25519Sign(null, signed, privateKey);
const trusted = "timestamp:1\tfile:original-before-collection.msi";
const packet = Buffer.concat([Buffer.from("ED"), keyId, signature]).toString("base64");
const global = ed25519Sign(null, Buffer.concat([signature, Buffer.from(trusted)]), privateKey).toString("base64");
return Buffer.from(`untrusted comment: test signature\n${packet}\ntrusted comment: ${trusted}\n${global}\n`).toString("base64");
};
return { pubkeyText, keyId, signPayload };
}

test("accepts the upstream minisign 0.7.3 prehashed vector in Tauri encoding", () => {
const fixture = JSON.parse(readFileSync(repoPath("tests/fixtures/minisign/prehashed-vector.json"), "utf8")) as {
payload: string; publicKeyBase64: string; signatureBox: string;
};
const dir = temporaryDirectory();
try {
const asset = join(dir, "renamed-release.bin");
const key = parseMinisignPublicKey(`untrusted comment: upstream key\n${fixture.publicKeyBase64}\n`);
writeFileSync(asset, fixture.payload);
writeFileSync(`${asset}.sig`, Buffer.from(fixture.signatureBox).toString("base64"));
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
writeFileSync(asset, "tampered");
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/);
writeFileSync(asset, fixture.payload);
writeFileSync(`${asset}.sig`, Buffer.from(fixture.signatureBox.replace("file:test", "file:changed")).toString("base64"));
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("derives the expected set from the real release matrices and producer tables", () => {
const workflow = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8");
const { standaloneTargets: workflowStandalone, desktopTargets } = releaseMatrixTargets(workflow);
Expand Down Expand Up @@ -624,9 +651,43 @@ describe("release asset verification", () => {
writeFileSync(`${asset}.sig`, other.signPayload(payload));
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/not the pinned updater key/);

const hashed = `untrusted comment: test\n${Buffer.concat([Buffer.from("ED"), other.keyId, Buffer.alloc(64)]).toString("base64")}\n`;
writeFileSync(`${asset}.sig`, hashed);
const valid = signPayload(payload);
writeFileSync(`${asset}.sig`, signPayload(payload, true));
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/);
const lines = Buffer.from(valid, "base64").toString("utf8").trimEnd().split("\n");
const encode = (box: string[]) => Buffer.from(`${box.join("\n")}\n`).toString("base64");
const packet = Buffer.from(lines[1]!, "base64");
packet[10] = packet[10]! ^ 1;
writeFileSync(`${asset}.sig`, encode([lines[0]!, packet.toString("base64"), lines[2]!, lines[3]!]));
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/);
packet[10] = packet[10]! ^ 1;
packet.write("Ed", 0);
writeFileSync(`${asset}.sig`, encode([lines[0]!, packet.toString("base64"), lines[2]!, lines[3]!]));
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Unsupported signature algorithm/);

writeFileSync(`${asset}.sig`, encode([lines[0]!, lines[1]!, "trusted comment: changed", lines[3]!]));
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/);
writeFileSync(`${asset}.sig`, encode([lines[0]!, lines[1]!, lines[2]!, Buffer.alloc(64).toString("base64")]));
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/);
const sameIdOtherKey = parseMinisignPublicKey(makeMinisignKeypair("0123456789abcdef").pubkeyText);
writeFileSync(`${asset}.sig`, valid);
expect(() => verifyUpdaterSignature(asset, sameIdOtherKey)).toThrow(/Signature verification failed/);

for (const malformed of [valid + "!", encode(lines.slice(0, 3)), encode([...lines, "extra"]),
encode([lines[0]!, Buffer.alloc(73).toString("base64"), lines[2]!, lines[3]!]),
encode([lines[0]!, lines[1]!, lines[2]!, Buffer.alloc(65).toString("base64")]),
Buffer.from([0xff]).toString("base64"),
Buffer.from(`\uFEFF${lines.join("\n")}`).toString("base64"),
lines.slice(0, 2).join("\n")]) {
writeFileSync(`${asset}.sig`, malformed);
expect(() => verifyUpdaterSignature(asset, key)).toThrow();
}
writeFileSync(`${asset}.sig`, encode(["untrusted comment: changed", ...lines.slice(1)]));
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
writeFileSync(`${asset}.sig`, Buffer.from(`${lines.join("\r\n")}\r\n`).toString("base64"));
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
writeFileSync(`${asset}.sig`, ` \n${valid}\n`);
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
} finally {
rmSync(dir, { recursive: true, force: true });
}
Expand Down
3 changes: 3 additions & 0 deletions tests/ci-workflows/release-pipeline-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ describe("release pipeline contract", () => {
const output = />\s+"([^"]+)"/.exec(line)?.[1];
expect(output, line).toBeDefined();
expect(output!).not.toContain("/");
// verifyChecksums binds each record to its own payload by removing only
// the final .sha256 suffix, including the archive extension in the name.
expect(output, line).toBe(`${argument}.sha256`);
}

// The bare names above only resolve end to end if the step checksums from the directory
Expand Down
60 changes: 60 additions & 0 deletions tests/fixtures/minisign/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
Copyright (c) 2019-2023 Frank Denis
Copyright (c) 2017 Daniel Rangel

Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

--

Code in the src/crypto folder is derived from the rust-crypto project:
https://github.com/DaGenix/rust-crypto

Original ISC license follows:

Copyright (c) 2006-2009 Graydon Hoare
Copyright (c) 2009-2013 Mozilla Foundation

Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:

The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
6 changes: 6 additions & 0 deletions tests/fixtures/minisign/prehashed-vector.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"source": "https://github.com/jedisct1/rust-minisign/blob/068d25a2fffd3d9da96e43f3844c830d8176d5a0/src/tests.rs#L159-L185",
"payload": "test",
"publicKeyBase64": "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3",
"signatureBox": "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg=="
}
Loading