From 31891d65edd23534a5508b3b1930233a45e0c7c5 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:04:05 -0400 Subject: [PATCH 1/2] fix(env): the app bundles the live fleet's CA and members, so a default install reaches the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A default install could not reach the beta fleet. The APK bundled dsm_client/frontend/public/ca.crt, a retired CA with the same subject (CN=DSM-Storage-CA) and a different key, and MainActivity re-materializes it over files/ca.crt on every launch, so rustls refused every member's certificate (UnknownIssuer, surfacing as "error sending request"). The bundled dsm_env_config.toml named the retired three-node Alibaba set with no register incarnations, which the loader refuses. Only a pushed developer override, with the CA under another file name, made the 2026-09-27 device run possible. - frontend/public/ca.crt: the fleet's CA (the issuer of every member's certificate; sha256 8a8b36a6…). - frontend/public/dsm_env_config.toml: the five members, their endpoints and their register incarnations as each logged at its first boot after the reprovision; custom_ca_certs names the bundled ca.crt. - android/app/src/main/dsm_env_config.toml deleted: the dead six-node fleet of 2026-03-31, read by nothing. - The fast-deploy scripts no longer describe the bundled fleet as six nodes. The bundled file is now checked through the loader's own parser, the catalog it builds and the canonical-set resolution every read uses: the_bundled_env_config_resolves_the_pinned_beta_set refuses a pair that does not re-derive core's pinned set, and the_bundled_env_config_names_a_ca_the_storage_client_builds_from builds the storage client from the CA the config names. Both went red under their mutation (a stale incarnation; the CA line removed) and green restored. Which CA issued the members' certificates is not decidable offline; the device run against the fleet is what showed this CA verifies them. --- .../android/app/src/main/dsm_env_config.toml | 41 ------------ .../dsm_sdk/src/network.rs | 2 +- .../dsm_sdk/src/sdk/storage_node_sdk.rs | 26 +++++++- .../dsm_sdk/src/sdk/storage_set.rs | 62 ++++++++++++++++--- dsm_client/frontend/public/ca.crt | 54 ++++++++-------- .../frontend/public/dsm_env_config.toml | 47 ++++++++++---- scripts/fast_deploy_android.sh | 2 +- scripts/fast_deploy_android_release.sh | 2 +- 8 files changed, 143 insertions(+), 93 deletions(-) delete mode 100644 dsm_client/android/app/src/main/dsm_env_config.toml diff --git a/dsm_client/android/app/src/main/dsm_env_config.toml b/dsm_client/android/app/src/main/dsm_env_config.toml deleted file mode 100644 index 064057c0b..000000000 --- a/dsm_client/android/app/src/main/dsm_env_config.toml +++ /dev/null @@ -1,41 +0,0 @@ -# DSM environment config — GCP storage nodes (6-node, 3-region) -# Push to device with: scripts/push_env_override.sh --gcp -# -# Nodes: -# us-east1: 34.73.141.32, 35.243.157.151 -# europe-west1: 35.205.9.157, 34.53.251.120 -# asia-southeast1: 34.21.157.56, 34.87.93.29 - -allow_localhost = false - -bitcoin_network = "signet" -dbtc_min_confirmations = 1 -mempool_api_url = "https://mempool.space" - -# Self-signed CA for GCP storage node TLS certs -# Path is relative to this config file's directory (resolved by SDK). -custom_ca_certs = ["ca.crt"] - -[[nodes]] -name = "us-east1-a" -endpoint = "https://34.73.141.32:8080" - -[[nodes]] -name = "us-east1-b" -endpoint = "https://35.243.157.151:8080" - -[[nodes]] -name = "europe-west1-a" -endpoint = "https://35.205.9.157:8080" - -[[nodes]] -name = "europe-west1-b" -endpoint = "https://34.53.251.120:8080" - -[[nodes]] -name = "asia-southeast1-a" -endpoint = "https://34.21.157.56:8080" - -[[nodes]] -name = "asia-southeast1-b" -endpoint = "https://34.87.93.29:8080" diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs index efa28e8d4..0945485d2 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/network.rs @@ -148,7 +148,7 @@ impl NetworkConfigLoader { } /// Parse TOML using serde for type safety and automatic deserialization. -fn parse_env_config_toml(toml_str: &str) -> Result { +pub(crate) fn parse_env_config_toml(toml_str: &str) -> Result { let mut config: EnvConfig = toml::from_str(toml_str).map_err(|e| { DsmError::serialization_error( "STRICT: failed to parse TOML env config", diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs index f3028534c..cdf5fa11d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_node_sdk.rs @@ -698,7 +698,31 @@ impl SetClient { #[cfg(test)] #[allow(clippy::disallowed_methods)] mod tests { - use super::{build_ca_aware_client, read_ca_certs, LatestByteCommit, MemberClient}; + use super::{ + build_ca_aware_client, build_client_from, read_ca_certs, CaMaterial, LatestByteCommit, + MemberClient, + }; + + /// The env config the app bundles names the CA bundled beside it, and the + /// storage client builds from that CA through the same reader and builder + /// every client is made by. (Which CA issued the fleet's certificates is + /// not decidable offline; a device run against the fleet is what shows the + /// bundled CA verifies them.) + #[test] + fn the_bundled_env_config_names_a_ca_the_storage_client_builds_from() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../frontend/public/dsm_env_config.toml" + ); + let certs = read_ca_certs(path).expect("the bundled CA is named and readable"); + assert_eq!(certs.len(), 1, "exactly the fleet's CA"); + assert!(certs[0].0.ends_with("ca.crt")); + build_client_from(&CaMaterial { + env_path: Some(path.to_string()), + certs, + }) + .expect("the storage client builds from the bundled CA"); + } /// A write the member did not take is not acknowledged. The same running /// node, reached at a path it does not serve, answers `404`: the put, the diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_set.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_set.rs index e8bec04e8..efb795859 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_set.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/storage_set.rs @@ -180,7 +180,13 @@ impl StorageSetCatalog { /// where `name` is the node's configured protocol identity (`node.id`) and /// `endpoint` its transport address. pub fn from_env_config() -> Result { - let env = crate::network::NetworkConfigLoader::load_env_config()?; + Self::from_env(crate::network::NetworkConfigLoader::load_env_config()?) + } + + /// The catalog a parsed env config states — the one [`Self::from_env_config`] + /// reads from the configured file, and the one the env config bundled into + /// the app states. + pub(crate) fn from_env(env: crate::network::EnvConfig) -> Result { let members: Vec = env .nodes .into_iter() @@ -245,14 +251,28 @@ impl StorageSetCatalog { /// Resolve the canonical register set for `network_id`, fail-closed, through /// the catalog (never `sole_set` — consumers RESOLVE). pub fn canonical_set(network_id: &[u8]) -> Result { - let profile = - dsm::economic::register::resolve_root_register_profile(network_id).map_err(|e| { - DsmError::storage( - format!("root register profile: {e}"), - None::, - ) - })?; - let catalog = StorageSetCatalog::from_env_config()?; + let profile = root_register_profile(network_id)?; + canonical_set_in(&profile, &StorageSetCatalog::from_env_config()?) +} + +/// The network's pinned root-register profile, fail-closed. +fn root_register_profile( + network_id: &[u8], +) -> Result { + dsm::economic::register::resolve_root_register_profile(network_id).map_err(|e| { + DsmError::storage( + format!("root register profile: {e}"), + None::, + ) + }) +} + +/// The set of `catalog` that re-derives `profile`'s pinned id, or the +/// fail-closed refusal. +fn canonical_set_in( + profile: &dsm::economic::register::RootRegisterProfile, + catalog: &StorageSetCatalog, +) -> Result { // The set id is a function of `(member_id, register_incarnation_id)` // pairs, so it cannot be asked for by name: the catalog offers candidates // and `verify_candidate` refuses any that does not re-derive the pinned id. @@ -313,6 +333,30 @@ mod tests { ); } + /// The env config the app bundles is what a default install reaches the + /// network with, so its `(member, incarnation)` pairs must be the pinned + /// beta set: a stale pair (a retired fleet, an incarnation from before a + /// reprovision) makes every read of the canonical set fail closed on a + /// fresh device, with nothing on screen but the intro. + #[test] + fn the_bundled_env_config_resolves_the_pinned_beta_set() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../frontend/public/dsm_env_config.toml" + ); + let text = std::fs::read_to_string(path).expect("the bundled env config"); + let env = crate::network::parse_env_config_toml(&text) + .expect("the bundled env config parses under the loader"); + let catalog = StorageSetCatalog::from_env(env).expect("its members form one set"); + let profile = root_register_profile(b"dsm-testnet").expect("the beta profile"); + let set = canonical_set_in(&profile, &catalog) + .expect("the bundled env config resolves the pinned beta set"); + assert_eq!( + crate::util::text_id::encode_base32_crockford(&set.id()), + "7GBBB51DM8XP433F6H896G4R88W6RJZATZ3CT0WTRAFZ2EHYZ9D0" + ); + } + #[test] fn beta_root_register_pins_render_to_the_logged_values() { let logged = [ diff --git a/dsm_client/frontend/public/ca.crt b/dsm_client/frontend/public/ca.crt index 08f99f8ca..73380a3d1 100644 --- a/dsm_client/frontend/public/ca.crt +++ b/dsm_client/frontend/public/ca.crt @@ -1,31 +1,31 @@ -----BEGIN CERTIFICATE----- -MIIFSTCCAzGgAwIBAgIURsiHUZIdwJgdYSZy1dBcNF5dV0gwDQYJKoZIhvcNAQEL +MIIFSTCCAzGgAwIBAgIUOeBVAVMR2zmh4EtFCeJYmocSQ68wDQYJKoZIhvcNAQEL BQAwNDELMAkGA1UEBhMCWFgxDDAKBgNVBAoMA0RTTTEXMBUGA1UEAwwORFNNLVN0 -b3JhZ2UtQ0EwHhcNMjYwNzA0MTUzODI2WhcNMzYwNzAxMTUzODI2WjA0MQswCQYD +b3JhZ2UtQ0EwHhcNMjYwOTEyMDM1NTAwWhcNMzYwOTA5MDM1NTAwWjA0MQswCQYD VQQGEwJYWDEMMAoGA1UECgwDRFNNMRcwFQYDVQQDDA5EU00tU3RvcmFnZS1DQTCC -AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJj7zGFmO4T/f8yf8Zscg5u+ -PZgH6QE6dnFmbrXXPuT+Gu6qfrcccAsTd55V9pG+xTRIPEXdak09RIf8MWdKX2BC -kf9orsqy9iXa0fb8RlBnR7NIG/cytv1ICFHYfRPlYimf5uWNqlnppGpBP5Fnkwty -XPUHJlBIw0yoAvZCIV4Ve/7g8lZsg7fhVsQitttBH0GZU91Q7aZCADwVSKx4bbBi -+gazgrfc+e1Eg7xtNs9wnwcL/Qi00VtGw0bAMn6LnpGBACvL2fjhbFKrYSrpixLF -9RvKwPQEK7AYUJpMz937twO12ogstkrLF2+8yXlDLWfYook+BFjpydP7F1C0m9xV -l7zqkBDZrqhqDT4C1Qg8zQiri+0VMpjo3HKwLhfo5HV17SwxR7Z7nY5qtcoI0YSW -s0IDk8V+jrRyi39W97XYyHFnNxdpEG+VTx8Te13NBra5cYBu99ojGk+T8zY+khpI -XwWgfjsbIGOfm5Hqd9wgVT4ZqZGRPeoUjzVhzMIGiid/lKU2jkTbZqQwC3r0fBcJ -8/TTDq8c6C/1rBbo7ifrBXP/zeYZlvHs0v+NISaLkA6xdtoElJjWIL9Xzr2Mz+vm -YODHOoXWzDhsR3z+tPn8xFrtTMcnQkoFeoRLm4csC6/MhqsmoC8biVUeJvZ1/rjo -bJ0yxeyBJUlBdnCLzn7NAgMBAAGjUzBRMB0GA1UdDgQWBBSgUZ8P3Ia5N5YyIZxo -/4ht553DqjAfBgNVHSMEGDAWgBSgUZ8P3Ia5N5YyIZxo/4ht553DqjAPBgNVHRMB -Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBNq1iJt/16HL0EmINj3ey0W8tb -fS7TvuKSJOJw430NmOgfh4U8NiRm0y8gURztJiwKfpidiZZzYKiuQk2sTKH1WZv+ -EB0eDPFU0NRJCK6wPWHt/5lU38tfLtep0fFOY0ZfX4/cdafP32WQol2Ihi9GqDlm -fJVI8AL/F0Tx2jb+oAcb4WOhv20Q4hmpK2m0eDly/cvu6slRC4NLojGftJ0KXeph -ehBHwsn+uqclrkHWXti4DJjTHrYEY16p7mCVfz6FpQGqYom4dJjmK3wO5pCD2d8R -4FrkalDWJT6DFSvDR2wfcId0W7ZlKwRTTCjc2kIYXR0SFB4lSsbLE4WRTKrAnVZX -WiNIKMkxTMoEqeoF7v6gyyy/r2e5eeo8ZZTuYOaSTXmM60qCcWwtd0G1VvN7DODk -zW9H6RmS1FWm3WPPUgMU7pYu4Z9P6gCW1dkQjs/NNr4qmf47PbjvQXikGe4OeNMT -txFxbqTKwpPdfnGvj1H/QHBN9XbcWl1B+/WNKLGRT7tRnwdZ8HRMss7sS449yrXR -L9Fxex2b9f3dyJKgxDus+Gsqtpy1iq/iWEIwtt88QXJ629mI+a+a4xn+IUkBugnj -YE2KOj3xslUvoWOnD4JI5ZDrh22TneZpahdfEDJu9AiPELJTBXQZZEgwKfpn+UGW -zX0igzm3udg5ZeYJqQ== +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJkIYRepQO/Q/VnpCJqnxtsA +kGGRqJh48ehyAfPgWfH1Dv9A/o283qQqZSc+DdEKueArz5e0mqJKdC1/mxxmMX7E +2iGN4+BrgpKrntSRatuxdY6Xq/heFjmErCiuTpAtnwP22S7jeOxzdXefpEfN4vlF +gmpGXbrTvfRU/ELAAPgxTgEz0XTvps/ma4uZlHaB3HX8dw8+w6keeeyimVBYoxLz +Nu/LRLBQGkxd/Uo8kUF4dDs+8hI83J5paLYeIHHAsngFEnR0NybnMNVDlEKOmYmF +z45U1/f6qHFF5XANaaJ1x/hD/0hcIhbTCYhA3vHJvp/oIYTtWd2o6JYqlse0TfjY +39vQ8h9XXP/MJQ0hCcb3dZL38DxJn7UMfMOjrIFwFg66cnGIVlKL4qCaSdSpDMDQ +GBjOa7kZvl/CcMfmPBdRtpTVbzipTdfs95S79UdAumdtIOlwen+wkKr75N1ShARi +2VvNm9ufhU1bDBREM6OMFk3rSMXRus8RtknJ+J9JPmbZPQDHgHXTtQn0pE6m6xRu +8Uyu0csZYc4tHcOoVqsCrDDoyMcSHqIc6l9dqq1A5ce8rUNIpPf172lDchHZze4A +yqcAEtxBMFJMA6kSNqM+Cn7NniO2CQWStDd4SG3EDLIYbhZo4UcS47fNDQRQl5ja +fGzCvqfG0owKShKKVRSpAgMBAAGjUzBRMB0GA1UdDgQWBBTg35BVePyiQ3JV0ImJ +2e8VEFx9GDAfBgNVHSMEGDAWgBTg35BVePyiQ3JV0ImJ2e8VEFx9GDAPBgNVHRMB +Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAnIshWgVnk1T9EKWOKK1QVUki2 +BrY18NxSUNoQHGhNIECL0ZzlUOIY+loVTv5sHCWZJbcvaOHhIdHUhAmSnG2iFG+z +Ae37NaLy0SwlGXeYxFskZoGcbgtDyikCiw24XJ/J7v1qvnXopMavKGColurH7OeB +76s4a5SH4i0pDc6JzSTOd6GPXl7NqDxufd8o9fVc/SWIEjTcJ4HailHtq3cv1bRN +Nv14v5LH1//aQpcfceRdWeAZHFeRCRwlB4cQL5Cs/nKvEKEhNDTxgc1qrL9uOMd+ +nEfiMXkFWa6OGbsZFkoGWEqmMbOrJheBDHwQTzs6r/Wz2Vt1OIrfxOS3+V+OKeEy +9E1dmctUOOdhgBaYchWwFXlr/mZcpefVNrVqY0pyPb7kV1P5qeOn7P/21fRYbnCH ++SNF7/dUHwvVmd+2R1aDRP5agBnZytex+T8RaQ3b8rvRD5ewjG6tgQ9TuzZ8Q1dK ++hbNa+FXoaKQbPfGH6IafKuQEuW08Um8Dl2Ut+T91TtZnGnU400Z39cHBdOXDUZW +SFzTOm0w2cIj4vFXEI/cRq2VaxBWfUAEANVvzHPOy2cpRldrBcyOKSIsQI0cNjYE +iWL9McPQJe5RpaKfQfCEWNT17E2v/YbkAGq2rkeYvGJ3t7ObNTx64jAckmstKu9Z +z5YOPTWcM5j1FJxV0w== -----END CERTIFICATE----- diff --git a/dsm_client/frontend/public/dsm_env_config.toml b/dsm_client/frontend/public/dsm_env_config.toml index d4a3eb0a8..e88e20679 100644 --- a/dsm_client/frontend/public/dsm_env_config.toml +++ b/dsm_client/frontend/public/dsm_env_config.toml @@ -1,8 +1,14 @@ -# DSM environment config — Alibaba Cloud storage nodes (3-node, us-west-1) -# Push to device with: scripts/push_env_override.sh --alibaba +# DSM environment config — the beta storage fleet (5 nodes, GCP us-central1). +# Bundled into the APK: this file and ca.crt beside it are what a default +# install reaches the network with. # -# Nodes (ECS, VPC vpc-rj9oepiel1c2517avaboj, sg dsm-storage-sg): -# us-west-1a: 47.251.246.93, 47.251.250.159, 47.251.88.58 +# The fleet was reprovisioned 2026-09-27 on the storage node at main f779cb872; +# every member minted a new register incarnation into its fresh database, and +# the committed set id every member logs is +# 7GBBB51DM8XP433F6H896G4R88W6RJZATZ3CT0WTRAFZ2EHYZ9D0 +# which is the id core pins (dsm::economic::register), so this list must stay +# exactly the pinned pairs: `dsm_sdk::sdk::storage_set::tests:: +# the_bundled_env_config_resolves_the_pinned_beta_set` refuses anything else. allow_localhost = false @@ -10,8 +16,10 @@ bitcoin_network = "signet" dbtc_min_confirmations = 1 mempool_api_url = "https://mempool.space" -# Self-signed CA for Alibaba storage node TLS certs -# Path is relative to this config file's directory (resolved by SDK). +# The fleet's CA (CN=DSM-Storage-CA): every member serves a certificate for +# its IP and CN=dsm-node-N issued by it. Path is relative to this config +# file's directory (resolved by the SDK); the app installs the bundled ca.crt +# next to the config. custom_ca_certs = ["ca.crt"] # `name` IS THE MEMBER ID OF THE CANONICAL STORAGE SET, not a label. It is @@ -20,17 +28,32 @@ custom_ca_certs = ["ca.crt"] # toward quorum when the node echoes this exact string back as x-dsm-node-id. # # So each name MUST equal that node's own `[node] id`, which is also the CN of -# the TLS certificate it serves. A name that merely describes the node -# ("us-west-1a-1") makes the client compute a set id no node belongs to: every -# settlement-slot claim is refused as a foreign set. +# the TLS certificate it serves. `register_incarnation` is the register history +# that member serves, as its own database minted it at first boot and logged +# it; the set id is derived from the (name, register_incarnation) pairs, so a +# stale incarnation makes the client compute a set id no member belongs to and +# every read of the canonical set fails closed. [[nodes]] name = "dsm-node-1" -endpoint = "https://47.251.246.93:8080" +endpoint = "https://34.58.75.224:8080" +register_incarnation = "B6DZ4TFJ2Y1GSJ8X57CE8BWX0QM5JRVAJP08JRQTV65DEQ53DYKG" [[nodes]] name = "dsm-node-2" -endpoint = "https://47.251.250.159:8080" +endpoint = "https://35.254.209.202:8080" +register_incarnation = "QYR6K65CD8SZ40G4PZV4PMS2R0VK02N14TECZ0GP00ZR9VCSYC20" [[nodes]] name = "dsm-node-3" -endpoint = "https://47.251.88.58:8080" +endpoint = "https://146.148.99.141:8080" +register_incarnation = "2AJ8GZ4QM7YH7D5G552EAHSPTSTWG9KXE04RTBKYN8ES17Y9BTWG" + +[[nodes]] +name = "dsm-node-4" +endpoint = "https://104.197.96.66:8080" +register_incarnation = "S95VHW0YZ413DA4YX972ZA9XDA69FXF4KBCG2AHG89DA8PTY200G" + +[[nodes]] +name = "dsm-node-5" +endpoint = "https://35.184.46.44:8080" +register_incarnation = "56VB5TY2G8QKVJNVFKJQX9N0GHJN6VEQ1KV9E88GX272V8G43RWG" diff --git a/scripts/fast_deploy_android.sh b/scripts/fast_deploy_android.sh index 6f40806ee..e369e2927 100755 --- a/scripts/fast_deploy_android.sh +++ b/scripts/fast_deploy_android.sh @@ -86,7 +86,7 @@ if [[ $LOCAL_DEV -eq 1 ]]; then echo "" fi else - echo "[fast_deploy] GCP mode: using bundled dsm_env_config.toml (6 GCP nodes, no adb reverse)" + echo "[fast_deploy] GCP mode: using bundled dsm_env_config.toml (the beta fleet: 5 GCP nodes, no adb reverse)" fi for d in "${DEVICES[@]}"; do diff --git a/scripts/fast_deploy_android_release.sh b/scripts/fast_deploy_android_release.sh index c34d36812..ee09eef2b 100755 --- a/scripts/fast_deploy_android_release.sh +++ b/scripts/fast_deploy_android_release.sh @@ -173,7 +173,7 @@ if [[ $LOCAL_DEV -eq 1 ]]; then echo "" fi else - echo "[fast_deploy_release] GCP mode: using bundled dsm_env_config.toml (6 GCP nodes)" + echo "[fast_deploy_release] GCP mode: using bundled dsm_env_config.toml (the beta fleet: 5 GCP nodes)" fi for d in "${DEVICES[@]}"; do From 00155d13abb542272189bb68093ada04aa580507 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:39:52 -0400 Subject: [PATCH 2/2] fix(frontend): the APK's network config and CA come from public/ on every build, checked byte for byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled dsm_env_config.toml and ca.crt reached the APK by a two-step copy: webpack copied public/ into dist/, and copy-to-android.js copied dist/ into the git-ignored Android assets directory. A build on 2026-09-26 packaged dist/'s previous copy of both, the retired CA and the retired fleet, while public/ already held the new ones. copy-to-android.js also kept those two files across builds and carried a comment calling the ignored assets copy the "single source of truth", which every build overwrites. - copy-to-android.js regenerates the assets directory with nothing kept, then writes both files straight from public/, after everything else, and refuses to finish unless each copy equals its source byte for byte. - validate-android-assets.js requires both files to be the tracked public/ copies, and now runs in build:full-deploy, so make android fails rather than package another CA. Its js/runtime and js/vendors requirements described the web build; the Android build emits one entry chunk (splitChunks and runtimeChunk are off for it), so it failed on every Android build and ran nowhere. - MainActivity's comment no longer calls ca.crt the AWS nodes' CA. Checked: with the old CA and the Alibaba config planted in dist/, the copy step still wrote the tracked files; a tampered assets ca.crt fails the validator; make android with the webpack cache warm packaged CA 8a8b36a6… and the five-member config. --- .../java/com/dsm/wallet/ui/MainActivity.kt | 2 +- dsm_client/frontend/package.json | 2 +- .../frontend/scripts/copy-to-android.js | 33 +++++++++++++++---- .../scripts/validate-android-assets.js | 25 ++++++++++++-- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt index 63af48cf9..976e5d440 100644 --- a/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt +++ b/dsm_client/android/app/src/main/java/com/dsm/wallet/ui/MainActivity.kt @@ -1625,7 +1625,7 @@ class MainActivity : AppCompatActivity(), NfcAdapter.ReaderCallback { val outFile = File(files, assetName) // Materialize bundled assets that the SDK reads from filesDir. - // ca.crt is the self-signed CA for AWS storage node TLS certs. + // ca.crt is the storage fleet's CA, the one the bundled env config names. materializeAssetIfBundled("ca.crt", files) fun firstExisting(vararg candidates: File?): File? = candidates.firstOrNull { it != null && it.exists() } diff --git a/dsm_client/frontend/package.json b/dsm_client/frontend/package.json index d9a3a08ab..dacdc45c4 100644 --- a/dsm_client/frontend/package.json +++ b/dsm_client/frontend/package.json @@ -14,7 +14,7 @@ "proto:gen": "npx protoc --plugin=protoc-gen-es=./node_modules/.bin/protoc-gen-es --es_out=import_extension=.js:./src/proto --es_opt=target=ts -I ../../proto ../../proto/dsm_app.proto", "copy:android": "node scripts/copy-to-android.js", "build:android": "cd ../android && ./gradlew assembleDebug", - "build:full-deploy": "npm run build:android-webpack && npm run copy:android", + "build:full-deploy": "npm run build:android-webpack && npm run copy:android && npm run validate:android-assets", "clean:android": "rimraf ../android/app/src/main/assets/*", "test:device": "./test-device.sh", "build:nuclear": "node scripts/build-and-deploy-nuclear.js", diff --git a/dsm_client/frontend/scripts/copy-to-android.js b/dsm_client/frontend/scripts/copy-to-android.js index 481a8eafa..4a6cd2071 100644 --- a/dsm_client/frontend/scripts/copy-to-android.js +++ b/dsm_client/frontend/scripts/copy-to-android.js @@ -19,13 +19,12 @@ if (!fs.existsSync(targetDir)) { console.log('Created Android assets directory'); } -// Remove stale assets (but preserve whitelisted files such as env configs) -const whitelist = new Set(['dsm_env_config.toml', 'ca.crt']); -console.log('Cleaning existing Android assets (preserving whitelist)...'); +// The Android assets directory is regenerated on every build: nothing in it +// is kept from a previous build, so nothing stale can be packaged. +console.log('Cleaning existing Android assets...'); try { const existing = fs.readdirSync(targetDir); existing.forEach((name) => { - if (whitelist.has(name)) return; // keep whitelisted files const full = path.join(targetDir, name); try { const stat = fs.lstatSync(full); @@ -90,5 +89,27 @@ if (fs.existsSync(overlayDir)) { copyRecursive(overlayDir, targetDir); } -// Single source of truth for config: android/app/src/main/assets/dsm_env_config.toml. -// Do NOT add code that copies over that file — it will break allow_localhost. +// The network config and the fleet's CA the app ships with. Their one tracked +// source is frontend/public/; they are written here straight from it, after +// everything else, and checked byte for byte. They are not taken from dist/: +// a build once packaged dist/'s previous copy of both while public/ held the +// new ones, and the app then trusted a retired CA and a retired fleet. For a +// local fleet, push an override config to the device +// (scripts/push_env_override.sh); do not edit the copies here — every build +// rewrites them. +const RUNTIME_CONFIG = ['dsm_env_config.toml', 'ca.crt']; +const publicDir = path.join(__dirname, '..', 'public'); +for (const name of RUNTIME_CONFIG) { + const source = path.join(publicDir, name); + const target = path.join(targetDir, name); + if (!fs.existsSync(source)) { + console.error(`Error: ${source} is missing; the app cannot reach the network without it`); + process.exit(1); + } + fs.copyFileSync(source, target); + if (!fs.readFileSync(source).equals(fs.readFileSync(target))) { + console.error(`Error: ${target} does not equal ${source} after copying`); + process.exit(1); + } + console.log(`OK: ${name} written from public/`); +} diff --git a/dsm_client/frontend/scripts/validate-android-assets.js b/dsm_client/frontend/scripts/validate-android-assets.js index b07b0591a..c3eda3f04 100644 --- a/dsm_client/frontend/scripts/validate-android-assets.js +++ b/dsm_client/frontend/scripts/validate-android-assets.js @@ -11,9 +11,10 @@ const ASSETS_DIR = path.resolve(__dirname, '../../android/app/src/main/assets'); const REQUIRED = [ 'index.html', - 'js/main', // prefix match (hashed filename) - 'js/runtime', - 'js/vendors', + // prefix match (hashed filename). The Android build emits one entry chunk: + // webpack.config.js sets splitChunks and runtimeChunk off for it, so there + // is no js/runtime or js/vendors to find (those are the web build's). + 'js/main', 'css/main', 'config/app.json', 'images/logos/era_token_gb.gif', @@ -52,6 +53,24 @@ for (const item of REQUIRED) { } } +// The network config and the fleet's CA must be exactly the tracked files in +// frontend/public/: a copy that differs by one byte means the APK would ship a +// fleet or a CA nobody committed. +const PUBLIC_DIR = path.resolve(__dirname, '../public'); +for (const name of ['dsm_env_config.toml', 'ca.crt']) { + const shipped = path.join(ASSETS_DIR, name); + const tracked = path.join(PUBLIC_DIR, name); + if (!fs.existsSync(shipped) || !fs.existsSync(tracked)) { + console.error(`Error: ${name} is missing from ${fs.existsSync(shipped) ? PUBLIC_DIR : ASSETS_DIR}`); + ok = false; + } else if (!fs.readFileSync(shipped).equals(fs.readFileSync(tracked))) { + console.error(`Error: ${shipped} is not the tracked ${tracked}`); + ok = false; + } else { + console.log(`OK: ${name} is the tracked public/${name}`); + } +} + if (!ok) { console.error(`\nAsset validation failed in ${ASSETS_DIR}`); process.exit(1);