Harden Switch OTA install with TLS verification and reliable NRO replace.

Enable HTTPS peer checks via a romfs CA bundle, remove existing NROs before rename on sdmc, and surface launcher replace failures before writing version.txt.
This commit is contained in:
Andrew Quenehen
2026-08-06 08:58:22 -03:00
parent c7e72149f1
commit 84e1a5177b
7 changed files with 44 additions and 9 deletions
+1
View File
@@ -45,6 +45,7 @@ native/switch-ota-launcher/*.nacp
native/switch-ota-launcher/*.elf native/switch-ota-launcher/*.elf
native/switch-ota-launcher/*.map native/switch-ota-launcher/*.map
native/switch-ota-launcher/romfs/logo.png native/switch-ota-launcher/romfs/logo.png
native/switch-ota-launcher/romfs/cacert.pem
# Legacy manual convenience-copy location (superseded by /dist/android/) # Legacy manual convenience-copy location (superseded by /dist/android/)
mobile/dist/ mobile/dist/
+6
View File
@@ -135,9 +135,15 @@ endif
#--------------------------------------------------------------------------------- #---------------------------------------------------------------------------------
all: sync-romfs $(BUILD) all: sync-romfs $(BUILD)
CACERT_URL := https://curl.se/ca/cacert.pem
CACERT_ROMFS := $(CURDIR)/$(ROMFS)/cacert.pem
sync-romfs: sync-romfs:
@mkdir -p $(CURDIR)/$(ROMFS) @mkdir -p $(CURDIR)/$(ROMFS)
@cp -f $(LOGO_SRC) $(CURDIR)/$(ROMFS)/logo.png @cp -f $(LOGO_SRC) $(CURDIR)/$(ROMFS)/logo.png
@if ! curl -sfL --time-cond $(CACERT_ROMFS) -o $(CACERT_ROMFS) $(CACERT_URL); then \
[ -f $(CACERT_ROMFS) ] || (echo "sync-romfs: failed to fetch cacert.pem" && exit 1); \
fi
$(BUILD): sync-romfs $(BUILD): sync-romfs
@[ -d $@ ] || mkdir -p $@ @[ -d $@ ] || mkdir -p $@
+1
View File
@@ -69,5 +69,6 @@ Manifest: `scripts/switch/ota_launcher.manifest`.
- Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device. - Zip extraction uses `switch-zziplib` (`ota_unzip.c`) on device.
- OTA replaces launcher + game from the install zip (NACP versions stay aligned). - OTA replaces launcher + game from the install zip (NACP versions stay aligned).
- HTTPS uses Mozilla CA bundle in romfs (`cacert.pem`, fetched at build time).
- Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md). - Sphaira HOME forwarders cache metadata until reinstalled (see docs/switch-install.md).
- Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker) - Release runner: `switch-dev` + (`install_devkitpro_deps.sh` **or** Docker)
+18 -3
View File
@@ -185,14 +185,29 @@ static int run_update_flow(const char *install_dir) {
} }
remove(extracted); remove(extracted);
int have_launcher = 0;
if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err, if (ota_unzip_extract_file(zip_path, LAUNCHER_MEMBER_IN_ZIP, extracted_launcher, err,
sizeof(err)) == 0 || sizeof(err)) == 0 ||
ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err, ota_unzip_extract_file(zip_path, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
sizeof(err)) == 0) { sizeof(err)) == 0) {
(void)ota_fs_atomic_replace_nro(install_dir, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err, have_launcher = 1;
sizeof(err));
remove(extracted_launcher);
} }
if (!have_launcher) {
remove(zip_path);
show_update_error("Could not extract",
"Update zip is missing launcher files.", err, installed);
return 0;
}
if (ota_fs_atomic_replace_nro(install_dir, OTA_LAUNCHER_NRO_NAME, extracted_launcher, err,
sizeof(err)) != 0) {
remove(extracted_launcher);
remove(zip_path);
show_update_error("Could not install",
"Game updated but launcher could not be replaced. Reinstall from the SD zip.",
err, installed);
return 0;
}
remove(extracted_launcher);
char vpath[192]; char vpath[192];
snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir); snprintf(vpath, sizeof(vpath), "%s/version.txt", install_dir);
+1 -2
View File
@@ -104,9 +104,8 @@ int ota_fs_atomic_replace_nro(const char *install_dir, const char *nro_name,
if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name); if (err && err_len) snprintf(err, err_len, "copy to .part failed (%s)", nro_name);
return -1; return -1;
} }
#ifdef _WIN32 /* sdmc/FAT (Switch) and Windows do not replace an existing dest on rename. */
remove(dest); remove(dest);
#endif
if (rename(part, dest) != 0) { if (rename(part, dest) != 0) {
if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name); if (err && err_len) snprintf(err, err_len, "rename .part -> %s failed", nro_name);
remove(part); remove(part);
+10 -4
View File
@@ -10,6 +10,14 @@
#endif #endif
#if defined(__SWITCH__) #if defined(__SWITCH__)
#define OTA_CA_BUNDLE "romfs:/cacert.pem"
static void ota_net_configure_tls(CURL *curl) {
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_CAINFO, OTA_CA_BUNDLE);
}
struct mem_buf { struct mem_buf {
char *data; char *data;
size_t len; size_t len;
@@ -76,8 +84,7 @@ int ota_net_download_buffer(const char *url, long timeout_ms, char **out, size_t
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_mem);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &mem);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); ota_net_configure_tls(curl);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
CURLcode rc = curl_easy_perform(curl); CURLcode rc = curl_easy_perform(curl);
curl_easy_cleanup(curl); curl_easy_cleanup(curl);
if (rc != CURLE_OK) { if (rc != CURLE_OK) {
@@ -122,8 +129,7 @@ int ota_net_download_file(const char *url, const char *path, long timeout_ms, ch
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); ota_net_configure_tls(curl);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
if (progress) { if (progress) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress); curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xfer_progress);
+7
View File
@@ -513,7 +513,11 @@ test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () =
assert.match(otaUi, /draw_text_wrapped_centered/); assert.match(otaUi, /draw_text_wrapped_centered/);
assert.match(otaUi, /ota_ui_alert_error/); assert.match(otaUi, /ota_ui_alert_error/);
assert.match(read('native/switch-ota-launcher/src/ota_net.c'), /XFERINFOFUNCTION|xfer_progress/); assert.match(read('native/switch-ota-launcher/src/ota_net.c'), /XFERINFOFUNCTION|xfer_progress/);
assert.match(read('native/switch-ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*1L/);
assert.match(read('native/switch-ota-launcher/src/ota_net.c'), /romfs:\/cacert\.pem/);
assert.doesNotMatch(read('native/switch-ota-launcher/src/ota_net.c'), /CURLOPT_SSL_VERIFYPEER,\s*0L/);
assert.match(read('native/switch-ota-launcher/Makefile'), /^ROMFS\s*:=/m); assert.match(read('native/switch-ota-launcher/Makefile'), /^ROMFS\s*:=/m);
assert.match(read('native/switch-ota-launcher/Makefile'), /cacert\.pem/);
const mk = read('native/switch-ota-launcher/Makefile'); const mk = read('native/switch-ota-launcher/Makefile');
assert.match(mk, /libnx\/switch_rules|DEVKITPRO/); assert.match(mk, /libnx\/switch_rules|DEVKITPRO/);
@@ -526,7 +530,10 @@ test('AC-010: Fonte do launcher e Makefile DEVKITPRO existem @spec:AC-010', () =
assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_unzip_extract_file/); assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_unzip_extract_file/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//); assert.match(read('native/switch-ota-launcher/src/main.c'), /GAME_MEMBER_IN_ZIP|switch\/gen1recomp\//);
assert.match(read('native/switch-ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_atomic_replace_nro/); assert.match(read('native/switch-ota-launcher/src/main.c'), /LAUNCHER_MEMBER_IN_ZIP|ota_fs_atomic_replace_nro/);
assert.match(read('native/switch-ota-launcher/src/main.c'), /ota_fs_atomic_replace_nro\([\s\S]*\) != 0/);
assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/); assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /ota_fs_atomic_replace_nro/);
assert.match(read('native/switch-ota-launcher/src/ota_fs.c'), /remove\(dest\)/);
assert.doesNotMatch(read('native/switch-ota-launcher/src/ota_fs.c'), /#ifdef _WIN32[\s\S]*remove\(dest\)/);
assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/); assert.doesNotMatch(read('scripts/switch/install_devkitpro_deps.sh'), /switch-minizip/);
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/); assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-zziplib/);
assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-dev/); assert.match(read('scripts/switch/install_devkitpro_deps.sh'), /switch-dev/);