Skip to content

Commit 76c483e

Browse files
sturqclaude
andauthored
Verify the downloaded .deb against its release asset digest (#18)
The released .deb runs a postinst that patches binaries but was fetched over TLS only, with no integrity check. install.sh now verifies the download against its GitHub release asset digest (per-asset sha256; releases are immutable) before apt-get install. A mismatch aborts — and so does a missing digest, which can't legitimately occur since install.sh only ever resolves the latest release, which always carries one; failing closed keeps the check from being bypassed by stripping the field. Release-JSON parsing lives in pure asset_url / asset_digest helpers (grep/sed, since jq is only pulled in later as a package dependency), with the digest bound to the same .deb asset asset_url selects. The helpers are unit-tested hermetically via shUnit2 (test/install_test.sh, the test:shunit2 suite under mise run test:fast). Co-authored-by: sturq <81992095+sturq@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent af4dea3 commit 76c483e

2 files changed

Lines changed: 64 additions & 15 deletions

File tree

install.sh

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ asset_url() {
3838
sed -E 's/.*"(https[^"]+)"$/\1/'
3939
}
4040

41+
# Extract the aarch64 .deb asset's sha256 digest from GitHub release JSON ($1).
42+
# Digests are per-asset, so bind to the same asset asset_url picks rather than
43+
# grabbing the first digest in the document: flatten newlines, split the asset
44+
# array into one object per line, select the .deb object by its URL, then read
45+
# that object's digest. grep/sed, not jq (only pulled in later as a package
46+
# dependency). GitHub records a sha256 for every asset and this repo's releases
47+
# are immutable, so it can't change after publish. `|| true` tolerates a
48+
# no-match; an absent digest makes main abort (it only fetches the latest
49+
# release, which always carries one).
50+
asset_digest() {
51+
printf '%s' "$1" |
52+
tr '\n' ' ' |
53+
sed -E 's/\}[[:space:]]*,[[:space:]]*\{/}\n{/g' |
54+
{ grep -E '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*_aarch64\.deb"' || true; } |
55+
head -1 |
56+
{ grep -oE '"digest"[[:space:]]*:[[:space:]]*"sha256:[0-9a-f]{64}"' || true; } |
57+
head -1 |
58+
sed -E 's/.*"sha256:([0-9a-f]{64})".*/\1/'
59+
}
60+
4161
main() {
4262
set -euo pipefail
4363

@@ -52,7 +72,7 @@ main() {
5272
log "Installing local package $deb"
5373
else
5474
log "Resolving the latest release of $REPO"
55-
local api url
75+
local api url digest actual
5676
api=$(curl -fsSL "$API") ||
5777
die "could not reach the GitHub API (network down or rate limited)."
5878
url=$(asset_url "$api")
@@ -62,6 +82,20 @@ main() {
6282
trap 'rm -f -- "$deb"' EXIT
6383
log "Downloading $url"
6484
curl -fsSL "$url" -o "$deb"
85+
86+
# Verify the download against the release asset's digest before install,
87+
# since the .deb runs a postinst that patches binaries. A missing digest
88+
# aborts too: install.sh only fetches the latest release, which always
89+
# carries one, so its absence means tampered or unexpected metadata — not a
90+
# legitimately undigested asset — and skipping the check would defeat it.
91+
digest=$(asset_digest "$api")
92+
[ -n "$digest" ] ||
93+
die "no sha256 digest for $(basename "$url") in the release metadata; refusing to install unverified."
94+
log "Verifying checksum…"
95+
actual=$(sha256sum "$deb" | cut -d' ' -f1)
96+
[ "$actual" = "$digest" ] ||
97+
die "checksum mismatch for $(basename "$url"): expected $digest, got $actual."
98+
log "Checksum OK."
6599
fi
66100

67101
# glibc-runner and patchelf-glibc live in the glibc-packages repo, which the

test/install_test.sh

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,22 @@ cd "$(dirname "$0")/.." || exit 1
1919
# shellcheck source=/dev/null
2020
CLAUDE_CODE_INSTALL_LIB=1 . ./install.sh
2121

22-
# A release-JSON shape mirroring GitHub's, with a decoy asset before the aarch64
23-
# .deb so the selector has to discriminate rather than grab the first URL.
22+
# A release-JSON shape mirroring GitHub's, with a decoy asset (no .deb suffix,
23+
# its own distinct digest) ordered before the aarch64 .deb so the selectors must
24+
# discriminate by asset — not just grab the first url/digest in the document.
2425
release_json() {
2526
cat <<'EOF'
2627
{
2728
"assets": [
2829
{
29-
"name": "SHA256SUMS",
30-
"browser_download_url": "https://github.com/gtbuchanan/claude-code-termux/releases/download/v2026.6.19/SHA256SUMS"
30+
"name": "release-notes.txt",
31+
"browser_download_url": "https://github.com/gtbuchanan/claude-code-termux/releases/download/v2026.6.19/release-notes.txt",
32+
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
3133
},
3234
{
3335
"name": "claude-code-termux_2026.6.19_aarch64.deb",
34-
"browser_download_url": "https://github.com/gtbuchanan/claude-code-termux/releases/download/v2026.6.19/claude-code-termux_2026.6.19_aarch64.deb"
36+
"browser_download_url": "https://github.com/gtbuchanan/claude-code-termux/releases/download/v2026.6.19/claude-code-termux_2026.6.19_aarch64.deb",
37+
"digest": "sha256:6c5280d0a9fa52138097035b298e03fcb40e61001350a3492a8d70c35b2805a8"
3538
}
3639
]
3740
}
@@ -44,27 +47,39 @@ test_asset_url_extracts_aarch64_deb() {
4447
"$(asset_url "$(release_json)")"
4548
}
4649

47-
# Run asset_url under pipefail — as main() does — and assert it yields no output
48-
# AND still exits 0. This is what proves the `|| true` guard: without it, the
49-
# no-match grep's non-zero status would trip pipefail and abort the install.
50-
assert_no_match_under_pipefail() { # $1 = message, $2 = release JSON
50+
test_asset_digest_extracts_sha256() {
51+
assertEquals 'extracts the .deb asset sha256 digest (bare hex, no prefix)' \
52+
'6c5280d0a9fa52138097035b298e03fcb40e61001350a3492a8d70c35b2805a8' \
53+
"$(asset_digest "$(release_json)")"
54+
}
55+
56+
# Run helper $1 (asset_url|asset_digest) on JSON $3 under pipefail — as main()
57+
# does — and assert no output AND a 0 exit. Empty-with-success is what proves
58+
# the `|| true` guard: without it the no-match grep's non-zero status would trip
59+
# pipefail and abort the install.
60+
assert_empty_under_pipefail() { # $1 = helper, $2 = message, $3 = release JSON
5161
local out rc
5262
out="$(
5363
set -o pipefail
54-
asset_url "$2"
64+
"$1" "$3"
5565
)"
5666
rc=$?
57-
assertEquals "$1" '' "$out"
58-
assertEquals "$1 (exits 0 under pipefail)" 0 "$rc"
67+
assertEquals "$2" '' "$out"
68+
assertEquals "$2 (exits 0 under pipefail)" 0 "$rc"
5969
}
6070

6171
test_asset_url_empty_when_no_aarch64_deb() {
62-
assert_no_match_under_pipefail 'empty when the release has no aarch64 .deb asset' \
72+
assert_empty_under_pipefail asset_url 'empty when the release has no aarch64 .deb asset' \
6373
'{"assets":[{"browser_download_url":"https://example.com/x.txt"}]}'
6474
}
6575

6676
test_asset_url_empty_on_non_json() {
67-
assert_no_match_under_pipefail 'empty on a non-JSON blob' 'this is not json'
77+
assert_empty_under_pipefail asset_url 'empty on a non-JSON blob' 'this is not json'
78+
}
79+
80+
test_asset_digest_empty_when_absent() {
81+
assert_empty_under_pipefail asset_digest 'empty when the .deb asset has no digest' \
82+
'{"assets":[{"browser_download_url":"https://example.com/x_aarch64.deb"}]}'
6883
}
6984

7085
# shUnit2 hands control here: it discovers the test_* functions above and prints

0 commit comments

Comments
 (0)