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.
This commit is contained in:
Knutwurst committed 2026-06-23 18:08:57 +02:00
1 parent 9006965a75
commit ea1328de79
5 files changed
+117 -27

No files matched your search

+76 -13
View File
@@ -7,12 +7,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#ifdef PATCHDL_HAVE_CURL
#include <curl/curl.h>
#include <openssl/evp.h>
#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);
}
+3 -2
View File
@@ -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`. */
+25 -12
View File
@@ -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": { "<id>": 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();
+6
View File
@@ -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;
+7
View File
@@ -184,6 +184,13 @@
<em>Only after a successful install</em>
</span>
</label>
<label class="switch-row">
<input id="verifyDownloads" type="checkbox" />
<span>
<strong>Verify downloaded pieces (SHA-256)</strong>
<em>Checks each manifest piece; off by default, verify on-device first</em>
</span>
</label>
</div>
<div class="allowlist">