Skip to content

Commit 0f2d1f2

Browse files
committed
Validate trusted comment in minisign signature
This prevents a malicious actor from substituting one signed tarball for another (e.g. providing Zig 0.13.0 where 0.14.0 was requested). This could be a vulnerability in some subtle edge cases; consider a user requesting a patch release, but getting the preceding minor release, making their application vulnerable to some upstream bug which was fixed in the patch release. Resolves: #1
1 parent 806c1bb commit 0f2d1f2

File tree

2 files changed

+51
-20
lines changed

2 files changed

+51
-20
lines changed

main.js

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,38 @@ const CANONICAL = 'https://ziglang.org/builds';
1818
// This is an array of URLs.
1919
const MIRRORS = require('./mirrors.json').map((x) => x[0]);
2020

21-
async function downloadFromMirror(mirror, tarball_name, tarball_ext) {
22-
const tarball_path = await tc.downloadTool(`${mirror}/${tarball_name}${tarball_ext}?source=github-actions`);
21+
async function downloadFromMirror(mirror, tarball_filename) {
22+
const tarball_path = await tc.downloadTool(`${mirror}/${tarball_filename}?source=github-actions`);
2323

24-
const signature_response = await fetch(`${mirror}/${tarball_name}${tarball_ext}.minisig?source=github-actions`);
24+
const signature_response = await fetch(`${mirror}/${tarball_filename}.minisig?source=github-actions`);
2525
const signature_data = Buffer.from(await signature_response.arrayBuffer());
2626

2727
const tarball_data = await fs.readFile(tarball_path);
2828

2929
const key = minisign.parseKey(MINISIGN_KEY);
3030
const signature = minisign.parseSignature(signature_data);
3131
if (!minisign.verifySignature(key, signature, tarball_data)) {
32-
throw new Error(`signature verification failed for '${mirror}/${tarball_name}${tarball_ext}'`);
32+
throw new Error(`signature verification failed for '${mirror}/${tarball_filename}'`);
33+
}
34+
35+
// Parse the trusted comment to validate the tarball name.
36+
// This prevents a malicious actor from trying to pass off one signed tarball as another.
37+
const match = /^timestamp:\d+\s+file:([^\s]+)\s+hashed$/.exec(signature.trusted_comment.toString());
38+
if (match === null || match[1] !== tarball_filename) {
39+
throw new Error(`filename verification failed for '${mirror}/${tarball_filename}'`);
3340
}
3441

3542
return tarball_path;
3643
}
3744

38-
async function downloadTarball(tarball_name, tarball_ext) {
45+
async function downloadTarball(tarball_filename) {
3946
const preferred_mirror = core.getInput('mirror');
4047
if (preferred_mirror.includes("://ziglang.org/") || preferred_mirror.startsWith("ziglang.org/")) {
4148
throw new Error("'https://ziglang.org' cannot be used as mirror override; for more information see README.md");
4249
}
4350
if (preferred_mirror) {
4451
core.info(`Using mirror: ${preferred_mirror}`);
45-
return await downloadFromMirror(preferred_mirror, tarball_name, tarball_ext);
52+
return await downloadFromMirror(preferred_mirror, tarball_filename);
4653
}
4754

4855
// We will attempt all mirrors before making a last-ditch attempt to the official download.
@@ -51,14 +58,14 @@ async function downloadTarball(tarball_name, tarball_ext) {
5158
for (const mirror of shuffled_mirrors) {
5259
core.info(`Attempting mirror: ${mirror}`);
5360
try {
54-
return await downloadFromMirror(mirror, tarball_name, tarball_ext);
61+
return await downloadFromMirror(mirror, tarball_filename);
5562
} catch (e) {
5663
core.info(`Mirror failed with error: ${e}`);
5764
// continue loop to next mirror
5865
}
5966
}
6067
core.info(`Attempting official: ${CANONICAL}`);
61-
return await downloadFromMirror(CANONICAL, tarball_name, tarball_ext);
68+
return await downloadFromMirror(CANONICAL, tarball_filename);
6269
}
6370

6471
async function retrieveTarball(tarball_name, tarball_ext) {
@@ -70,7 +77,7 @@ async function retrieveTarball(tarball_name, tarball_ext) {
7077
}
7178

7279
core.info(`Cache miss. Fetching Zig ${await common.getVersion()}`);
73-
const downloaded_path = await downloadTarball(tarball_name, tarball_ext);
80+
const downloaded_path = await downloadTarball(`${tarball_name}${tarball_ext}`);
7481
await fs.copyFile(downloaded_path, tarball_cache_path)
7582
await cache.saveCache([tarball_cache_path], cache_key);
7683
return tarball_cache_path;

minisign.js

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ function parseKey(key_str) {
2222
// Throws exceptions on invalid signature files.
2323
function parseSignature(sig_buf) {
2424
const untrusted_header = Buffer.from('untrusted comment: ');
25+
const trusted_header = Buffer.from('trusted comment: ');
2526

2627
// Validate untrusted comment header, and skip
2728
if (!sig_buf.subarray(0, untrusted_header.byteLength).equals(untrusted_header)) {
28-
throw new Error('file format not recognised');
29+
throw new Error('invalid minisign signature: bad untrusted comment header');
2930
}
3031
sig_buf = sig_buf.subarray(untrusted_header.byteLength);
3132

@@ -42,38 +43,61 @@ function parseSignature(sig_buf) {
4243
const key_id = sig_info.subarray(2, 10);
4344
const signature = sig_info.subarray(10);
4445

45-
// We don't look at the trusted comment or global signature, so we're done.
46+
// Validate trusted comment header, and skip
47+
if (!sig_buf.subarray(0, trusted_header.byteLength).equals(trusted_header)) {
48+
throw new Error('invalid minisign signature: bad trusted comment header');
49+
}
50+
sig_buf = sig_buf.subarray(trusted_header.byteLength);
51+
52+
// Read and skip trusted comment
53+
const trusted_comment_end = sig_bug.indexOf('\n');
54+
const trusted_comment = sig_buf.subarray(0, trusted_comment_end);
55+
sig_buf = sig_buf.subarray(trusted_comment_end + 1);
56+
57+
// Read and skip global signature; handle missing trailing newline, just in case
58+
let global_sig_end = sig_buf.indexOf('\n');
59+
if (global_sig_end == -1) global_sig_end = sig_buf.length;
60+
const global_sig = Buffer.from(sig_buf.subarray(0, global_sig_end).toString(), 'base64');
61+
sig_buf = sig_buf.subarray(sig_info_end + 1); // this might be length+1, but that's allowed
62+
63+
// Validate that all data has been consumed
64+
if (sig_buf.length !== 0) {
65+
throw new Error('invalid minisign signature: trailing bytes');
66+
}
4667

4768
return {
4869
algorithm: algorithm,
4970
key_id: key_id,
5071
signature: signature,
72+
trusted_comment: trusted_comment,
73+
global_signature: global_signature,
5174
};
5275
}
5376

54-
// Given a parsed key, parsed signature file, and raw file content, verifies the
55-
// signature. Does not throw. Returns 'true' if the signature is valid for this
56-
// file, 'false' otherwise.
77+
// Given a parsed key, parsed signature file, and raw file content, verifies the signature,
78+
// including the global signature (hence validating the trusted comment). Does not throw.
79+
// Returns 'true' if the signature is valid for this file, 'false' otherwise.
5780
function verifySignature(pubkey, signature, file_content) {
81+
if (!signature.key_id.equals(pubkey.id)) {
82+
return false;
83+
}
84+
5885
let signed_content;
5986
if (signature.algorithm.equals(Buffer.from('ED'))) {
6087
signed_content = Buffer.alloc(sodium.crypto_generichash_BYTES_MAX);
6188
sodium.crypto_generichash(signed_content, file_content);
6289
} else {
6390
signed_content = file_content;
6491
}
65-
66-
if (!signature.key_id.equals(pubkey.id)) {
92+
if (!sodium.crypto_sign_verify_detached(signature.signature, signed_content, pubkey.key)) {
6793
return false;
6894
}
6995

70-
if (!sodium.crypto_sign_verify_detached(signature.signature, signed_content, pubkey.key)) {
96+
const global_signed_content = Buffer.concat([signature.signature, signature.trusted_comment]);
97+
if (!sodium.crypto_sign_verify_detached(signature.global_signature, global_signed_content, pubkey.key)) {
7198
return false;
7299
}
73100

74-
// Since we don't use the trusted comment, we don't bother verifying the global signature.
75-
// If we were to start using the trusted comment for any purpose, we must add this.
76-
77101
return true;
78102
}
79103

0 commit comments

Comments
 (0)