Skip to content

Commit 2b8b007

Browse files
authored
fix(release): align checksum and Tauri signature verification (#5544)
* fix(release): name standalone checksums after their archives * fix(release): verify Tauri prehashed minisign signature boxes
1 parent 7bd9985 commit 2b8b007

7 files changed

Lines changed: 186 additions & 27 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ jobs:
165165
# dist/release, where the artifact download lands these files flat; the
166166
# checksum therefore records the bare file name, which sha256sum takes
167167
# verbatim from its argument.
168-
if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
169-
else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256"
168+
if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip.sha256"
169+
else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz.sha256"
170170
fi
171171
172172
- name: Upload standalone release

‎desktop/scripts/verify-release-assets.ts‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -129,22 +129,36 @@ export interface MinisignPublicKey {
129129
publicKey: KeyObject;
130130
}
131131

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

142+
function decodeBox(text: string, what: string): string {
143+
// Transport whitespace is harmless (the updater manifest also trims it).
144+
// The encoded payload itself must still be canonical and valid UTF-8.
145+
const payload = decodeBase64(text.trim(), what);
146+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(payload);
147+
}
148+
149+
function boxLines(text: string): string[] {
150+
// Accept minisign text with LF or CRLF and an optional terminal newline;
151+
// signatures authenticate decoded bytes/comments, not transport line endings.
152+
return text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
153+
}
154+
145155
/** minisign public key: base64 of algorithm ("Ed") || key id (8) || raw key (32). */
146156
export function parseMinisignPublicKey(text: string): MinisignPublicKey {
147-
const payload = minisignPayload(text, 42, "minisign public key");
157+
const lines = boxLines(text);
158+
if (lines.length !== 2 || !lines[0]!.startsWith("untrusted comment: ")) {
159+
throw new Error("Malformed minisign public key box");
160+
}
161+
const payload = decodeBase64(lines[1]!, "minisign public key", 42);
148162
const algorithm = payload.subarray(0, 2).toString("utf8");
149163
if (algorithm !== "Ed") {
150164
throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`);
@@ -166,30 +180,42 @@ export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey {
166180
};
167181
const pubkey = conf.plugins?.updater?.pubkey;
168182
if (!pubkey) throw new Error(`No plugins.updater.pubkey in ${tauriConfPath}`);
169-
return parseMinisignPublicKey(Buffer.from(pubkey, "base64").toString("utf8"));
183+
return parseMinisignPublicKey(decodeBox(pubkey, "Tauri public key"));
170184
}
171185

172-
/**
173-
* minisign signature: base64 of algorithm || key id (8) || signature (64).
174-
* "Ed" is a pure Ed25519 signature over the raw file bytes — the form the Tauri
175-
* bundler emits. "ED" (BLAKE2b-prehashed) or anything else fails loudly rather
176-
* than being silently mis-verified.
177-
*/
186+
/** Tauri CLI 2.11.1 wraps a minisign 0.7.3 prehashed signature box in base64. */
178187
export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void {
179188
const signaturePath = `${filePath}.sig`;
180189
if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`);
181-
const payload = minisignPayload(readFileSync(signaturePath, "utf8"), 74, `signature ${signaturePath}`);
190+
const lines = boxLines(decodeBox(readFileSync(signaturePath, "utf8"), "Tauri signature"));
191+
const trustedPrefix = "trusted comment: ";
192+
if (lines.length !== 4 || !lines[0]!.startsWith("untrusted comment: ")
193+
|| !lines[2]!.startsWith(trustedPrefix)) {
194+
throw new Error(`Malformed signature box in ${signaturePath}`);
195+
}
196+
const payload = decodeBase64(lines[1]!, "signature packet", 74);
197+
const globalSignature = decodeBase64(lines[3]!, "comment signature", 64);
182198
const algorithm = payload.subarray(0, 2).toString("utf8");
183-
if (algorithm !== "Ed") {
199+
if (algorithm !== "ED") {
184200
throw new Error(`Unsupported signature algorithm in ${signaturePath}: ${JSON.stringify(algorithm)}`);
185201
}
186202
const keyId = payload.subarray(2, 10).toString("hex");
187203
if (keyId !== key.keyId) {
188204
throw new Error(`Signature ${signaturePath} was made by key ${keyId}, not the pinned updater key ${key.keyId}`);
189205
}
190-
if (!ed25519Verify(null, readFileSync(filePath), key.publicKey, payload.subarray(10, 74))) {
206+
const signature = payload.subarray(10, 74);
207+
// ED is ordinary Ed25519 over the BLAKE2b-512 digest, not Ed25519ph.
208+
const digest = createHash("blake2b512").update(readFileSync(filePath)).digest();
209+
if (!ed25519Verify(null, digest, key.publicKey, signature)) {
191210
throw new Error(`Signature verification failed for ${filePath}`);
192211
}
212+
// minisign signs the raw signature + trimmed trusted comment, without its
213+
// prefix or line terminator. The original filename may differ after collection.
214+
const trustedComment = lines[2]!.slice(trustedPrefix.length).trim();
215+
const message = Buffer.concat([signature, Buffer.from(trustedComment, "utf8")]);
216+
if (!ed25519Verify(null, message, key.publicKey, globalSignature)) {
217+
throw new Error(`Comment signature verification failed for ${filePath}`);
218+
}
193219
}
194220

195221
function parseBackManifest(manifestPath: string, options: VerifyReleaseAssetsOptions): string[] {

‎structure/desktop-shell.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ packaging matrices, verifies every checksum and every updater signature, and wri
214214
platforms to have updater signatures. Publication waits for that verification, and the
215215
attachment job uploads the verified bundle only after the verification receipt names
216216
the same version and commit.
217+
Updater signature verification decodes Tauri’s outer-base64 minisign box, checks the
218+
`ED` signature over the BLAKE2b-512 digest against the pinned key, and verifies the
219+
trusted-comment signature. Missing or malformed fields fail before publication.
217220
On macOS, in-app updates download `OpenCodex-<version>-macos.app.tar.gz`; the DMG is for
218221
the first installation.
219222

‎tests/ci-workflows/release-desktop-scripts.test.ts‎

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -530,17 +530,44 @@ describe("release asset verification", () => {
530530
function makeMinisignKeypair(keyIdHex: string): {
531531
pubkeyText: string;
532532
keyId: Buffer;
533-
signPayload: (payload: Buffer) => string;
533+
signPayload: (payload: Buffer, rawBytes?: boolean) => string;
534534
} {
535535
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
536536
const raw = Buffer.from(publicKey.export({ format: "der", type: "spki" })).subarray(-32);
537537
const keyId = Buffer.from(keyIdHex, "hex");
538538
const pubkeyText = `untrusted comment: test public key\n${Buffer.concat([Buffer.from("Ed"), keyId, raw]).toString("base64")}\n`;
539-
const signPayload = (payload: Buffer): string =>
540-
`untrusted comment: test signature\n${Buffer.concat([Buffer.from("Ed"), keyId, ed25519Sign(null, payload, privateKey)]).toString("base64")}\n`;
539+
const signPayload = (payload: Buffer, rawBytes = false): string => {
540+
const signed = rawBytes ? payload : createHash("blake2b512").update(payload).digest();
541+
const signature = ed25519Sign(null, signed, privateKey);
542+
const trusted = "timestamp:1\tfile:original-before-collection.msi";
543+
const packet = Buffer.concat([Buffer.from("ED"), keyId, signature]).toString("base64");
544+
const global = ed25519Sign(null, Buffer.concat([signature, Buffer.from(trusted)]), privateKey).toString("base64");
545+
return Buffer.from(`untrusted comment: test signature\n${packet}\ntrusted comment: ${trusted}\n${global}\n`).toString("base64");
546+
};
541547
return { pubkeyText, keyId, signPayload };
542548
}
543549

550+
test("accepts the upstream minisign 0.7.3 prehashed vector in Tauri encoding", () => {
551+
const fixture = JSON.parse(readFileSync(repoPath("tests/fixtures/minisign/prehashed-vector.json"), "utf8")) as {
552+
payload: string; publicKeyBase64: string; signatureBox: string;
553+
};
554+
const dir = temporaryDirectory();
555+
try {
556+
const asset = join(dir, "renamed-release.bin");
557+
const key = parseMinisignPublicKey(`untrusted comment: upstream key\n${fixture.publicKeyBase64}\n`);
558+
writeFileSync(asset, fixture.payload);
559+
writeFileSync(`${asset}.sig`, Buffer.from(fixture.signatureBox).toString("base64"));
560+
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
561+
writeFileSync(asset, "tampered");
562+
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/);
563+
writeFileSync(asset, fixture.payload);
564+
writeFileSync(`${asset}.sig`, Buffer.from(fixture.signatureBox.replace("file:test", "file:changed")).toString("base64"));
565+
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/);
566+
} finally {
567+
rmSync(dir, { recursive: true, force: true });
568+
}
569+
});
570+
544571
test("derives the expected set from the real release matrices and producer tables", () => {
545572
const workflow = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8");
546573
const { standaloneTargets: workflowStandalone, desktopTargets } = releaseMatrixTargets(workflow);
@@ -624,9 +651,43 @@ describe("release asset verification", () => {
624651
writeFileSync(`${asset}.sig`, other.signPayload(payload));
625652
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/not the pinned updater key/);
626653

627-
const hashed = `untrusted comment: test\n${Buffer.concat([Buffer.from("ED"), other.keyId, Buffer.alloc(64)]).toString("base64")}\n`;
628-
writeFileSync(`${asset}.sig`, hashed);
654+
const valid = signPayload(payload);
655+
writeFileSync(`${asset}.sig`, signPayload(payload, true));
656+
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/);
657+
const lines = Buffer.from(valid, "base64").toString("utf8").trimEnd().split("\n");
658+
const encode = (box: string[]) => Buffer.from(`${box.join("\n")}\n`).toString("base64");
659+
const packet = Buffer.from(lines[1]!, "base64");
660+
packet[10] = packet[10]! ^ 1;
661+
writeFileSync(`${asset}.sig`, encode([lines[0]!, packet.toString("base64"), lines[2]!, lines[3]!]));
662+
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/);
663+
packet[10] = packet[10]! ^ 1;
664+
packet.write("Ed", 0);
665+
writeFileSync(`${asset}.sig`, encode([lines[0]!, packet.toString("base64"), lines[2]!, lines[3]!]));
629666
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Unsupported signature algorithm/);
667+
668+
writeFileSync(`${asset}.sig`, encode([lines[0]!, lines[1]!, "trusted comment: changed", lines[3]!]));
669+
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/);
670+
writeFileSync(`${asset}.sig`, encode([lines[0]!, lines[1]!, lines[2]!, Buffer.alloc(64).toString("base64")]));
671+
expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Comment signature verification failed/);
672+
const sameIdOtherKey = parseMinisignPublicKey(makeMinisignKeypair("0123456789abcdef").pubkeyText);
673+
writeFileSync(`${asset}.sig`, valid);
674+
expect(() => verifyUpdaterSignature(asset, sameIdOtherKey)).toThrow(/Signature verification failed/);
675+
676+
for (const malformed of [valid + "!", encode(lines.slice(0, 3)), encode([...lines, "extra"]),
677+
encode([lines[0]!, Buffer.alloc(73).toString("base64"), lines[2]!, lines[3]!]),
678+
encode([lines[0]!, lines[1]!, lines[2]!, Buffer.alloc(65).toString("base64")]),
679+
Buffer.from([0xff]).toString("base64"),
680+
Buffer.from(`\uFEFF${lines.join("\n")}`).toString("base64"),
681+
lines.slice(0, 2).join("\n")]) {
682+
writeFileSync(`${asset}.sig`, malformed);
683+
expect(() => verifyUpdaterSignature(asset, key)).toThrow();
684+
}
685+
writeFileSync(`${asset}.sig`, encode(["untrusted comment: changed", ...lines.slice(1)]));
686+
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
687+
writeFileSync(`${asset}.sig`, Buffer.from(`${lines.join("\r\n")}\r\n`).toString("base64"));
688+
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
689+
writeFileSync(`${asset}.sig`, ` \n${valid}\n`);
690+
expect(() => verifyUpdaterSignature(asset, key)).not.toThrow();
630691
} finally {
631692
rmSync(dir, { recursive: true, force: true });
632693
}

‎tests/ci-workflows/release-pipeline-contract.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ describe("release pipeline contract", () => {
9393
const output = />\s+"([^"]+)"/.exec(line)?.[1];
9494
expect(output, line).toBeDefined();
9595
expect(output!).not.toContain("/");
96+
// verifyChecksums binds each record to its own payload by removing only
97+
// the final .sha256 suffix, including the archive extension in the name.
98+
expect(output, line).toBe(`${argument}.sha256`);
9699
}
97100

98101
// The bare names above only resolve end to end if the step checksums from the directory

‎tests/fixtures/minisign/LICENSE‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
Copyright (c) 2019-2023 Frank Denis
2+
Copyright (c) 2017 Daniel Rangel
3+
4+
Permission is hereby granted, free of charge, to any
5+
person obtaining a copy of this software and associated
6+
documentation files (the "Software"), to deal in the
7+
Software without restriction, including without
8+
limitation the rights to use, copy, modify, merge,
9+
publish, distribute, sublicense, and/or sell copies of
10+
the Software, and to permit persons to whom the Software
11+
is furnished to do so, subject to the following
12+
conditions:
13+
14+
The above copyright notice and this permission notice
15+
shall be included in all copies or substantial portions
16+
of the Software.
17+
18+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
19+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
20+
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
21+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
22+
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
23+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
24+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
25+
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
26+
DEALINGS IN THE SOFTWARE.
27+
28+
--
29+
30+
Code in the src/crypto folder is derived from the rust-crypto project:
31+
https://github.com/DaGenix/rust-crypto
32+
33+
Original ISC license follows:
34+
35+
Copyright (c) 2006-2009 Graydon Hoare
36+
Copyright (c) 2009-2013 Mozilla Foundation
37+
38+
Permission is hereby granted, free of charge, to any
39+
person obtaining a copy of this software and associated
40+
documentation files (the "Software"), to deal in the
41+
Software without restriction, including without
42+
limitation the rights to use, copy, modify, merge,
43+
publish, distribute, sublicense, and/or sell copies of
44+
the Software, and to permit persons to whom the Software
45+
is furnished to do so, subject to the following
46+
conditions:
47+
48+
The above copyright notice and this permission notice
49+
shall be included in all copies or substantial portions
50+
of the Software.
51+
52+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
53+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
54+
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
55+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
56+
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
57+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
58+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
59+
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
60+
DEALINGS IN THE SOFTWARE.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"source": "https://github.com/jedisct1/rust-minisign/blob/068d25a2fffd3d9da96e43f3844c830d8176d5a0/src/tests.rs#L159-L185",
3+
"payload": "test",
4+
"publicKeyBase64": "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3",
5+
"signatureBox": "untrusted comment: signature from minisign secret key\nRUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=\ntrusted comment: timestamp:1556193335\tfile:test\ny/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg=="
6+
}

0 commit comments

Comments
 (0)