From ea1328de79b2c7570d57abc898da89635126cbb7 Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:08:57 +0200 Subject: [PATCH] Add optional SHA-256 verification of downloaded manifest pieces Each Sony manifest piece carries a SHA-256 (hashValue). When the new "Verify downloaded pieces" setting is on, every piece is hashed while it streams to disk (OpenSSL EVP, already linked) and compared against the manifest value; a mismatch aborts the download, deletes the partial, and reports piece_verify_failed instead of handing a corrupt 60 GB package to the installer. Off by default: TLS already protects the bytes in transit and the PS5 installer verifies the whole packageDigest before applying, so this is a fail-fast belt-and-suspenders check. It is also unverified on hardware yet, so it stays opt-in (persisted in config.json) until confirmed on-device; the hex compare is case-insensitive since Sony mixes cases. --- src/patchdl_net.c | 89 +++++++++++++++++++++++++++++++++++++------- src/patchdl_net.h | 5 ++- src/patchdl_websrv.c | 37 ++++++++++++------ web/app.js | 6 +++ web/index.html | 7 ++++ 5 files changed, 117 insertions(+), 27 deletions(-) diff --git a/src/patchdl_net.c b/src/patchdl_net.c index 5662d52..e22bcfe 100644 --- a/src/patchdl_net.c +++ b/src/patchdl_net.c @@ -7,12 +7,14 @@ #include #include #include +#include #include #include #include #ifdef PATCHDL_HAVE_CURL #include +#include #include "patchdl_ca.h" #endif @@ -290,9 +292,32 @@ patchdl_http_get(const char *url, patchdl_buf_t *out) { return 0; } +/* Write sink: tees the body to the file and, when verifying, into a running + SHA-256. curl always calls with size==1, so nmemb is the byte count. */ +typedef struct { + FILE *fp; + EVP_MD_CTX *md; /* NULL when not verifying */ +} write_sink_t; + static size_t file_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) { - return fwrite(ptr, size, nmemb, (FILE *)userdata); + write_sink_t *s = (write_sink_t *)userdata; + size_t written = fwrite(ptr, size, nmemb, s->fp); + if (s->md && written) + EVP_DigestUpdate(s->md, ptr, written * size); + return written; +} + +/* Hex-encode a digest, lowercase. */ +static void +hex_encode(const unsigned char *d, unsigned int len, char *out, size_t out_sz) { + static const char hexd[] = "0123456789abcdef"; + unsigned int i; + for (i = 0; i < len && (2u * i + 2u) < out_sz; i++) { + out[2 * i] = hexd[(d[i] >> 4) & 0xf]; + out[2 * i + 1] = hexd[d[i] & 0xf]; + } + out[2 * i] = '\0'; } typedef struct { @@ -318,21 +343,32 @@ curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow, return p->cb(p->ctx, p->base + (long long)dlnow, total); } +/* Returns 0 on success, -1 on download/network failure, -2 when an expected + SHA-256 was given and the downloaded bytes did not match it. */ static int http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out, - progress_state_t *progress) { + progress_state_t *progress, + const char *expected_sha256_hex) { CURL *curl; CURLcode res; char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512]; struct curl_slist *rl = NULL; struct curl_blob ca_blob; curl_off_t dl = 0; + write_sink_t sink = { fp, NULL }; + int verify = (expected_sha256_hex && expected_sha256_hex[0]); if (bytes_out) *bytes_out = 0; if (url_host(url, host, sizeof(host))) return -1; if (!host_allowed(host)) return -1; if (dns_lookup(host, ip, sizeof(ip))) return -1; + if (verify) { + sink.md = EVP_MD_CTX_new(); + if (sink.md) + EVP_DigestInit_ex(sink.md, EVP_sha256(), NULL); + } + snprintf(rs443, sizeof(rs443), "%s:443:%s", host, ip); rl = curl_slist_append(NULL, rs443); snprintf(rs80, sizeof(rs80), "%s:80:%s", host, ip); @@ -343,12 +379,16 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out, ca_blob.flags = CURL_BLOB_COPY; curl = curl_easy_init(); - if (!curl) { curl_slist_free_all(rl); return -1; } + if (!curl) { + curl_slist_free_all(rl); + if (sink.md) EVP_MD_CTX_free(sink.md); + return -1; + } curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_RESOLVE, rl); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, file_write_cb); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink); curl_easy_setopt(curl, CURLOPT_CAINFO_BLOB, &ca_blob); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); @@ -371,7 +411,22 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out, curl_easy_cleanup(curl); curl_slist_free_all(rl); - if (res != CURLE_OK) return -1; + if (res != CURLE_OK) { + if (sink.md) EVP_MD_CTX_free(sink.md); + return -1; + } + + if (sink.md) { + unsigned char dig[EVP_MAX_MD_SIZE]; + unsigned int dlen = 0; + char hex[2 * EVP_MAX_MD_SIZE + 1]; + EVP_DigestFinal_ex(sink.md, dig, &dlen); + EVP_MD_CTX_free(sink.md); + hex_encode(dig, dlen, hex, sizeof(hex)); + if (strcasecmp(hex, expected_sha256_hex) != 0) + return -2; /* integrity mismatch */ + } + if (bytes_out) *bytes_out = (long long)dl; return 0; } @@ -385,7 +440,7 @@ patchdl_http_download_progress(const char *url, const char *dest_path, int rc; if (!fp) return -1; - rc = http_download_to_file_progress(url, fp, bytes_out, &progress); + rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL); fclose(fp); if (rc) { @@ -449,7 +504,7 @@ patchdl_http_download_manifest_progress(const char *manifest_url, const char *dest_path, long long *bytes_out, patchdl_download_progress_cb cb, - void *ctx) { + void *ctx, int verify) { patchdl_buf_t manifest; const char *pieces; const char *p; @@ -486,10 +541,11 @@ patchdl_http_download_manifest_progress(const char *manifest_url, p = pieces; while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) { char url[768]; + char hash[80] = {0}; long long got = 0; unsigned long long expected = 0; unsigned long long offset = 0; - int have_offset; + int have_offset, drc; const char *obj_end = strchr(p, '}'); progress_state_t progress = { cb, @@ -502,6 +558,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url, break; json_u64_after(p, "fileSize", &expected); have_offset = (json_u64_after(p, "fileOffset", &offset) == 0); + if (verify) + json_string_after(p, "hashValue", hash, sizeof(hash)); /* Pieces are concatenated in array order; each one's fileOffset must equal the bytes written so far. A manifest that lists them out of @@ -509,8 +567,13 @@ patchdl_http_download_manifest_progress(const char *manifest_url, if (have_offset && offset != (unsigned long long)total) goto done; - if (http_download_to_file_progress(url, fp, &got, &progress)) - goto done; /* network error or cancel mid-piece */ + /* drc: 0 ok, -1 network/cancel, -2 SHA-256 mismatch (propagated out). */ + drc = http_download_to_file_progress(url, fp, &got, &progress, + hash[0] ? hash : NULL); + if (drc) { + if (drc == -2) rc = -2; + goto done; + } if (expected && (unsigned long long)got != expected) goto done; @@ -538,7 +601,7 @@ int patchdl_http_download_manifest(const char *manifest_url, const char *dest_path, long long *bytes_out) { return patchdl_http_download_manifest_progress(manifest_url, dest_path, - bytes_out, NULL, NULL); + bytes_out, NULL, NULL, 0); } void @@ -633,8 +696,8 @@ patchdl_http_download_manifest_progress(const char *manifest_url, const char *dest_path, long long *bytes_out, patchdl_download_progress_cb cb, - void *ctx) { - (void)cb; (void)ctx; + void *ctx, int verify) { + (void)cb; (void)ctx; (void)verify; return patchdl_http_download_manifest(manifest_url, dest_path, bytes_out); } diff --git a/src/patchdl_net.h b/src/patchdl_net.h index 3edae0a..54af4de 100644 --- a/src/patchdl_net.h +++ b/src/patchdl_net.h @@ -28,14 +28,15 @@ int patchdl_http_download_progress(const char *url, const char *dest_path, patchdl_download_progress_cb cb, void *ctx); /* Download a Sony JSON package manifest by concatenating every entry in - "pieces" into one installable PKG. */ + "pieces" into one installable PKG. When `verify` is non-zero each piece is + checked against its manifest SHA-256 (a mismatch returns -2). */ int patchdl_http_download_manifest(const char *manifest_url, const char *dest_path, long long *bytes_out); int patchdl_http_download_manifest_progress(const char *manifest_url, const char *dest_path, long long *bytes_out, patchdl_download_progress_cb cb, - void *ctx); + void *ctx, int verify); /* Diagnostic: run the GET pipeline for `url` and write a JSON report (dns result/ip, curl code, http status, bytes) into `out_json`. */ diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 9b3447b..7f867f9 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -37,7 +37,8 @@ static struct { char default_policy[8]; /* "deny" | "allow" */ int install_after_download; int delete_pkg_after_install; -} g_cfg = { "deny", 0, 1 }; + int verify_downloads; /* SHA-256 each manifest piece (default off) */ +} g_cfg = { "deny", 0, 1, 0 }; static struct { int active; @@ -436,10 +437,12 @@ build_config_json(void) { snprintf(head, sizeof(head), "{\"default_policy\":\"%s\"," "\"install_after_download\":%s," - "\"delete_pkg_after_install\":%s,", + "\"delete_pkg_after_install\":%s," + "\"verify_downloads\":%s,", g_cfg.default_policy[0] ? g_cfg.default_policy : "deny", g_cfg.install_after_download ? "true" : "false", - g_cfg.delete_pkg_after_install ? "true" : "false"); + g_cfg.delete_pkg_after_install ? "true" : "false", + g_cfg.verify_downloads ? "true" : "false"); pthread_mutex_unlock(&g_mutex); char *out = malloc(strlen(head) + sizeof(config_tail_json)); @@ -463,10 +466,12 @@ save_config(void) { fprintf(f, "{\n\"default_policy\":\"%s\",\n" "\"install_after_download\":%s,\n" - "\"delete_pkg_after_install\":%s,\n\"titles\":{", + "\"delete_pkg_after_install\":%s,\n" + "\"verify_downloads\":%s,\n\"titles\":{", g_cfg.default_policy[0] ? g_cfg.default_policy : "deny", g_cfg.install_after_download ? "true" : "false", - g_cfg.delete_pkg_after_install ? "true" : "false"); + g_cfg.delete_pkg_after_install ? "true" : "false", + g_cfg.verify_downloads ? "true" : "false"); for (size_t i = 0; i < g_title_count; i++) fprintf(f, "%s\"%s\":%s", i ? "," : "", g_titles[i].title_id, g_titles[i].enabled ? "true" : "false"); @@ -500,6 +505,8 @@ load_config(void) { json_get_bool(buf, "install_after_download", g_cfg.install_after_download); g_cfg.delete_pkg_after_install = json_get_bool(buf, "delete_pkg_after_install", g_cfg.delete_pkg_after_install); + g_cfg.verify_downloads = + json_get_bool(buf, "verify_downloads", g_cfg.verify_downloads); /* per-title overrides live under "titles": { "": true|false, ... } */ const char *titles = strstr(buf, "\"titles\""); @@ -835,6 +842,7 @@ do_download(struct MHD_Connection *conn, const char *title_id, const char *name, const char *version, int enabled) { char dir[256], dest[320], resp[640]; long long bytes = 0; + int verify = 0, dlrc; if (!enabled) return queue_json(conn, MHD_HTTP_FORBIDDEN, @@ -866,13 +874,15 @@ do_download(struct MHD_Connection *conn, const char *title_id, strncpy(g_dl.name, name && name[0] ? name : title_id, sizeof(g_dl.name) - 1); strncpy(g_dl.version, version ? version : "", sizeof(g_dl.version) - 1); strncpy(g_dl.path, dest, sizeof(g_dl.path) - 1); + verify = g_cfg.verify_downloads; pthread_mutex_unlock(&g_mutex); - if ((url_is_manifest(patch_url) - ? patchdl_http_download_manifest_progress(patch_url, dest, &bytes, - download_progress_cb, NULL) - : patchdl_http_download_progress(patch_url, dest, &bytes, - download_progress_cb, NULL))) { + dlrc = url_is_manifest(patch_url) + ? patchdl_http_download_manifest_progress(patch_url, dest, &bytes, + download_progress_cb, NULL, verify) + : patchdl_http_download_progress(patch_url, dest, &bytes, + download_progress_cb, NULL); + if (dlrc) { int was_cancel; pthread_mutex_lock(&g_mutex); was_cancel = g_dl.cancel; @@ -889,8 +899,9 @@ do_download(struct MHD_Connection *conn, const char *title_id, return queue_json(conn, MHD_HTTP_OK, "{\"ok\":false,\"cancelled\":true," "\"reason\":\"download_cancelled\"}"); - snprintf(resp, sizeof(resp), - "{\"ok\":false,\"reason\":\"download_failed\"}"); + /* -2 = a piece failed its SHA-256 (only possible when verify is on). */ + snprintf(resp, sizeof(resp), "{\"ok\":false,\"reason\":\"%s\"}", + dlrc == -2 ? "piece_verify_failed" : "download_failed"); return queue_json_owned(conn, MHD_HTTP_BAD_GATEWAY, strdup(resp)); } @@ -1064,6 +1075,8 @@ handle_config_post(struct MHD_Connection *conn, const char *body) { json_get_bool(body, "install_after_download", g_cfg.install_after_download); g_cfg.delete_pkg_after_install = json_get_bool(body, "delete_pkg_after_install", g_cfg.delete_pkg_after_install); + g_cfg.verify_downloads = + json_get_bool(body, "verify_downloads", g_cfg.verify_downloads); pthread_mutex_unlock(&g_mutex); save_config(); diff --git a/web/app.js b/web/app.js index 63391ea..21dea37 100644 --- a/web/app.js +++ b/web/app.js @@ -47,6 +47,7 @@ const fallback = { download_dir: "/data/patchdl (internal)", install_after_download: false, delete_pkg_after_install: true, + verify_downloads: false, source_policy: { official: { allow_check: true, allow_download: true, allow_install: true }, external: { allow_check: true, allow_download: true, allow_install: true }, @@ -168,6 +169,7 @@ function bindElements() { downloadDir: document.getElementById("downloadDir"), installAfterDownload: document.getElementById("installAfterDownload"), deleteAfterInstall: document.getElementById("deleteAfterInstall"), + verifyDownloads: document.getElementById("verifyDownloads"), refreshBtn: document.getElementById("refreshBtn"), saveBtn: document.getElementById("saveBtn"), pauseAllBtn: document.getElementById("pauseAllBtn"), @@ -262,6 +264,8 @@ function renderSettings() { els.downloadDir.value = state.config.download_dir || ""; els.installAfterDownload.checked = Boolean(state.config.install_after_download); els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install); + if (els.verifyDownloads) + els.verifyDownloads.checked = Boolean(state.config.verify_downloads); els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => { const chip = document.createElement("span"); chip.className = "host-chip"; @@ -573,6 +577,7 @@ async function saveConfig() { download_dir: els.downloadDir.value.trim(), install_after_download: els.installAfterDownload.checked, delete_pkg_after_install: els.deleteAfterInstall.checked, + verify_downloads: els.verifyDownloads ? els.verifyDownloads.checked : Boolean(state.config.verify_downloads), }; try { @@ -782,6 +787,7 @@ const REASON_TEXT = { no_compatible_patch: "No compatible patch available.", download_failed: "Download failed.", download_in_progress: "Another download is already running.", + piece_verify_failed: "A downloaded piece failed its SHA-256 check.", }; function reasonText(error) { const r = error && error.body && error.body.reason; diff --git a/web/index.html b/web/index.html index 924203b..3855f84 100644 --- a/web/index.html +++ b/web/index.html @@ -184,6 +184,13 @@ Only after a successful install +