diff --git a/AndroidManifest.xml b/AndroidManifest.xml index c4d0dec..4ec0892 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ diff --git a/README.md b/README.md index ceaeb17..8b2c6d2 100644 --- a/README.md +++ b/README.md @@ -16,51 +16,21 @@ Sign in with your username and password, or tick "Use an API key instead". It wo ## HTTPS (from outside your home) -Android 2.3 only speaks **TLS 1.0**, which modern servers refuse. So HTTPS needs one change on the server and is handled by the app from there. - -### Server: a legacy HTTPS port - -Add a second `server` block to nginx, on its own port, used only by the phone. Your normal HTTPS site stays strict. - -```nginx -server { - listen 8443 ssl; - server_name abs.example.com; - ssl_certificate /etc/letsencrypt/live/abs.example.com/fullchain.pem; # must be an RSA certificate - ssl_certificate_key /etc/letsencrypt/live/abs.example.com/privkey.pem; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - # @SECLEVEL=0 is required: OpenSSL 3 refuses TLS 1.0 without it - ssl_ciphers "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-SHA:AES128-SHA:@SECLEVEL=0"; - ssl_prefer_server_ciphers on; - client_max_body_size 0; - location / { - proxy_pass http://127.0.0.1:13378; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_http_version 1.1; - proxy_buffering off; - } -} -``` - -Then sign in with `https://abs.example.com:8443`. - -- **RSA certificate:** Android 2.3 can't use ECDSA certificates. With certbot, request one with `--key-type rsa`. -- **Security:** TLS 1.0 with these ciphers is weak by today's standards, but much better than plain HTTP over the internet. Only open this port if you need it. -- **Caddy** can't serve TLS 1.0; put nginx (or HAProxy) in front for this port. +Android 2.3 only speaks TLS 1.0, which modern servers refuse, so since 1.7 MiniABS brings its own +**TLS 1.2** (Bouncy Castle, the same client as Minamp, with Mozilla's root certificates, +keep-alive and session resumption). Sign in with your server's normal `https://` address, at +home or away; nothing changes on the server. (A legacy TLS 1.0 port set up for older versions +keeps working too.) ### App: certificates -- **Built-in roots:** MiniABS trusts what the phone trusts, plus modern root certificates the phone's 2011-era list lacks: Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL, DigiCert G2, Amazon and others. Most certificates just work. +- **Built-in roots:** MiniABS checks certificates against Mozilla's current root list (`assets/roots`), so Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL and the rest just work. - **Anything else** (self-signed, a private CA, a new root): MiniABS shows the certificate's SHA-256 fingerprint and asks once. Compare it with the fingerprint your computer's browser shows for the site before tapping Trust. It then trusts the CA that issued it, so renewals keep working. - **Settings → Certificates you trusted** lets you forget them. ### App: streaming -Android 2.x's media player can't stream https:// URLs, so for HTTPS servers MiniABS runs a small proxy on the phone (127.0.0.1 only, random unguessable URLs). It forwards seeks and gets a fresh sign-in token for each request, so long episodes don't break when the token expires. +Android 2.x's media player can't stream modern https:// URLs, so for HTTPS servers MiniABS runs a small proxy on the phone (127.0.0.1 only, random unguessable URLs). It forwards seeks and gets a fresh sign-in token for each request, so long episodes don't break when the token expires. ## Using it @@ -86,7 +56,7 @@ Playback opens an Audiobookshelf session and syncs every 15 seconds and on pause ## Building -Needs a JDK, `aapt`, `dalvik-exchange` (dx), `zipalign`, `apksigner` (Ubuntu: `apt install aapt dalvik-exchange zipalign apksigner`) and an API 8 `android.jar`: +Needs a JDK, `aapt`, `dalvik-exchange` (dx), `zipalign`, `apksigner` (Ubuntu: `apt install aapt dalvik-exchange zipalign apksigner`), an API 8 `android.jar`, and the renamed Bouncy Castle sources from Minamp's `tools/setup_toolchain.sh` (`BC_SRC`, default `~/android-toolchain/bcsrc`): ANDROID_JAR=/path/to/android-8/android.jar ./build.sh @@ -94,6 +64,6 @@ Compiling against the API 8 jar guarantees no newer Android APIs are used. The A All sizes (text, rows, buttons, covers) are in `res/values/dimens.xml`; change them there and rebuild. -`tools/gen_roots.py` regenerates the bundled root certificates from a system CA store. +`src/com/miniabs/net/` is the bundled HTTP/TLS client shared with Minamp, FreshMini and GingerCal; `assets/roots/` holds Mozilla's roots (regenerate with Minamp's `tools/make_roots.py`). `test/run_tests.sh` runs the API, JSON, progress, HTTPS-trust and streaming-proxy code on the desktop JVM against a mock Audiobookshelf server (`test/mock_abs.py`), over plain HTTP and over TLS with good, expired, wrong-host, forged and self-signed certificates (`test/make_pki.sh`). diff --git a/assets/roots/032d371dbf31afaf7ded8c4817d515f8152ed5a6-0.der b/assets/roots/032d371dbf31afaf7ded8c4817d515f8152ed5a6-0.der new file mode 100644 index 0000000..47bfc6f Binary files /dev/null and b/assets/roots/032d371dbf31afaf7ded8c4817d515f8152ed5a6-0.der differ diff --git a/assets/roots/03eb97b4e8a522a943a90d3301b19f99a8e6fcaf-0.der b/assets/roots/03eb97b4e8a522a943a90d3301b19f99a8e6fcaf-0.der new file mode 100644 index 0000000..cb9a586 Binary files /dev/null and b/assets/roots/03eb97b4e8a522a943a90d3301b19f99a8e6fcaf-0.der differ diff --git a/assets/roots/0c0ed0137e724ee890170d0a0797ca406897614d-0.der b/assets/roots/0c0ed0137e724ee890170d0a0797ca406897614d-0.der new file mode 100644 index 0000000..1dd645c Binary files /dev/null and b/assets/roots/0c0ed0137e724ee890170d0a0797ca406897614d-0.der differ diff --git a/assets/roots/0f4efcf05175b60e4914344f85fcf18b3f826fd8-0.der b/assets/roots/0f4efcf05175b60e4914344f85fcf18b3f826fd8-0.der new file mode 100644 index 0000000..c4347d3 Binary files /dev/null and b/assets/roots/0f4efcf05175b60e4914344f85fcf18b3f826fd8-0.der differ diff --git a/assets/roots/0f5b4ff6887b4722adb4859609e6a925212174e0-0.der b/assets/roots/0f5b4ff6887b4722adb4859609e6a925212174e0-0.der new file mode 100644 index 0000000..7031f88 Binary files /dev/null and b/assets/roots/0f5b4ff6887b4722adb4859609e6a925212174e0-0.der differ diff --git a/assets/roots/108abf716ef774bd28f3e2cd3949cc2eaa52316a-0.der b/assets/roots/108abf716ef774bd28f3e2cd3949cc2eaa52316a-0.der new file mode 100644 index 0000000..a0cd3ec Binary files /dev/null and b/assets/roots/108abf716ef774bd28f3e2cd3949cc2eaa52316a-0.der differ diff --git a/assets/roots/1336799a38ebd57730aa49f89be555490ab47bf8-0.der b/assets/roots/1336799a38ebd57730aa49f89be555490ab47bf8-0.der new file mode 100644 index 0000000..ae5ae35 Binary files /dev/null and b/assets/roots/1336799a38ebd57730aa49f89be555490ab47bf8-0.der differ diff --git a/assets/roots/14146f28d9038bfbf04a2093c205456baf4034b2-0.der b/assets/roots/14146f28d9038bfbf04a2093c205456baf4034b2-0.der new file mode 100644 index 0000000..96e02ca Binary files /dev/null and b/assets/roots/14146f28d9038bfbf04a2093c205456baf4034b2-0.der differ diff --git a/assets/roots/14c0f884650ebfb1564d3da153a0d7f1c7c670a8-0.der b/assets/roots/14c0f884650ebfb1564d3da153a0d7f1c7c670a8-0.der new file mode 100644 index 0000000..cf6588c Binary files /dev/null and b/assets/roots/14c0f884650ebfb1564d3da153a0d7f1c7c670a8-0.der differ diff --git a/assets/roots/17a7d72a82ce8c8116af6a12f0701505259f816d-0.der b/assets/roots/17a7d72a82ce8c8116af6a12f0701505259f816d-0.der new file mode 100644 index 0000000..f63cc7f Binary files /dev/null and b/assets/roots/17a7d72a82ce8c8116af6a12f0701505259f816d-0.der differ diff --git a/assets/roots/1ca566add30938e8135505ffd08b5a70c488b73b-0.der b/assets/roots/1ca566add30938e8135505ffd08b5a70c488b73b-0.der new file mode 100644 index 0000000..d1531f6 Binary files /dev/null and b/assets/roots/1ca566add30938e8135505ffd08b5a70c488b73b-0.der differ diff --git a/assets/roots/1d236f8e064b971fdddfa11523e1aee502be2b1b-0.der b/assets/roots/1d236f8e064b971fdddfa11523e1aee502be2b1b-0.der new file mode 100644 index 0000000..3cdad66 Binary files /dev/null and b/assets/roots/1d236f8e064b971fdddfa11523e1aee502be2b1b-0.der differ diff --git a/assets/roots/20dd24a4280ef8840064866941b494f3f30de194-0.der b/assets/roots/20dd24a4280ef8840064866941b494f3f30de194-0.der new file mode 100644 index 0000000..7508480 Binary files /dev/null and b/assets/roots/20dd24a4280ef8840064866941b494f3f30de194-0.der differ diff --git a/assets/roots/220f44b97081f27eb2d32757ef0242567e51fed9-0.der b/assets/roots/220f44b97081f27eb2d32757ef0242567e51fed9-0.der new file mode 100644 index 0000000..fed8dab Binary files /dev/null and b/assets/roots/220f44b97081f27eb2d32757ef0242567e51fed9-0.der differ diff --git a/assets/roots/229a8437e3d9edafce3dc9904897b48fdeb436d3-0.der b/assets/roots/229a8437e3d9edafce3dc9904897b48fdeb436d3-0.der new file mode 100644 index 0000000..6eb01cc Binary files /dev/null and b/assets/roots/229a8437e3d9edafce3dc9904897b48fdeb436d3-0.der differ diff --git a/assets/roots/254d769250bbe91e2178c18a2240319eaf17e365-0.der b/assets/roots/254d769250bbe91e2178c18a2240319eaf17e365-0.der new file mode 100644 index 0000000..354b346 Binary files /dev/null and b/assets/roots/254d769250bbe91e2178c18a2240319eaf17e365-0.der differ diff --git a/assets/roots/262175b10d1adadd3525515deb67dda91edf2154-0.der b/assets/roots/262175b10d1adadd3525515deb67dda91edf2154-0.der new file mode 100644 index 0000000..5a3dc50 Binary files /dev/null and b/assets/roots/262175b10d1adadd3525515deb67dda91edf2154-0.der differ diff --git a/assets/roots/281aea4e6a11200e3949b766237385489c2e8792-0.der b/assets/roots/281aea4e6a11200e3949b766237385489c2e8792-0.der new file mode 100644 index 0000000..9d2132e Binary files /dev/null and b/assets/roots/281aea4e6a11200e3949b766237385489c2e8792-0.der differ diff --git a/assets/roots/2c7a348b4c3fe39c4b2f2a1919788781b2fde745-0.der b/assets/roots/2c7a348b4c3fe39c4b2f2a1919788781b2fde745-0.der new file mode 100644 index 0000000..2913bb0 Binary files /dev/null and b/assets/roots/2c7a348b4c3fe39c4b2f2a1919788781b2fde745-0.der differ diff --git a/assets/roots/3053402c7b5dbe592758be81f8b1a0c89bd9db40-0.der b/assets/roots/3053402c7b5dbe592758be81f8b1a0c89bd9db40-0.der new file mode 100644 index 0000000..45ac938 Binary files /dev/null and b/assets/roots/3053402c7b5dbe592758be81f8b1a0c89bd9db40-0.der differ diff --git a/assets/roots/3091c2d60f82ea189edb13b0755d9b046e67f050-0.der b/assets/roots/3091c2d60f82ea189edb13b0755d9b046e67f050-0.der new file mode 100644 index 0000000..e193a05 Binary files /dev/null and b/assets/roots/3091c2d60f82ea189edb13b0755d9b046e67f050-0.der differ diff --git a/assets/roots/31e4c31bde81d32ad0d8ecacd40c3da4e71aeb38-0.der b/assets/roots/31e4c31bde81d32ad0d8ecacd40c3da4e71aeb38-0.der new file mode 100644 index 0000000..f81b63a Binary files /dev/null and b/assets/roots/31e4c31bde81d32ad0d8ecacd40c3da4e71aeb38-0.der differ diff --git a/assets/roots/322ad42b3fe752585a7c1435fd71b9763f2c7bd9-0.der b/assets/roots/322ad42b3fe752585a7c1435fd71b9763f2c7bd9-0.der new file mode 100644 index 0000000..a8b2d8e Binary files /dev/null and b/assets/roots/322ad42b3fe752585a7c1435fd71b9763f2c7bd9-0.der differ diff --git a/assets/roots/3366ae4363646a8c15060a3b4adff12e8c1809af-0.der b/assets/roots/3366ae4363646a8c15060a3b4adff12e8c1809af-0.der new file mode 100644 index 0000000..02ab364 Binary files /dev/null and b/assets/roots/3366ae4363646a8c15060a3b4adff12e8c1809af-0.der differ diff --git a/assets/roots/3582a277b57e01b32d31d926b4f7b4366493d235-0.der b/assets/roots/3582a277b57e01b32d31d926b4f7b4366493d235-0.der new file mode 100644 index 0000000..62d354a Binary files /dev/null and b/assets/roots/3582a277b57e01b32d31d926b4f7b4366493d235-0.der differ diff --git a/assets/roots/36341f8404085341117cfe8fc657667384b14135-0.der b/assets/roots/36341f8404085341117cfe8fc657667384b14135-0.der new file mode 100644 index 0000000..e5e2343 Binary files /dev/null and b/assets/roots/36341f8404085341117cfe8fc657667384b14135-0.der differ diff --git a/assets/roots/369df24e274d52c0b46a58590043b883b4f6b096-0.der b/assets/roots/369df24e274d52c0b46a58590043b883b4f6b096-0.der new file mode 100644 index 0000000..40db2f3 Binary files /dev/null and b/assets/roots/369df24e274d52c0b46a58590043b883b4f6b096-0.der differ diff --git a/assets/roots/39d28b71fe1d19b65fb3f1288f23bc04595c4395-0.der b/assets/roots/39d28b71fe1d19b65fb3f1288f23bc04595c4395-0.der new file mode 100644 index 0000000..1e927a7 Binary files /dev/null and b/assets/roots/39d28b71fe1d19b65fb3f1288f23bc04595c4395-0.der differ diff --git a/assets/roots/3f1855d3c9a93b24f8b4f95a07ae048c20cea42d-0.der b/assets/roots/3f1855d3c9a93b24f8b4f95a07ae048c20cea42d-0.der new file mode 100644 index 0000000..2f6e59f Binary files /dev/null and b/assets/roots/3f1855d3c9a93b24f8b4f95a07ae048c20cea42d-0.der differ diff --git a/assets/roots/41655b67500e9a2e2c0cdcba7a9247f773f77269-0.der b/assets/roots/41655b67500e9a2e2c0cdcba7a9247f773f77269-0.der new file mode 100644 index 0000000..3e49eba Binary files /dev/null and b/assets/roots/41655b67500e9a2e2c0cdcba7a9247f773f77269-0.der differ diff --git a/assets/roots/41fc0249061723abd565d5bce6f9562e79c7c8e8-0.der b/assets/roots/41fc0249061723abd565d5bce6f9562e79c7c8e8-0.der new file mode 100644 index 0000000..6160479 Binary files /dev/null and b/assets/roots/41fc0249061723abd565d5bce6f9562e79c7c8e8-0.der differ diff --git a/assets/roots/4264562253e3c442d08ec17f32fb2d58f406ace3-0.der b/assets/roots/4264562253e3c442d08ec17f32fb2d58f406ace3-0.der new file mode 100644 index 0000000..71f9f88 Binary files /dev/null and b/assets/roots/4264562253e3c442d08ec17f32fb2d58f406ace3-0.der differ diff --git a/assets/roots/42aaaa5ea487b4f18a25daaf86f22533b7ee39ab-0.der b/assets/roots/42aaaa5ea487b4f18a25daaf86f22533b7ee39ab-0.der new file mode 100644 index 0000000..db0e85a Binary files /dev/null and b/assets/roots/42aaaa5ea487b4f18a25daaf86f22533b7ee39ab-0.der differ diff --git a/assets/roots/42d0b7e6a82098efcfb77957c139770ef5df885f-0.der b/assets/roots/42d0b7e6a82098efcfb77957c139770ef5df885f-0.der new file mode 100644 index 0000000..e7ad806 Binary files /dev/null and b/assets/roots/42d0b7e6a82098efcfb77957c139770ef5df885f-0.der differ diff --git a/assets/roots/481f557ae1206403ecc941543a54504072f2c1df-0.der b/assets/roots/481f557ae1206403ecc941543a54504072f2c1df-0.der new file mode 100644 index 0000000..d46aa38 Binary files /dev/null and b/assets/roots/481f557ae1206403ecc941543a54504072f2c1df-0.der differ diff --git a/assets/roots/4a7cdaf39dfbf67800700df49cd84af1531b0969-0.der b/assets/roots/4a7cdaf39dfbf67800700df49cd84af1531b0969-0.der new file mode 100644 index 0000000..1449f86 Binary files /dev/null and b/assets/roots/4a7cdaf39dfbf67800700df49cd84af1531b0969-0.der differ diff --git a/assets/roots/4b1ec7d2704f8539cb63fb2d863f09b26393e823-0.der b/assets/roots/4b1ec7d2704f8539cb63fb2d863f09b26393e823-0.der new file mode 100644 index 0000000..70a9ec9 Binary files /dev/null and b/assets/roots/4b1ec7d2704f8539cb63fb2d863f09b26393e823-0.der differ diff --git a/assets/roots/4e9b3837a2d6eb46985ae7cc638568fdaced325b-0.der b/assets/roots/4e9b3837a2d6eb46985ae7cc638568fdaced325b-0.der new file mode 100644 index 0000000..3bbe4a9 Binary files /dev/null and b/assets/roots/4e9b3837a2d6eb46985ae7cc638568fdaced325b-0.der differ diff --git a/assets/roots/4f59a39453cfb9559e7f6bd8c54da53da642b714-0.der b/assets/roots/4f59a39453cfb9559e7f6bd8c54da53da642b714-0.der new file mode 100644 index 0000000..86b7dcd Binary files /dev/null and b/assets/roots/4f59a39453cfb9559e7f6bd8c54da53da642b714-0.der differ diff --git a/assets/roots/538933b9a76ad7c55cbf4dc2d4769ce2bb50d20b-0.der b/assets/roots/538933b9a76ad7c55cbf4dc2d4769ce2bb50d20b-0.der new file mode 100644 index 0000000..603c65e Binary files /dev/null and b/assets/roots/538933b9a76ad7c55cbf4dc2d4769ce2bb50d20b-0.der differ diff --git a/assets/roots/5588e1fad8ea116388ec542a0110b63fb5c826e6-0.der b/assets/roots/5588e1fad8ea116388ec542a0110b63fb5c826e6-0.der new file mode 100644 index 0000000..ecd8c9d Binary files /dev/null and b/assets/roots/5588e1fad8ea116388ec542a0110b63fb5c826e6-0.der differ diff --git a/assets/roots/55f003ec26723b65ec4b936600e5d1af9fa80fa9-0.der b/assets/roots/55f003ec26723b65ec4b936600e5d1af9fa80fa9-0.der new file mode 100644 index 0000000..91d8e47 Binary files /dev/null and b/assets/roots/55f003ec26723b65ec4b936600e5d1af9fa80fa9-0.der differ diff --git a/assets/roots/5cd9dcebfc1a5bb0aa6923e532de48a56851f8ec-0.der b/assets/roots/5cd9dcebfc1a5bb0aa6923e532de48a56851f8ec-0.der new file mode 100644 index 0000000..10ef441 Binary files /dev/null and b/assets/roots/5cd9dcebfc1a5bb0aa6923e532de48a56851f8ec-0.der differ diff --git a/assets/roots/5e021b686c5cd3be1691995789dfc41472163d03-0.der b/assets/roots/5e021b686c5cd3be1691995789dfc41472163d03-0.der new file mode 100644 index 0000000..83fdeb7 Binary files /dev/null and b/assets/roots/5e021b686c5cd3be1691995789dfc41472163d03-0.der differ diff --git a/assets/roots/62fbb4c96c7627f44989f56d3795e701ecff86ed-0.der b/assets/roots/62fbb4c96c7627f44989f56d3795e701ecff86ed-0.der new file mode 100644 index 0000000..3492b95 Binary files /dev/null and b/assets/roots/62fbb4c96c7627f44989f56d3795e701ecff86ed-0.der differ diff --git a/assets/roots/6435ac18333cfe8ca49f61b4f2151d005ee86ce9-0.der b/assets/roots/6435ac18333cfe8ca49f61b4f2151d005ee86ce9-0.der new file mode 100644 index 0000000..7a991b7 Binary files /dev/null and b/assets/roots/6435ac18333cfe8ca49f61b4f2151d005ee86ce9-0.der differ diff --git a/assets/roots/64f5c98fa750327769f72ce76ccfd9ea956526f3-0.der b/assets/roots/64f5c98fa750327769f72ce76ccfd9ea956526f3-0.der new file mode 100644 index 0000000..fa25f67 Binary files /dev/null and b/assets/roots/64f5c98fa750327769f72ce76ccfd9ea956526f3-0.der differ diff --git a/assets/roots/69ad4bafb45c7a8423a7fb924545dff35aab46eb-0.der b/assets/roots/69ad4bafb45c7a8423a7fb924545dff35aab46eb-0.der new file mode 100644 index 0000000..a59a90b Binary files /dev/null and b/assets/roots/69ad4bafb45c7a8423a7fb924545dff35aab46eb-0.der differ diff --git a/assets/roots/6cf8311aae93dc3feadb1abc64d44c38494b2940-0.der b/assets/roots/6cf8311aae93dc3feadb1abc64d44c38494b2940-0.der new file mode 100644 index 0000000..aac1822 Binary files /dev/null and b/assets/roots/6cf8311aae93dc3feadb1abc64d44c38494b2940-0.der differ diff --git a/assets/roots/6f43c3357b52a21924146518f9cb7d4dbe05a82f-0.der b/assets/roots/6f43c3357b52a21924146518f9cb7d4dbe05a82f-0.der new file mode 100644 index 0000000..5aea734 Binary files /dev/null and b/assets/roots/6f43c3357b52a21924146518f9cb7d4dbe05a82f-0.der differ diff --git a/assets/roots/7036af3c2bea54342640b3ff78c70768e0fdfd68-0.der b/assets/roots/7036af3c2bea54342640b3ff78c70768e0fdfd68-0.der new file mode 100644 index 0000000..316a530 Binary files /dev/null and b/assets/roots/7036af3c2bea54342640b3ff78c70768e0fdfd68-0.der differ diff --git a/assets/roots/704b80e92c15138ab8bef8cad0f2ed793ab3697c-0.der b/assets/roots/704b80e92c15138ab8bef8cad0f2ed793ab3697c-0.der new file mode 100644 index 0000000..235e186 Binary files /dev/null and b/assets/roots/704b80e92c15138ab8bef8cad0f2ed793ab3697c-0.der differ diff --git a/assets/roots/71719c5ecfe502b0b8bdccfe3aac3b0057a13809-0.der b/assets/roots/71719c5ecfe502b0b8bdccfe3aac3b0057a13809-0.der new file mode 100644 index 0000000..22d99a4 Binary files /dev/null and b/assets/roots/71719c5ecfe502b0b8bdccfe3aac3b0057a13809-0.der differ diff --git a/assets/roots/724dba45ab521e8115a29d695c1fc06daee1671c-0.der b/assets/roots/724dba45ab521e8115a29d695c1fc06daee1671c-0.der new file mode 100644 index 0000000..63ce66e Binary files /dev/null and b/assets/roots/724dba45ab521e8115a29d695c1fc06daee1671c-0.der differ diff --git a/assets/roots/72c74580524922e9a5b62e238cb16cd887402dde-0.der b/assets/roots/72c74580524922e9a5b62e238cb16cd887402dde-0.der new file mode 100644 index 0000000..47d3400 Binary files /dev/null and b/assets/roots/72c74580524922e9a5b62e238cb16cd887402dde-0.der differ diff --git a/assets/roots/79e688bd141d71bbcfed90e833ca29c89162f156-0.der b/assets/roots/79e688bd141d71bbcfed90e833ca29c89162f156-0.der new file mode 100644 index 0000000..24e5adb Binary files /dev/null and b/assets/roots/79e688bd141d71bbcfed90e833ca29c89162f156-0.der differ diff --git a/assets/roots/79eabcd4a06fa193dda974a7c6cf184c1fbb4f14-0.der b/assets/roots/79eabcd4a06fa193dda974a7c6cf184c1fbb4f14-0.der new file mode 100644 index 0000000..e99af2e Binary files /dev/null and b/assets/roots/79eabcd4a06fa193dda974a7c6cf184c1fbb4f14-0.der differ diff --git a/assets/roots/7e63ed24cfd532db5d6e66acee1149168947a20d-0.der b/assets/roots/7e63ed24cfd532db5d6e66acee1149168947a20d-0.der new file mode 100644 index 0000000..70cd462 Binary files /dev/null and b/assets/roots/7e63ed24cfd532db5d6e66acee1149168947a20d-0.der differ diff --git a/assets/roots/7f7fad1e1daeeb75e7ecc9ad1ec5f590f5b98461-0.der b/assets/roots/7f7fad1e1daeeb75e7ecc9ad1ec5f590f5b98461-0.der new file mode 100644 index 0000000..b40648c Binary files /dev/null and b/assets/roots/7f7fad1e1daeeb75e7ecc9ad1ec5f590f5b98461-0.der differ diff --git a/assets/roots/81abd26e80718923cd527a3315639bcabc82baac-0.der b/assets/roots/81abd26e80718923cd527a3315639bcabc82baac-0.der new file mode 100644 index 0000000..d0e4cf3 Binary files /dev/null and b/assets/roots/81abd26e80718923cd527a3315639bcabc82baac-0.der differ diff --git a/assets/roots/83cf671cc5fe6cef98a1dba2ad2bdd787607c611-0.der b/assets/roots/83cf671cc5fe6cef98a1dba2ad2bdd787607c611-0.der new file mode 100644 index 0000000..056eace Binary files /dev/null and b/assets/roots/83cf671cc5fe6cef98a1dba2ad2bdd787607c611-0.der differ diff --git a/assets/roots/83fcdde3ff2a744eb4370369af62cd497095e486-0.der b/assets/roots/83fcdde3ff2a744eb4370369af62cd497095e486-0.der new file mode 100644 index 0000000..452e549 Binary files /dev/null and b/assets/roots/83fcdde3ff2a744eb4370369af62cd497095e486-0.der differ diff --git a/assets/roots/843eb9be7c5fcf0311180cf2f1e3ecb9d5e36027-0.der b/assets/roots/843eb9be7c5fcf0311180cf2f1e3ecb9d5e36027-0.der new file mode 100644 index 0000000..cbccb43 Binary files /dev/null and b/assets/roots/843eb9be7c5fcf0311180cf2f1e3ecb9d5e36027-0.der differ diff --git a/assets/roots/887da4445e67ea7c94774e43189c3ecee4c87312-0.der b/assets/roots/887da4445e67ea7c94774e43189c3ecee4c87312-0.der new file mode 100644 index 0000000..42f3c1b Binary files /dev/null and b/assets/roots/887da4445e67ea7c94774e43189c3ecee4c87312-0.der differ diff --git a/assets/roots/8a938d24845c923b512b1cd0ac0e7a64a33b4cf0-0.der b/assets/roots/8a938d24845c923b512b1cd0ac0e7a64a33b4cf0-0.der new file mode 100644 index 0000000..0f5f95f Binary files /dev/null and b/assets/roots/8a938d24845c923b512b1cd0ac0e7a64a33b4cf0-0.der differ diff --git a/assets/roots/8aa2eaae98f84c6b1703fefce5ee0bc9afed2fa6-0.der b/assets/roots/8aa2eaae98f84c6b1703fefce5ee0bc9afed2fa6-0.der new file mode 100644 index 0000000..c568023 Binary files /dev/null and b/assets/roots/8aa2eaae98f84c6b1703fefce5ee0bc9afed2fa6-0.der differ diff --git a/assets/roots/8df6bca2fa8dd4f6a68eaf461bf2091e9aa77576-0.der b/assets/roots/8df6bca2fa8dd4f6a68eaf461bf2091e9aa77576-0.der new file mode 100644 index 0000000..1a0c2e2 Binary files /dev/null and b/assets/roots/8df6bca2fa8dd4f6a68eaf461bf2091e9aa77576-0.der differ diff --git a/assets/roots/8e88f59059604b03df43780a8094a649cdb975f6-0.der b/assets/roots/8e88f59059604b03df43780a8094a649cdb975f6-0.der new file mode 100644 index 0000000..d0e8f0b Binary files /dev/null and b/assets/roots/8e88f59059604b03df43780a8094a649cdb975f6-0.der differ diff --git a/assets/roots/9370a55f7c274e0d3a10562231c60a406a8d98a6-0.der b/assets/roots/9370a55f7c274e0d3a10562231c60a406a8d98a6-0.der new file mode 100644 index 0000000..6927c52 Binary files /dev/null and b/assets/roots/9370a55f7c274e0d3a10562231c60a406a8d98a6-0.der differ diff --git a/assets/roots/953e4462a2852855675a4a4f8a790c5e904cfa5a-0.der b/assets/roots/953e4462a2852855675a4a4f8a790c5e904cfa5a-0.der new file mode 100644 index 0000000..09438c8 Binary files /dev/null and b/assets/roots/953e4462a2852855675a4a4f8a790c5e904cfa5a-0.der differ diff --git a/assets/roots/96db2813a1d464fed7792b0db6d29e5d8308bbf9-0.der b/assets/roots/96db2813a1d464fed7792b0db6d29e5d8308bbf9-0.der new file mode 100644 index 0000000..a4dfe1f Binary files /dev/null and b/assets/roots/96db2813a1d464fed7792b0db6d29e5d8308bbf9-0.der differ diff --git a/assets/roots/9717ccde7d8296f32c79363127c7d366c7ccf94c-0.der b/assets/roots/9717ccde7d8296f32c79363127c7d366c7ccf94c-0.der new file mode 100644 index 0000000..12f481e Binary files /dev/null and b/assets/roots/9717ccde7d8296f32c79363127c7d366c7ccf94c-0.der differ diff --git a/assets/roots/984a0a63fba1d7069deb8931aef39c24ea7a5c47-0.der b/assets/roots/984a0a63fba1d7069deb8931aef39c24ea7a5c47-0.der new file mode 100644 index 0000000..d618f1e Binary files /dev/null and b/assets/roots/984a0a63fba1d7069deb8931aef39c24ea7a5c47-0.der differ diff --git a/assets/roots/9b55f064e2c8498d97481114eeaf435fe0cd3a39-0.der b/assets/roots/9b55f064e2c8498d97481114eeaf435fe0cd3a39-0.der new file mode 100644 index 0000000..16a364d Binary files /dev/null and b/assets/roots/9b55f064e2c8498d97481114eeaf435fe0cd3a39-0.der differ diff --git a/assets/roots/9bbd65196c3a9fffbf0bc4bfdb20299bf0d6df8f-0.der b/assets/roots/9bbd65196c3a9fffbf0bc4bfdb20299bf0d6df8f-0.der new file mode 100644 index 0000000..58d018a Binary files /dev/null and b/assets/roots/9bbd65196c3a9fffbf0bc4bfdb20299bf0d6df8f-0.der differ diff --git a/assets/roots/9be242c83e15138e38e53cc701da3ab52d475144-0.der b/assets/roots/9be242c83e15138e38e53cc701da3ab52d475144-0.der new file mode 100644 index 0000000..6a62de1 Binary files /dev/null and b/assets/roots/9be242c83e15138e38e53cc701da3ab52d475144-0.der differ diff --git a/assets/roots/a27cf103f952a9ba8cd636012f4adfffe82b92af-0.der b/assets/roots/a27cf103f952a9ba8cd636012f4adfffe82b92af-0.der new file mode 100644 index 0000000..e4795f3 Binary files /dev/null and b/assets/roots/a27cf103f952a9ba8cd636012f4adfffe82b92af-0.der differ diff --git a/assets/roots/a4c867427bbd6f1c2a429387154ec72447e68508-0.der b/assets/roots/a4c867427bbd6f1c2a429387154ec72447e68508-0.der new file mode 100644 index 0000000..c1258ee Binary files /dev/null and b/assets/roots/a4c867427bbd6f1c2a429387154ec72447e68508-0.der differ diff --git a/assets/roots/a4d7c276adf4f0fa5b7b3606db53807f3cd79d09-0.der b/assets/roots/a4d7c276adf4f0fa5b7b3606db53807f3cd79d09-0.der new file mode 100644 index 0000000..c676be5 Binary files /dev/null and b/assets/roots/a4d7c276adf4f0fa5b7b3606db53807f3cd79d09-0.der differ diff --git a/assets/roots/a76e38b989c88f14809cb8826da96b75cadeeb8d-0.der b/assets/roots/a76e38b989c88f14809cb8826da96b75cadeeb8d-0.der new file mode 100644 index 0000000..1ff0c9c Binary files /dev/null and b/assets/roots/a76e38b989c88f14809cb8826da96b75cadeeb8d-0.der differ diff --git a/assets/roots/aab38011e10133cb69cdf7f710e973cf9577abbf-0.der b/assets/roots/aab38011e10133cb69cdf7f710e973cf9577abbf-0.der new file mode 100644 index 0000000..8c59853 Binary files /dev/null and b/assets/roots/aab38011e10133cb69cdf7f710e973cf9577abbf-0.der differ diff --git a/assets/roots/abf377b2272de78e18d5a67e5b1297dce8c5dac1-0.der b/assets/roots/abf377b2272de78e18d5a67e5b1297dce8c5dac1-0.der new file mode 100644 index 0000000..9c53496 Binary files /dev/null and b/assets/roots/abf377b2272de78e18d5a67e5b1297dce8c5dac1-0.der differ diff --git a/assets/roots/b0e31e6fe1b4e58b38cd4664dd9184b2eead11f6-0.der b/assets/roots/b0e31e6fe1b4e58b38cd4664dd9184b2eead11f6-0.der new file mode 100644 index 0000000..194de84 Binary files /dev/null and b/assets/roots/b0e31e6fe1b4e58b38cd4664dd9184b2eead11f6-0.der differ diff --git a/assets/roots/b63a54aeb7c096b2c72835b60f7d423e851e811a-0.der b/assets/roots/b63a54aeb7c096b2c72835b60f7d423e851e811a-0.der new file mode 100644 index 0000000..dd740ab Binary files /dev/null and b/assets/roots/b63a54aeb7c096b2c72835b60f7d423e851e811a-0.der differ diff --git a/assets/roots/b7e7d918696e6d071cafa898ed1bf22538e71ab2-0.der b/assets/roots/b7e7d918696e6d071cafa898ed1bf22538e71ab2-0.der new file mode 100644 index 0000000..53919a6 Binary files /dev/null and b/assets/roots/b7e7d918696e6d071cafa898ed1bf22538e71ab2-0.der differ diff --git a/assets/roots/badf8ae3f7eb508c94c1bae31e7cdc3a713d4437-0.der b/assets/roots/badf8ae3f7eb508c94c1bae31e7cdc3a713d4437-0.der new file mode 100644 index 0000000..d6aa0f4 Binary files /dev/null and b/assets/roots/badf8ae3f7eb508c94c1bae31e7cdc3a713d4437-0.der differ diff --git a/assets/roots/bece0f14569371822ea32fed5faa588deb9a03a2-0.der b/assets/roots/bece0f14569371822ea32fed5faa588deb9a03a2-0.der new file mode 100644 index 0000000..0642a03 Binary files /dev/null and b/assets/roots/bece0f14569371822ea32fed5faa588deb9a03a2-0.der differ diff --git a/assets/roots/c17e0d0305d965b2bf298a6c492a3b0bea8ba25f-0.der b/assets/roots/c17e0d0305d965b2bf298a6c492a3b0bea8ba25f-0.der new file mode 100644 index 0000000..86d70df Binary files /dev/null and b/assets/roots/c17e0d0305d965b2bf298a6c492a3b0bea8ba25f-0.der differ diff --git a/assets/roots/c5c7806f3b0ec8c5ac7df8fd9922dcc8b15bd0bf-0.der b/assets/roots/c5c7806f3b0ec8c5ac7df8fd9922dcc8b15bd0bf-0.der new file mode 100644 index 0000000..0911a56 Binary files /dev/null and b/assets/roots/c5c7806f3b0ec8c5ac7df8fd9922dcc8b15bd0bf-0.der differ diff --git a/assets/roots/c90a620226764c614af7a3e7549b6b1dac6b0f55-0.der b/assets/roots/c90a620226764c614af7a3e7549b6b1dac6b0f55-0.der new file mode 100644 index 0000000..675cbeb Binary files /dev/null and b/assets/roots/c90a620226764c614af7a3e7549b6b1dac6b0f55-0.der differ diff --git a/assets/roots/c9770b343f6c16083e2a9e9be4f9ee2de35dd3d9-0.der b/assets/roots/c9770b343f6c16083e2a9e9be4f9ee2de35dd3d9-0.der new file mode 100644 index 0000000..982345d Binary files /dev/null and b/assets/roots/c9770b343f6c16083e2a9e9be4f9ee2de35dd3d9-0.der differ diff --git a/assets/roots/cc74fc3960063bc493f2540de8ccc3b47e59d84a-0.der b/assets/roots/cc74fc3960063bc493f2540de8ccc3b47e59d84a-0.der new file mode 100644 index 0000000..86658ae Binary files /dev/null and b/assets/roots/cc74fc3960063bc493f2540de8ccc3b47e59d84a-0.der differ diff --git a/assets/roots/cd30d24c343a82ab1f0570158ad7a107762992e9-0.der b/assets/roots/cd30d24c343a82ab1f0570158ad7a107762992e9-0.der new file mode 100644 index 0000000..2fa45b2 Binary files /dev/null and b/assets/roots/cd30d24c343a82ab1f0570158ad7a107762992e9-0.der differ diff --git a/assets/roots/cf3ab99d14f558ddad1db1c0c87dd6b76685ff6b-0.der b/assets/roots/cf3ab99d14f558ddad1db1c0c87dd6b76685ff6b-0.der new file mode 100644 index 0000000..26f98a8 Binary files /dev/null and b/assets/roots/cf3ab99d14f558ddad1db1c0c87dd6b76685ff6b-0.der differ diff --git a/assets/roots/d07c43a2a0f778fed080dff8600e49515c1d05c2-0.der b/assets/roots/d07c43a2a0f778fed080dff8600e49515c1d05c2-0.der new file mode 100644 index 0000000..ff212ef Binary files /dev/null and b/assets/roots/d07c43a2a0f778fed080dff8600e49515c1d05c2-0.der differ diff --git a/assets/roots/d422b47d649377b28a8d0620f4ffafe3444e309c-0.der b/assets/roots/d422b47d649377b28a8d0620f4ffafe3444e309c-0.der new file mode 100644 index 0000000..7a8caf9 Binary files /dev/null and b/assets/roots/d422b47d649377b28a8d0620f4ffafe3444e309c-0.der differ diff --git a/assets/roots/d83a4cebac21908f4f278d95db510429928526b9-0.der b/assets/roots/d83a4cebac21908f4f278d95db510429928526b9-0.der new file mode 100644 index 0000000..cf29ccc Binary files /dev/null and b/assets/roots/d83a4cebac21908f4f278d95db510429928526b9-0.der differ diff --git a/assets/roots/db967dfe40104bbb8a15f44c45067d6d9ca0117a-0.der b/assets/roots/db967dfe40104bbb8a15f44c45067d6d9ca0117a-0.der new file mode 100644 index 0000000..339a109 Binary files /dev/null and b/assets/roots/db967dfe40104bbb8a15f44c45067d6d9ca0117a-0.der differ diff --git a/assets/roots/dd8ae898b225382efd64f9f3c37486534fa9cbda-0.der b/assets/roots/dd8ae898b225382efd64f9f3c37486534fa9cbda-0.der new file mode 100644 index 0000000..3f6981b Binary files /dev/null and b/assets/roots/dd8ae898b225382efd64f9f3c37486534fa9cbda-0.der differ diff --git a/assets/roots/ddb36391a7eefac7df3e47972cdd9c04be806e9d-0.der b/assets/roots/ddb36391a7eefac7df3e47972cdd9c04be806e9d-0.der new file mode 100644 index 0000000..b21a344 Binary files /dev/null and b/assets/roots/ddb36391a7eefac7df3e47972cdd9c04be806e9d-0.der differ diff --git a/assets/roots/de1d43c47af5f1bc86ab36437e021703f89ccf5c-0.der b/assets/roots/de1d43c47af5f1bc86ab36437e021703f89ccf5c-0.der new file mode 100644 index 0000000..56039c0 Binary files /dev/null and b/assets/roots/de1d43c47af5f1bc86ab36437e021703f89ccf5c-0.der differ diff --git a/assets/roots/df22cf8b8c3b46c10d3d5c407561eabeb57f8181-0.der b/assets/roots/df22cf8b8c3b46c10d3d5c407561eabeb57f8181-0.der new file mode 100644 index 0000000..99bcc84 Binary files /dev/null and b/assets/roots/df22cf8b8c3b46c10d3d5c407561eabeb57f8181-0.der differ diff --git a/assets/roots/e24e78259c056c7f870c7b62f5b043d96fab7725-0.der b/assets/roots/e24e78259c056c7f870c7b62f5b043d96fab7725-0.der new file mode 100644 index 0000000..9519275 Binary files /dev/null and b/assets/roots/e24e78259c056c7f870c7b62f5b043d96fab7725-0.der differ diff --git a/assets/roots/e7e9ceba6df839282ba96295895cca12e56e2cd6-0.der b/assets/roots/e7e9ceba6df839282ba96295895cca12e56e2cd6-0.der new file mode 100644 index 0000000..f61e2f8 Binary files /dev/null and b/assets/roots/e7e9ceba6df839282ba96295895cca12e56e2cd6-0.der differ diff --git a/assets/roots/e9c6f2faacfba8533a5d58a04d4f76d1fa532b0b-0.der b/assets/roots/e9c6f2faacfba8533a5d58a04d4f76d1fa532b0b-0.der new file mode 100644 index 0000000..f88e810 Binary files /dev/null and b/assets/roots/e9c6f2faacfba8533a5d58a04d4f76d1fa532b0b-0.der differ diff --git a/assets/roots/eb25d890fe2720f41d7d6ac15eef76cf62cfab78-0.der b/assets/roots/eb25d890fe2720f41d7d6ac15eef76cf62cfab78-0.der new file mode 100644 index 0000000..9e42698 Binary files /dev/null and b/assets/roots/eb25d890fe2720f41d7d6ac15eef76cf62cfab78-0.der differ diff --git a/assets/roots/eb8ebc9d472b7e0291771923f2a5f1c1c51e57b5-0.der b/assets/roots/eb8ebc9d472b7e0291771923f2a5f1c1c51e57b5-0.der new file mode 100644 index 0000000..6dda6a3 Binary files /dev/null and b/assets/roots/eb8ebc9d472b7e0291771923f2a5f1c1c51e57b5-0.der differ diff --git a/assets/roots/ef58da4b2284f3860c68b18b916a064a520a9728-0.der b/assets/roots/ef58da4b2284f3860c68b18b916a064a520a9728-0.der new file mode 100644 index 0000000..9c1e26c Binary files /dev/null and b/assets/roots/ef58da4b2284f3860c68b18b916a064a520a9728-0.der differ diff --git a/assets/roots/f026813c6ea2e5bbca921b4b884a50f107f0cbff-0.der b/assets/roots/f026813c6ea2e5bbca921b4b884a50f107f0cbff-0.der new file mode 100644 index 0000000..507b842 Binary files /dev/null and b/assets/roots/f026813c6ea2e5bbca921b4b884a50f107f0cbff-0.der differ diff --git a/assets/roots/f180fc731db5ec5a468e4ba9bc8ab856319ceda9-0.der b/assets/roots/f180fc731db5ec5a468e4ba9bc8ab856319ceda9-0.der new file mode 100644 index 0000000..72e48c3 Binary files /dev/null and b/assets/roots/f180fc731db5ec5a468e4ba9bc8ab856319ceda9-0.der differ diff --git a/assets/roots/f20ef3e3718f19d130d9cc7a2dc303161cb29cd0-0.der b/assets/roots/f20ef3e3718f19d130d9cc7a2dc303161cb29cd0-0.der new file mode 100644 index 0000000..a42b519 Binary files /dev/null and b/assets/roots/f20ef3e3718f19d130d9cc7a2dc303161cb29cd0-0.der differ diff --git a/assets/roots/f33f2ef9470df6a3ca848f278ea48015299c54f1-0.der b/assets/roots/f33f2ef9470df6a3ca848f278ea48015299c54f1-0.der new file mode 100644 index 0000000..767b3c2 Binary files /dev/null and b/assets/roots/f33f2ef9470df6a3ca848f278ea48015299c54f1-0.der differ diff --git a/assets/roots/f4ee867f8a28dc57b3dfe2c74facf594b917520b-0.der b/assets/roots/f4ee867f8a28dc57b3dfe2c74facf594b917520b-0.der new file mode 100644 index 0000000..b29eefd Binary files /dev/null and b/assets/roots/f4ee867f8a28dc57b3dfe2c74facf594b917520b-0.der differ diff --git a/assets/roots/f5042d4a785f7dab303cd2dc815d90dee6995f09-0.der b/assets/roots/f5042d4a785f7dab303cd2dc815d90dee6995f09-0.der new file mode 100644 index 0000000..6a336d4 Binary files /dev/null and b/assets/roots/f5042d4a785f7dab303cd2dc815d90dee6995f09-0.der differ diff --git a/assets/roots/f59639270d6a2019f1683191887f7f3edb1f2013-0.der b/assets/roots/f59639270d6a2019f1683191887f7f3edb1f2013-0.der new file mode 100644 index 0000000..d5f8989 Binary files /dev/null and b/assets/roots/f59639270d6a2019f1683191887f7f3edb1f2013-0.der differ diff --git a/assets/roots/f59c687f2418d62a790f7592330756ea85e94707-0.der b/assets/roots/f59c687f2418d62a790f7592330756ea85e94707-0.der new file mode 100644 index 0000000..232c4b6 Binary files /dev/null and b/assets/roots/f59c687f2418d62a790f7592330756ea85e94707-0.der differ diff --git a/assets/roots/f7025660b8bbc206931faf24a7161c03980f6301-0.der b/assets/roots/f7025660b8bbc206931faf24a7161c03980f6301-0.der new file mode 100644 index 0000000..e18997f Binary files /dev/null and b/assets/roots/f7025660b8bbc206931faf24a7161c03980f6301-0.der differ diff --git a/assets/roots/f85cf300817f2b6cd6f5d220fd224155c466d35b-0.der b/assets/roots/f85cf300817f2b6cd6f5d220fd224155c466d35b-0.der new file mode 100644 index 0000000..c55cb68 Binary files /dev/null and b/assets/roots/f85cf300817f2b6cd6f5d220fd224155c466d35b-0.der differ diff --git a/assets/roots/f9780538aed47baadb39e8710440f329a43a372a-0.der b/assets/roots/f9780538aed47baadb39e8710440f329a43a372a-0.der new file mode 100644 index 0000000..3a08309 Binary files /dev/null and b/assets/roots/f9780538aed47baadb39e8710440f329a43a372a-0.der differ diff --git a/assets/roots/fbb2191bc63ac8ff27f29ee542bf779ce0c298a1-0.der b/assets/roots/fbb2191bc63ac8ff27f29ee542bf779ce0c298a1-0.der new file mode 100644 index 0000000..3cd7112 Binary files /dev/null and b/assets/roots/fbb2191bc63ac8ff27f29ee542bf779ce0c298a1-0.der differ diff --git a/assets/roots/fcc3e143973af986f6b33251f56e17bbee86f58c-0.der b/assets/roots/fcc3e143973af986f6b33251f56e17bbee86f58c-0.der new file mode 100644 index 0000000..bcafb43 Binary files /dev/null and b/assets/roots/fcc3e143973af986f6b33251f56e17bbee86f58c-0.der differ diff --git a/build.sh b/build.sh index c9364e2..9109989 100755 --- a/build.sh +++ b/build.sh @@ -6,18 +6,19 @@ set -e cd "$(dirname "$0")" ANDROID_JAR=${ANDROID_JAR:-/home/claude/sable/android-platforms/android-8/android.jar} KEYSTORE=${KEYSTORE:-miniabs.keystore} +BC_SRC=${BC_SRC:-$HOME/android-toolchain/bcsrc} # Bouncy Castle renamed to com.minamp.bc (Minamp's tools/setup_toolchain.sh) OUT=build rm -rf "$OUT" mkdir -p "$OUT/gen" "$OUT/classes" echo "== resources" -aapt package -f -m -J "$OUT/gen" -M AndroidManifest.xml -S res -I "$ANDROID_JAR" \ +aapt package -f -m -J "$OUT/gen" -M AndroidManifest.xml -S res -A assets -I "$ANDROID_JAR" \ -F "$OUT/MiniABS.unsigned.apk" echo "== compile (Java 8 bytecode, API 8 class library)" javac -nowarn -Xlint:-options -encoding UTF-8 -source 8 -target 8 \ - -bootclasspath "$ANDROID_JAR" -classpath "$ANDROID_JAR" \ + -bootclasspath "$ANDROID_JAR" -classpath "$ANDROID_JAR" -sourcepath "src:$BC_SRC:$OUT/gen" \ -d "$OUT/classes" $(find src "$OUT/gen" -name '*.java') echo "== dex" diff --git a/res/layout/activity_login.xml b/res/layout/activity_login.xml index 3a84314..fca83df 100644 --- a/res/layout/activity_login.xml +++ b/res/layout/activity_login.xml @@ -99,6 +99,6 @@ android:textSize="@dimen/login_tip" android:textColor="@color/dim" android:lineSpacingExtra="2dp" - android:text="Na casa, usa o enderezo local http:// do servidor. Para https://, Android 2.3 precisa que o servidor ofreza TLS 1.0 nun porto á parte (ve o README). Se o certificado non se recoñece, amosaráseche a súa pegada dixital para que a comprobes." /> + android:text="Usa o enderezo https:// normal do servidor, na casa ou fóra: MiniABS trae o seu propio TLS moderno. Na casa tamén vale o enderezo local http://. Se o certificado non se recoñece, amosaráseche a súa pegada dixital para que a comprobes." /> diff --git a/src/com/miniabs/Api.java b/src/com/miniabs/Api.java index 643f8b9..c1e9eca 100644 --- a/src/com/miniabs/Api.java +++ b/src/com/miniabs/Api.java @@ -26,7 +26,7 @@ import android.os.Build; /** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */ final class Api { - static final String VERSION = "1.6"; + static final String VERSION = "1.7"; static class ApiException extends IOException { final int code; @@ -57,12 +57,12 @@ final class Api { // ------------------------------------------------------------------ plumbing static HttpURLConnection open(Context c, String url, String method) throws IOException { - HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + Trust.install(c); + HttpURLConnection conn = new NetConnection(new URL(url)); conn.setConnectTimeout(15000); conn.setReadTimeout(30000); conn.setUseCaches(false); conn.setInstanceFollowRedirects(true); - if (conn instanceof HttpsURLConnection) Trust.apply(c, (HttpsURLConnection) conn); setMethod(conn, method); conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")"); return conn; @@ -73,71 +73,31 @@ final class Api { } /** - * PATCH over a plain socket. Android 2.x's HttpURLConnection only sends a body with POST or PUT, - * and Audiobookshelf uses PATCH for "mark as played" and for saving progress without a session. + * PATCH: Audiobookshelf uses it for "mark as played" and for saving progress without a session. + * (Android 2.x's own HttpURLConnection can't send it; the bundled client can.) */ private static Object rawPatch(Context c, String path, JSONObject body, boolean mayRefresh) throws IOException { Prefs p = Prefs.get(c); if (mayRefresh) ensureFresh(c, 120); String used = p.token(); - URL u = new URL(p.server() + path); - boolean https = "https".equalsIgnoreCase(u.getProtocol()); - int port = u.getPort() > 0 ? u.getPort() : (https ? 443 : 80); - Socket sock = new Socket(); + HttpURLConnection conn = open(c, p.server() + path, "PATCH"); + conn.setInstanceFollowRedirects(false); + conn.setRequestProperty("Authorization", "Bearer " + used); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Accept", "application/json"); + conn.setDoOutput(true); int code; try { - sock.connect(new InetSocketAddress(u.getHost(), port), 15000); - sock.setSoTimeout(30000); - Socket io = sock; - if (https) { - SSLSocket ss = (SSLSocket) Trust.factory(c).createSocket(sock, u.getHost(), port, true); - try { // send the host name (SNI) where the phone supports it (Android 2.3+) - ss.getClass().getMethod("setHostname", String.class).invoke(ss, u.getHost()); - } catch (Exception ignored) { - } - ss.startHandshake(); - java.security.cert.Certificate[] peer = ss.getSession().getPeerCertificates(); - if (peer.length == 0 || !(peer[0] instanceof java.security.cert.X509Certificate) - || !Trust.hostMatches((java.security.cert.X509Certificate) peer[0], u.getHost())) { - ss.close(); - throw new SSLException("O certificado non corresponde a " + u.getHost()); - } - io = ss; + OutputStream os = conn.getOutputStream(); + os.write((body == null ? "{}" : body.toString()).getBytes("UTF-8")); + code = conn.getResponseCode(); + InputStream in = code >= 400 ? conn.getErrorStream() : conn.getInputStream(); + if (in != null) { + byte[] rest = new byte[1024]; + while (in.read(rest) > 0) { } // read the reply so the connection can be reused } - byte[] b = (body == null ? "{}" : body.toString()).getBytes("UTF-8"); - String host = u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : ""); - String head = "PATCH " + u.getFile() + " HTTP/1.1\r\n" - + "Host: " + host + "\r\n" - + "Authorization: Bearer " + used + "\r\n" - + "Content-Type: application/json\r\n" - + "Accept: application/json\r\n" - + "User-Agent: MiniABS/" + VERSION + "\r\n" - + "Content-Length: " + b.length + "\r\n" - + "Connection: close\r\n\r\n"; - OutputStream os = io.getOutputStream(); - os.write(head.getBytes("UTF-8")); - os.write(b); - os.flush(); - InputStream in = io.getInputStream(); - StringBuilder line = new StringBuilder(); - int ch; - while ((ch = in.read()) != -1 && ch != '\n' && line.length() < 200) line.append((char) ch); - String[] parts = line.toString().trim().split(" "); - if (parts.length < 2 || !parts[0].startsWith("HTTP/")) throw new IOException("Resposta inesperada do servidor"); - try { - code = Integer.parseInt(parts[1]); - } catch (NumberFormatException e) { - throw new IOException("Resposta inesperada do servidor"); - } - byte[] rest = new byte[1024]; - int total = 0, n; - while (total < 16384 && (n = in.read(rest)) > 0) total += n; // read the reply politely - io.close(); } finally { - try { - sock.close(); - } catch (IOException ignored) { - } + conn.disconnect(); } if (code == 401) { if (mayRefresh && p.canRefresh()) { diff --git a/src/com/miniabs/Covers.java b/src/com/miniabs/Covers.java index af8c63c..f53f206 100644 --- a/src/com/miniabs/Covers.java +++ b/src/com/miniabs/Covers.java @@ -23,7 +23,7 @@ final class Covers { private static final int MAX_ENTRIES = 30; private static final Handler ui = new Handler(android.os.Looper.getMainLooper()); - private static final ExecutorService pool = Executors.newFixedThreadPool(2); + private static final ExecutorService pool = com.miniabs.net.Threads.pool("covers", 2); private static final Map cache = new LinkedHashMap(16, 0.75f, true) { @Override diff --git a/src/com/miniabs/DownloadService.java b/src/com/miniabs/DownloadService.java index 26783b0..96ad952 100644 --- a/src/com/miniabs/DownloadService.java +++ b/src/com/miniabs/DownloadService.java @@ -68,7 +68,22 @@ public class DownloadService extends IntentService { } @Override - protected void onHandleIntent(Intent intent) { + protected void onHandleIntent(final Intent intent) { + // IntentService's thread has Android 2.x's small default stack, which the TLS handshake + // overflows: download on a big-stack thread and wait for it + Thread t = com.miniabs.net.Threads.create(new Runnable() { + public void run() { + handle(intent); + } + }, "download"); + t.start(); + try { + t.join(); + } catch (InterruptedException ignored) { + } + } + + private void handle(Intent intent) { Episode e; try { e = Episode.fromStored(new JSONObject(intent.getStringExtra("ep"))); diff --git a/src/com/miniabs/Job.java b/src/com/miniabs/Job.java index 337eacd..fa18902 100644 --- a/src/com/miniabs/Job.java +++ b/src/com/miniabs/Job.java @@ -1,10 +1,17 @@ package com.miniabs; -import android.os.AsyncTask; +import java.util.concurrent.ExecutorService; -/** Background work with the result (or error) delivered on the UI thread. */ -abstract class Job extends AsyncTask { - private Exception error; +import android.os.Handler; +import android.os.Looper; + +/** + * Background work with the result (or error) delivered on the UI thread. Runs on big-stack + * threads, not AsyncTask's: the bundled TLS handshake overflows Android 2.x's default stack. + */ +abstract class Job { + private static final ExecutorService POOL = com.miniabs.net.Threads.pool("job", 3); + private static final Handler UI = new Handler(Looper.getMainLooper()); abstract T work() throws Exception; @@ -13,24 +20,26 @@ abstract class Job extends AsyncTask { void failed(Exception e) { } - @Override - protected final T doInBackground(Void... v) { - try { - return work(); - } catch (Exception e) { - error = e; - return null; - } - } - - @Override - protected final void onPostExecute(T result) { - if (error != null) failed(error); - else done(result); - } - Job start() { - execute(); + POOL.execute(new Runnable() { + public void run() { + T result = null; + Exception error = null; + try { + result = work(); + } catch (Exception e) { + error = e; + } + final T r = result; + final Exception err = error; + UI.post(new Runnable() { + public void run() { + if (err != null) failed(err); + else done(r); + } + }); + } + }); return this; } } diff --git a/src/com/miniabs/NetConnection.java b/src/com/miniabs/NetConnection.java new file mode 100644 index 0000000..683ef04 --- /dev/null +++ b/src/com/miniabs/NetConnection.java @@ -0,0 +1,177 @@ +package com.miniabs; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.miniabs.net.Http; + +/** + * An HttpURLConnection backed by the bundled TLS 1.2 client (com.miniabs.net, from Minamp), so + * the existing API, cover, download and stream code keeps its shape while the phone talks to the + * normal https:// address: Android 2.x's own TLS 1.0 can't. Any method works (PATCH too), and + * connections are kept alive between requests. + */ +final class NetConnection extends HttpURLConnection { + private final Map req = new LinkedHashMap(); + private ByteArrayOutputStream body; + private Http.Response resp; + private String verb = "GET"; + + NetConnection(URL u) { + super(u); + } + + @Override + public void setRequestMethod(String m) { + verb = m; // no validation: Android 2.x's refuses PATCH + method = m; + } + + @Override + public String getRequestMethod() { + return verb; + } + + @Override + public void setRequestProperty(String k, String v) { + req.put(k, v); + } + + @Override + public void addRequestProperty(String k, String v) { + String old = req.get(k); + req.put(k, old == null ? v : old + ", " + v); + } + + @Override + public String getRequestProperty(String k) { + return req.get(k); + } + + @Override + public OutputStream getOutputStream() throws IOException { + if (resp != null) throw new IOException("already connected"); + if (body == null) body = new ByteArrayOutputStream(); + return body; + } + + @Override + public void connect() throws IOException { + if (resp != null) return; + byte[] data = body == null ? null : body.toByteArray(); + if (data != null && "GET".equals(verb)) verb = "POST"; // as HttpURLConnection does with doOutput + int timeout = Math.max(getConnectTimeout(), getReadTimeout()); + if (timeout <= 0) timeout = 30000; + String u = url.toString(); + try { + resp = getInstanceFollowRedirects() ? Http.request(verb, u, req, data, timeout) + : Http.requestNoRedirect(verb, u, req, data, timeout); + } catch (com.miniabs.net.CertVerifier.Untrusted e) { + throw Trust.rejected(e); // the trust prompt offers this certificate + } catch (com.minamp.bc.tls.TlsException e) { + throw new javax.net.ssl.SSLException(e.getMessage()); + } + connected = true; + responseCode = resp.code; + } + + @Override + public int getResponseCode() throws IOException { + connect(); + return resp.code; + } + + @Override + public String getResponseMessage() throws IOException { + connect(); + String s = resp.status; + int a = s.indexOf(' '), b = a < 0 ? -1 : s.indexOf(' ', a + 1); + return b < 0 ? "" : s.substring(b + 1); + } + + @Override + public InputStream getInputStream() throws IOException { + connect(); + if (resp.code >= 400) throw new FileNotFoundException(url.toString()); + return resp.body; + } + + @Override + public InputStream getErrorStream() { + return resp != null && resp.code >= 400 ? resp.body : null; + } + + @Override + public String getHeaderField(String name) { + if (name == null) return resp == null ? null : resp.status; + try { + connect(); + } catch (IOException e) { + return null; + } + return resp.header(name); + } + + private List> entries() { + try { + connect(); + } catch (IOException e) { + return Collections.emptyList(); + } + return new ArrayList>(resp.headers.entrySet()); + } + + @Override + public String getHeaderFieldKey(int n) { + List> e = entries(); + return n >= 0 && n < e.size() ? e.get(n).getKey() : null; + } + + @Override + public String getHeaderField(int n) { + List> e = entries(); + return n >= 0 && n < e.size() ? e.get(n).getValue() : null; + } + + @Override + public Map> getHeaderFields() { + Map> m = new LinkedHashMap>(); + for (Map.Entry e : entries()) m.put(e.getKey(), Collections.singletonList(e.getValue())); + return m; + } + + @Override + public int getContentLength() { + try { + connect(); + } catch (IOException e) { + return -1; + } + return resp.contentLength > Integer.MAX_VALUE ? -1 : (int) resp.contentLength; + } + + @Override + public void disconnect() { + if (resp != null) resp.close(); + } + + /** Cuts the connection from another thread (a blocked read then fails). */ + void abort() { + Http.abort(resp); + } + + @Override + public boolean usingProxy() { + return false; + } +} diff --git a/src/com/miniabs/PlayerService.java b/src/com/miniabs/PlayerService.java index b5a408c..97b26d9 100644 --- a/src/com/miniabs/PlayerService.java +++ b/src/com/miniabs/PlayerService.java @@ -62,7 +62,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList } private final Handler h = new Handler(); - private final ExecutorService net = Executors.newSingleThreadExecutor(); + private final ExecutorService net = com.miniabs.net.Threads.pool("player-net", 1); private AudioManager am; private WifiManager.WifiLock wifi; diff --git a/src/com/miniabs/Roots.java b/src/com/miniabs/Roots.java deleted file mode 100644 index f59b07d..0000000 --- a/src/com/miniabs/Roots.java +++ /dev/null @@ -1,226 +0,0 @@ -package com.miniabs; - -/** Generated by tools/gen_roots.py from the system CA store. Do not edit. */ -final class Roots { - private Roots() { - } - - static final String[] DER_BASE64 = { - // ISRG_Root_X1 sha256=96bcec06264976f37460779acf28c5a7cfe8a3c0aae11a8ffcee05c0bddf08c6 - "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIElu" - + "dGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUw" - + "NjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNV" - + "BAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZX" - + "oHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDm" - + "gK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rg" - + "TBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3" - + "x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJ" - + "xQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8B" - + "CNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo" - + "//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTAD" - + "AQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1" - + "XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo7" - + "7x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNz" - + "ktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3J" - + "LF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc" - + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7" - + "y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcY" - + "xn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=", - // GTS_Root_R1 sha256=d947432abde7b7fa90fc2e6b59101b1280e0e1c7e4e40fa3c6887fff57a7f4cf - "MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xl" - + "IFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAwWjBH" - + "MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIi" - + "MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo27xqLpeXo4xL+Sv2" - + "sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7wCl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g" - + "4M/SCYe7zUjwTcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0PfyblqAj+lug8aJRT7oM6iCsVlgmy" - + "4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4" - + "Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Or" - + "p2yjj32JgfpMpf/VjsPOS+C12LOORc92wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70paDPv" - + "OmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1" - + "/oFB2ACYPTrIrnqYNxgFlQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU5K8rJnEa" - + "K0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibbC5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmm" - + "WJyq+bXmYOfg6LEeQkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuyh6f88/qBVRRiClmpIgUx" - + "PoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM47HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/K" - + "YnAK9y8JZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6EfMgfdG+RCzgwARWGAtQsgWSl4vflV" - + "y2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT" - + "6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNb" - + "oTXns5lndcEZOitHTtNCjv0xyBZm2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bbbP6MvPJw" - + "NQzcmRk13NfIRmPVNnGuV/u3gm3c", - // USERTrust_RSA_Certification_Authority sha256=e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2 - "MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5l" - + "dyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVT" - + "RVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkG" - + "A1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNU" - + "IE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUA" - + "A4ICDwAwggIKAoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0" - + "X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+" - + "bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47" - + "aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Pl" - + "fg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaD" - + "zLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRj" - + "L7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+C" - + "DwIDAQABo0IwQDAdBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w" - + "DQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtO" - + "uowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeY" - + "alltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR" - + "iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5o" - + "GzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL3" - + "9ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw" - + "sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfGjjxDah2nGN59PRbx" - + "YvnKkKj9", - // DigiCert_Global_Root_G2 sha256=cb3ccbb76031e5e0138f8dd39a23f9de47ffc35e43c1144cea27d46a5ab1cb5f - "MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln" - + "aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0x" - + "MzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsT" - + "EHd3dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A" - + "MIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULec" - + "YxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hd" - + "G82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9" - + "g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB" - + "/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9" - + "PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/J" - + "sw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FB" - + "cf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyN" - + "xF1WW6LZZm6zNTflMrY=", - // Amazon_Root_CA_1 sha256=8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e - "MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMG" - + "QW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UE" - + "BhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC" - + "AQoCggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM9O6II8c+6zf1tRn4" - + "SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mj" - + "UcAwhmahRWa6VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7" - + "LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E" - + "BAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYo" - + "u6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gD" - + "S63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1" - + "bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZs" - + "G4q5WTP468SQvvG5", - // Microsoft_RSA_Root_Certificate_Authority_2017 sha256=c741f70f4b2a8d88bf2e71c14122ef53ef10eba0cfa5e64cfa20f418853073e0 - "MIIFqDCCA5CgAwIBAgIQHtOXCV/YtLNHcB6qvn9FszANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWlj" - + "cm9zb2Z0IENvcnBvcmF0aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcw" - + "HhcNMTkxMjE4MjI1MTIyWhcNNDIwNzE4MjMwMDIzWjBlMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0" - + "aW9uMTYwNAYDVQQDEy1NaWNyb3NvZnQgUlNBIFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTcwggIiMA0GCSqGSIb3DQEB" - + "AQUAA4ICDwAwggIKAoICAQDKW76UM4wplZEWCpW9R2LBifOZNt9GkMml7Xhqb0eRaPgnZ1AzHaGm++DlQ6OEAlcBXZxIQIJTELy/" - + "xztokLaCLeX0ZdDMbRnMlfl7rEqUrQ7eS0MdhweSE5CAg2Q1OQT85elss7YfUJQ4ZVBcF0a5toW1HLUX6NZFndiyJrDKxHBKrmCk" - + "3bPZ7Pw71VdyvD/IybLeS2v4I2wDwAW9lcfNcztmgGTjGqwu+UcF8ga2m3P1eDNbx6H7JyqhtJqRjJHTOoI+dkC0zVJhUXAoP8XF" - + "WvLJjEm7FFtNyP9nTUwSlq31/niol4fX/V4ggNyhSyL71Imtus5Hl0dVe49FyGcohJUcaDDv70ngNXtk55iwlNpNhTs+VcQor1fz" - + "nhPbRiefHqJeRIOkpcrVE7NLP8TjwuaGYaRSMLl6IE9vDzhTyzMMEyuP1pq9KsgtsRx9S1HKR9FIJ3Jdh+vVReZIZZ2vUpC6W6IY" - + "ZVcSn2i51BVrlMRpIpj0M+Dt+VGOQVDJNE92kKz8OMHY4Xu54+OU4UZpyw4KUGsTuqwPN1q3ErWQgR5WrlcihtnJ0tHXUeOrO8ZV" - + "/R4O03QK0dqq6mm4lyiPSMQH+FJDOvTKVTUssKZqwJz58oHhEmrARdlns87/I6KJClTUFLkqqNfs+avNJVgyeY+QW5g5xAgGwax/" - + "Dj0ApQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUCctZf4aycI8awznjwNnpv7tN" - + "siMwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQEMBQADggIBAKyvPl3CEZaJjqPnktaXFbgToqZCLgLNFgVZJ8og6Lq46Brs" - + "TaiXVq5lQ7GPAJtSzVXNUzltYkyLDVt8LkS/gxCP81OCgMNPOsduET/m4xaRhPtthH80dK2Jp86519efhGSSvpWhrQlTM93uCupK" - + "UY5vVau6tZRGrox/2KJQJWVggEbbMwSubLWYdFQl3JPk+ONVFT24bcMKpBLBaYVu32TxU5nhSnUgnZUP5NbcA/FZGOhHibJXWpS2" - + "qdgXKxdJ5XbLwVaZOjex/2kskZGT4d9Mozd2TaGf+G0eHdP67Pv0RR0Tbc/3WeUiJ3IrhvNXuzDtJE3cfVa7o7P4NHmJweDyAmH3" - + "pvwPuxwXC65B2Xy9J6P9LjrRk5Sxcx0ki69bIImtt2dmefU6xqaWM/5TkshGsRGRxpl/j8nWZjEgQRCHLQzWwa80mMpkg/sTV9HB" - + "8Dx6jKXB/ZUhoHHBk2dxEuqPiAppGWSZI1b7rCoucL5mxAyE7+WL85MB+GqQk2dLsmijtWKP6T+MejteD+eMuMZ87zf9dOLITzNy" - + "4ZQ5bb0Sr74MTnB8G2+NszKTc0QWbej09+CVgI+WXTik9KveCjCHk9hNAHFiRSdLOkKEW39lt2c0Ui2cFmuqqNh7o0JMcccMyj6D" - + "5KbvtwEwXlGjefVwaaZBRA+GsCyRxj3qrg+E", - // SSL.com_Root_Certification_Authority_RSA sha256=85666a562ee0be5ce925c1d8890a6f76a87ec16d4d7d5f29ea7419cf20123b69 - "MIIF3TCCA8WgAwIBAgIIeyyb0xaAMpkwDQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxDjAMBgNVBAgMBVRleGFzMRAwDgYD" - + "VQQHDAdIb3VzdG9uMRgwFgYDVQQKDA9TU0wgQ29ycG9yYXRpb24xMTAvBgNVBAMMKFNTTC5jb20gUm9vdCBDZXJ0aWZpY2F0aW9u" - + "IEF1dGhvcml0eSBSU0EwHhcNMTYwMjEyMTczOTM5WhcNNDEwMjEyMTczOTM5WjB8MQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVGV4" - + "YXMxEDAOBgNVBAcMB0hvdXN0b24xGDAWBgNVBAoMD1NTTCBDb3Jwb3JhdGlvbjExMC8GA1UEAwwoU1NMLmNvbSBSb290IENlcnRp" - + "ZmljYXRpb24gQXV0aG9yaXR5IFJTQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPkP3aMrfcvQKv7sZ4Wm5y4bunfh" - + "4/WvpOz6Sl2RxFdHaxh3a3by/ZPkPQ/CFp4LZsNWlJ4Xg4XOVu/yFv0AYvUiCVToZRdOQbngT0aXqhvIuG5iXmmxX9sqAn78bMrz" - + "Qdjt0Oj8P2FI7bADFB0QDksZ4LtO7IZl/zbzXmcCC52GVWH9ejjt/uIZALdvoVBidXQ8oPrIJZK0bnoix/geoeOy3ZExqysdBP+l" - + "SgQ36YWkMyv94tZVNHwZpEpox7Ko07fKoZOI68GXvIz5HdkihCR0xwQ9aqkpk8zruFvh/l8lqjRYyMEjVJ0bmBHDOJx+PYZspQ9A" - + "hnwC9FwCTyjLrnGfDzrIM/4RJTXq/LrFYD3ZfBjVsqnTdXgDciLKOsMf7yzlLqn6niy2UUb9rwPW6mBo6oUWNmuF6R7As93EJNyA" - + "KoFBbZQ+yODJgUEAnl6/f8UImKIYLEJAs/lvOCdLToD0PYFH4Ih86hzOtXVcUS4cK38acijnALXRdMbX5J+tB5O2UzU1/Dfkw/Zd" - + "Fr4hc96SCvigY2q8lpJqPvi8ZVWb3vUNiSYE/CUapiVpy8JtynziWV+XrOvvLsi81xtZPCvM8hnIk2snYxnP/Okm+Mpxm3+T/jRn" - + "hE6Z6/yzeAkzcLpmpnbtG3PrGqUNxCITIJRWCk4sbE6x/c+cCbqiM+2HAgMBAAGjYzBhMB0GA1UdDgQWBBTdBAkHovV6fVJTEpKV" - + "7jiAJQ2mWTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFN0ECQei9Xp9UlMSkpXuOIAlDaZZMA4GA1UdDwEB/wQEAwIBhjAN" - + "BgkqhkiG9w0BAQsFAAOCAgEAIBgRlCn7Jp0cHh5wYfGVcpNxJK1ok1iOMq8bs3AD/CUrdIWQPXhq9LmLpZc7tRiRux6n+UBbkflV" - + "ma8eEdBcHadm47GUBwwyOabqG7B52B2ccETjit3E+ZUfijhDPwGFpUenPUayvOUiaPd7nNgsPgohyC0zrL/FgZkxdMF1ccW+sfAj" - + "RfSda/wZY52jvATGGAslu1OJD7OAUN5F7kR/q5R4ZJjT9ijdh9hwZXT7DrkT66cPYakylszeu+1jTBi7qUD3oFRuIIhxdRjqerQ0" - + "cuAjJ3dctpDqhiVAq+8zD8ufgr6iIPv2tS0a5sKFsXQP+8hlAqRSAUfdSSLBv9jra6x+3uxjMxW3IwiPxg+NQVrdjsW5j+VFP3jb" - + "utIbQLH+cU0/4IGiul607BXgk90IH37hVZkLId6Tngr75qNJvTYw/ud3sqB1l7UtgYgXZSD32pAAn8lSzDLKNXz1PQ/YK9f1JmzJ" - + "BjSWFupwWRoyeXkLtoh/D1JIPb9s2KJELtFOt3JY04kTlf5Eq/jXixtunLwsoFvVagCvXzfh1foQC5ichucmj87w7G6KVwuA406y" - + "wKBjYZC6VWg3dGq2ktufoYYitmUnDuy2n0Jg5GfCtdpBC8TTi2EbvPofkSvXRAdeuims2cXp71NIWuuA8ShYIc2wBlX7Jz9TkHCp" - + "BB5XJ7k=", - // Buypass_Class_2_Root_CA sha256=9a114025197c5bb95d94e63d55cd43790847b646b23cdf11ada4a00eff15fb48 - "MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwUQnV5cGFzcyBBUy05ODMxNjMz" - + "MjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4XDTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkG" - + "A1UEBhMCTk8xHTAbBgNVBAoMFEJ1eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCC" - + "AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1g1Lr6hxhFUi7HQfKjK6w" - + "3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fU" - + "l8FVM5I+GC911K2GScuVr1QGbNgGE41b/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFO" - + "Bz3cFIqUCqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeffawrbD02TTqigzXsu" - + "8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgIzRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPs" - + "aRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhnBkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/Ti" - + "zPJhk9H9Z2vXUq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHsM+71bbRuMGje" - + "yNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605" - + "MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6um" - + "wHJdELFx7rxP462sA20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EIosHsHdb9" - + "T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48Saq/v7h56rgJKihcrdv6sVIkkLE8/trKn" - + "ToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYdDnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6A" - + "DJHgJgg2SMX6OBE1/yWDLfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0oyLQ" - + "I+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGCwWg5TbSYWGZizEqQXsP6JwSxeRV0" - + "mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koSCTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv" - + "4x3kqdbQCtCev9eBCfHJxyYNrJgWVqA=", - // HARICA_TLS_RSA_Root_CA_2021 sha256=d95d0e8eda79525bf9beb11b14d2100d3294985f0c62d9fabd9cd999eccb7b1d - "MIIFpDCCA4ygAwIBAgIQOcqTHO9D88aOk8f0ZIk4fjANBgkqhkiG9w0BAQsFADBsMQswCQYDVQQGEwJHUjE3MDUGA1UECgwuSGVs" - + "bGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDQTEkMCIGA1UEAwwbSEFSSUNBIFRMUyBSU0EgUm9vdCBD" - + "QSAyMDIxMB4XDTIxMDIxOTEwNTUzOFoXDTQ1MDIxMzEwNTUzN1owbDELMAkGA1UEBhMCR1IxNzA1BgNVBAoMLkhlbGxlbmljIEFj" - + "YWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ0ExJDAiBgNVBAMMG0hBUklDQSBUTFMgUlNBIFJvb3QgQ0EgMjAyMTCC" - + "AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIvC569lmwVnlskNJLnQDmT8zuIkGCyEf3dRywQRNrhe7Wlxp57kJQmXZ8FH" - + "ws+RFjZiPTgE4VGC/6zStGndLuwRo0Xua2s7TL+MjaQenRG56Tj5eg4MmOIjHdFOY9TnuEFE+2uva9of08WRiFukiZLRgeaMOVig" - + "1mlDqa2YUlhu2wr7a89o+uOkXjpFc5gH6l8Cct4MpbOfrqkdtx2z/IpZ525yZa31MJQjB/OCFks1mJxTuy/K5FrZx40d/JiZ+yyk" - + "gmvwKh+OC19xXFyuQnspiYHLA6OZyoieC0AJQTPb5lh6/a6ZcMBaD9YThnEvdmn8kN3bLW7R8pv1GmuebxWMevBLKKAiOIAkbDak" - + "O/IwkfN4E8/BPzWr8R0RI7VDIp4BkrcYAuUR0YLbFQDMYTfBKnya4dC6s1BG7oKsnTH4+yPiAwBIcKMJJnkVU2DzOFytOOqBAGMU" - + "uTNe3QvboEUHGjMJ+E20pwKmafTCWQWIZYVWrkvL4N48fS0ayOn7H6NhStYqE613TBoYm5EPWNgGVMWX+Ko/IIqmhaZ39qb8HOLu" - + "bpQzKoNQhArlT4b4UEV4AIHrW2jjJo3Me1xR9BQsQL4aYB16cmEdH2MtiKrOokWQCPxrvrNQKlr9qEgYRtaQQJKQCoReaDH46+0N" - + "0x3GfZkYVVYnZS6NRcUk7M7jAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFApII6ZgpJIKM+qTW8VX6iVNvRLu" - + "MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAPpBIqm5iFSVmewzVjIuJndftTgfvnNAUX15QvWiWkKQUEapobQk1" - + "OUAJ2vQJLDSle1mESSmXdMgHHkdt8s4cUCbjnj1AUz/3f5Z2EMVGpdAgS1D0NTsY9FVqQRtHBmg8uwkIYtlfVUKqrFOFrJVWNlar" - + "5AWMxajaH6NpvVMPxP/cyuN+8kyIhkdGGvMA9YCRotxDQpSbIPDRzbLrLFPCU3hKTwSUQZqPJzLB5UkZv/HywouoCjkxKLR9YjYs" - + "TewfM7Z+d21+UPCfDtcRj88YxeMn/ibvBZ3PzzfF0HvaO7AWhAw6k9a+F9sPPg4ZeAnHqQJyIkv3N3a6dcSFA1pj1bF1BcK5vZSt" - + "jBWZp5N99sXzqnTPBIWUmAD04vnKJGW/4GKvyMX6ssmeVkjaef2WdhW+o45WxLM0/L5H9MG0qPzVMIho7suuyWPEdr6sOBjhXlzP" - + "rjoiUevRi7PzKzMHVIf6tLITe7pTBGIBnfHAT+7hOtSLIBD6Alfm78ELt5BGnBkpjNxvoEppaZS3JGWg/6w/zgH7IS79aPib8qXP" - + "MThcFarmlwDB31qlpzmq6YR/PFGoOtmUW4y/Twhx5duoXNTSpv4Ao8YWxw/ogM4cKGR0GQjTQuPOAF1/sdwTsOEFy9EgqoZ0njnn" - + "kf3/W9b3raYvAwtt41dU63ZTGI0RmLo=", - // Certum_Trusted_Network_CA sha256=5c58468d55f58e497e743982d2b50010b6d165374acf83a7d4a32db768c4408e - "MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQKExlVbml6ZXRvIFRlY2hub2xv" - + "Z2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVk" - + "IE5ldHdvcmsgQ0EwHhcNMDgxMDIyMTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0" - + "byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSIwIAYDVQQDExlDZXJ0" - + "dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7pt" - + "J11/91sts1rHUV+rpDKmYYe2bg+G0jACl/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jU" - + "I4ZZJ88JJ7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4fOQtf/WsX+sWn7Et" - + "0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5" - + "p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8B" - + "Af8EBAMCAQYwDQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCjjSfeC4Jj0O7e" - + "DDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3" - + "zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5ajZt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssD" - + "xLQqKi2WF+A5VLxI03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw=", - // Go_Daddy_Root_Certificate_Authority_-_G2 sha256=45140b3247eb9cc8c5b4f0d7b53091f73292089e6e5a63e2749dd3aca9198eda - "MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcT" - + "ClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMuMTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRl" - + "IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdB" - + "cml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8GA1UEAxMoR28gRGFkZHkg" - + "Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJ" - + "GKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT" - + "5LY/qQOD+qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutdfMh8+7ArU6SSYmlR" - + "JQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMlNAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdY" - + "q7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqa" - + "hQcQZyi27/a9BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096acvNjpL9DbWu7P" - + "dIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD0" - + "2JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYVN8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPK" - + "f1855eJ1usV2GDPOLPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1", - // Starfield_Root_Certificate_Authority_-_G2 sha256=2ce1cb0bf9d2f9e102993fbe215152c3b2dd0cabde1c68e5319b839154dbb7f5 - "MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcT" - + "ClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290" - + "IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVT" - + "MRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVjaG5vbG9naWVzLCBJ" - + "bmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQAD" - + "ggEPADCCAQoCggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdh" - + "l+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNM" - + "Yq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP" - + "4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV" - + "HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+C" - + "hTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu34jd/IroAow57" - + "UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6" - + "nH7PfrHxBy22/L/KpL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZc2T5NnRe" - + "JaH1ZgUufzkVqSr7UIuOhWn0", - }; -} diff --git a/src/com/miniabs/StreamProxy.java b/src/com/miniabs/StreamProxy.java index 41f9bfe..7d4decf 100644 --- a/src/com/miniabs/StreamProxy.java +++ b/src/com/miniabs/StreamProxy.java @@ -37,7 +37,7 @@ final class StreamProxy implements Runnable { private StreamProxy(Context c) throws IOException { this.c = c.getApplicationContext(); server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1")); - Thread t = new Thread(this, "MiniABS proxy"); + Thread t = com.miniabs.net.Threads.create(this, "MiniABS proxy"); t.setDaemon(true); t.start(); } @@ -85,7 +85,8 @@ final class StreamProxy implements Runnable { while (running) { try { final Socket s = server.accept(); - Thread t = new Thread(new Runnable() { + // big stack: the upstream TLS handshake overflows Android 2.x's default one + Thread t = com.miniabs.net.Threads.create(new Runnable() { public void run() { serve(s); } diff --git a/src/com/miniabs/Trust.java b/src/com/miniabs/Trust.java index d70d352..8f93ad4 100644 --- a/src/com/miniabs/Trust.java +++ b/src/com/miniabs/Trust.java @@ -2,7 +2,6 @@ package com.miniabs; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.security.KeyStore; import java.security.MessageDigest; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; @@ -15,21 +14,18 @@ import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; +import javax.net.ssl.SSLException; import android.content.Context; import android.util.Base64; +import com.miniabs.net.AssetRoots; +import com.miniabs.net.CertVerifier; + /** - * Decides which HTTPS certificates MiniABS accepts: - * 1. anything the phone itself trusts; - * 2. certificates chaining to the modern roots in {@link Roots} (Android 2.3's list predates Let's Encrypt); - * 3. certificates the user chose to trust after checking the fingerprint ("pins"). + * Decides which HTTPS certificates MiniABS accepts, for the bundled TLS 1.2 client: + * 1. certificates chaining to Mozilla's roots (assets/roots; Android 2.3's own list predates Let's Encrypt); + * 2. certificates the user chose to trust after checking the fingerprint ("pins"). */ final class Trust { private Trust() { @@ -54,26 +50,49 @@ final class Trust { static volatile Rejected lastRejected; - private static SSLSocketFactory factory; - private static List bundled; + private static volatile boolean installed; // ------------------------------------------------------------------ setup - static synchronized SSLSocketFactory factory(Context c) throws IOException { - if (factory == null) { - try { - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(null, new TrustManager[]{new Manager(c.getApplicationContext())}, null); - factory = ctx.getSocketFactory(); - } catch (Exception e) { - throw new IOException("As conexións seguras non están dispoñibles: " + e.getMessage()); - } + /** Bundled Mozilla roots plus the certificates the user chose to trust, for the TLS 1.2 client. */ + static void install(Context c) { + if (installed) return; + synchronized (Trust.class) { + if (installed) return; + final Context app = c.getApplicationContext(); + final AssetRoots mozilla = new AssetRoots(app); + CertVerifier.msgMismatch = "O certificado do servidor é doutro enderezo (%1$s)."; + CertVerifier.msgDates = "O certificado do servidor caducou ou a data do teléfono é incorrecta."; + CertVerifier.setRootStore(new CertVerifier.RootStore() { + public List find(byte[] subjectDer) { + List out = mozilla.find(subjectDer); + for (X509Certificate x : pinned(app)) { + if (!Arrays.equals(x.getSubjectX500Principal().getEncoded(), subjectDer)) continue; + if (out == null) out = new ArrayList(); + out.add(encoded(x)); + } + return out; + } + }); + installed = true; } - return factory; } - static void apply(Context c, HttpsURLConnection conn) throws IOException { - conn.setSSLSocketFactory(factory(c)); + /** The pins changed: rebuild the store (which also drops cached chains and TLS sessions). */ + private static void reinstall(Context c) { + installed = false; + install(c); + } + + /** Records an untrusted chain for the trust prompt and returns the error to throw. */ + static IOException rejected(CertVerifier.Untrusted u) { + try { + X509Certificate[] chain = new X509Certificate[u.chain.size()]; + for (int i = 0; i < chain.length; i++) chain[i] = parse(u.chain.get(i)); + lastRejected = new Rejected(chain, "untrusted"); + } catch (CertificateException ignored) { + } + return new SSLException("Certificate not trusted"); } /** Recently rejected (this attempt), or null. */ @@ -82,21 +101,6 @@ final class Trust { return r != null && System.currentTimeMillis() - r.at < 60000 ? r : null; } - private static synchronized List bundled() { - if (bundled == null) { - List list = new ArrayList(); - for (String b64 : Roots.DER_BASE64) { - try { - list.add(parse(Base64.decode(b64, Base64.DEFAULT))); - } catch (Exception ignored) { - // a root this phone can't parse; skip it - } - } - bundled = list; - } - return bundled; - } - static X509Certificate parse(byte[] der) throws CertificateException { return (X509Certificate) CertificateFactory.getInstance("X.509") .generateCertificate(new ByteArrayInputStream(der)); @@ -128,14 +132,14 @@ final class Trust { } } catch (Exception ignored) { } - factory = null; lastRejected = null; + reinstall(c); return top; } static synchronized void forgetPins(Context c) { Prefs.get(c).sp.edit().remove("pins").commit(); - factory = null; + reinstall(c); } /** Walks up the chain as far as the server's own certificates go. */ @@ -152,85 +156,6 @@ final class Trust { // ------------------------------------------------------------------ verification - private static final class Manager implements X509TrustManager { - private final Context c; - private X509TrustManager system; - - Manager(Context c) { - this.c = c; - try { - TrustManagerFactory f = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - f.init((KeyStore) null); - for (TrustManager t : f.getTrustManagers()) { - if (t instanceof X509TrustManager) system = (X509TrustManager) t; - } - } catch (Exception ignored) { - // no system trust store: rely on bundled roots and pins - } - } - - public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { - if (chain == null || chain.length == 0) throw new CertificateException("No certificate"); - if (system != null) { - try { - system.checkServerTrusted(chain, authType); - return; - } catch (CertificateException ignored) { - // fall through to our own roots and pins - } catch (RuntimeException ignored) { - } - } - List anchors = new ArrayList(bundled()); - anchors.addAll(pinned(c)); - String why = verify(chain, anchors); - if (why == null) return; - lastRejected = new Rejected(chain.clone(), why); - throw new CertificateException("Certificate not trusted: " + why); - } - - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - throw new CertificateException("Not used"); - } - - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - } - - /** - * Returns null if chain[0] chains up to one of the anchors, or a short reason why not. - * Every certificate that signs another must be a CA, so a website's own certificate - * can't be used to forge one for a different site. - */ - static String verify(X509Certificate[] chain, List anchors) { - X509Certificate cur = chain[0]; - try { - cur.checkValidity(); - } catch (Exception e) { - return "expired or not yet valid (check the phone's date)"; - } - for (int step = 0; step < 8; step++) { - for (X509Certificate a : anchors) { - if (Arrays.equals(encoded(a), encoded(cur))) return null; // trusted as-is - } - for (X509Certificate a : anchors) { - if (a.getSubjectX500Principal().equals(cur.getIssuerX500Principal()) && isCa(a) && signedBy(cur, a)) { - return null; - } - } - X509Certificate next = issuerIn(cur, chain); - if (next == null) return "unknown issuer"; - if (!isCa(next)) return "signed by a certificate that isn't a CA"; - try { - next.checkValidity(); - } catch (Exception e) { - return "expired intermediate certificate (check the phone's date)"; - } - cur = next; - } - return "chain too long"; - } - private static X509Certificate issuerIn(X509Certificate cert, X509Certificate[] chain) { for (X509Certificate x : chain) { if (x == cert || Arrays.equals(encoded(x), encoded(cert))) continue; diff --git a/src/com/miniabs/Util.java b/src/com/miniabs/Util.java index f8a27df..dc1032c 100644 --- a/src/com/miniabs/Util.java +++ b/src/com/miniabs/Util.java @@ -111,8 +111,8 @@ final class Util { return r.expired() ? "O certificado do servidor caducou ou a data do teléfono é incorrecta." : "Este teléfono non recoñece o certificado do servidor."; } - return "Non se puido establecer unha conexión segura. Android 2.3 só admite TLS 1.0, así que o servidor " - + "precisa un porto HTTPS antigo (ve o README). Na rede da casa tamén funciona o enderezo http://."; + String why = e.getMessage(); + return "Non se puido establecer unha conexión segura" + (why != null && why.length() > 0 ? " (" + why + ")." : "."); } if (e instanceof SocketException) return "Cortouse a conexión. Comproba a rede."; if (e instanceof FileNotFoundException) return "Non se atopou no servidor."; diff --git a/src/com/miniabs/net/AssetRoots.java b/src/com/miniabs/net/AssetRoots.java new file mode 100644 index 0000000..6ed9198 --- /dev/null +++ b/src/com/miniabs/net/AssetRoots.java @@ -0,0 +1,50 @@ +package com.miniabs.net; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import android.content.Context; + +/** Mozilla roots shipped as assets/roots/-.der */ +public final class AssetRoots implements CertVerifier.RootStore { + private final Context ctx; + private Set names; + + public AssetRoots(Context c) { ctx = c.getApplicationContext(); } + + public synchronized List find(byte[] subjectDer) { + try { + if (names == null) { + names = new HashSet(); + String[] l = ctx.getAssets().list("roots"); + if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]); + } + String h = CertVerifier.sha1Hex(subjectDer); + List out = null; + for (int i = 0; names.contains(h + "-" + i + ".der"); i++) { + if (out == null) out = new ArrayList(); + out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der"))); + } + return out; + } catch (IOException e) { + return null; + } + } + + static byte[] readAll(InputStream in) throws IOException { + try { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) b.write(buf, 0, n); + return b.toByteArray(); + } finally { + in.close(); + } + } +} diff --git a/src/com/miniabs/net/CertVerifier.java b/src/com/miniabs/net/CertVerifier.java new file mode 100644 index 0000000..96a6ce1 --- /dev/null +++ b/src/com/miniabs/net/CertVerifier.java @@ -0,0 +1,331 @@ +package com.miniabs.net; + +import java.io.IOException; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import com.minamp.bc.asn1.ASN1Encoding; +import com.minamp.bc.asn1.ASN1ObjectIdentifier; +import com.minamp.bc.asn1.ASN1OctetString; +import com.minamp.bc.asn1.pkcs.PKCSObjectIdentifiers; +import com.minamp.bc.asn1.x500.RDN; +import com.minamp.bc.asn1.x500.X500Name; +import com.minamp.bc.asn1.x500.style.BCStyle; +import com.minamp.bc.asn1.x500.style.IETFUtils; +import com.minamp.bc.asn1.x509.BasicConstraints; +import com.minamp.bc.asn1.x509.Certificate; +import com.minamp.bc.asn1.x509.Extension; +import com.minamp.bc.asn1.x509.Extensions; +import com.minamp.bc.asn1.x509.GeneralName; +import com.minamp.bc.asn1.x509.GeneralNames; +import com.minamp.bc.asn1.x9.X9ObjectIdentifiers; +import com.minamp.bc.crypto.Digest; +import com.minamp.bc.crypto.digests.SHA1Digest; +import com.minamp.bc.crypto.digests.SHA256Digest; +import com.minamp.bc.crypto.digests.SHA384Digest; +import com.minamp.bc.crypto.digests.SHA512Digest; +import com.minamp.bc.crypto.params.AsymmetricKeyParameter; +import com.minamp.bc.crypto.signers.DSADigestSigner; +import com.minamp.bc.crypto.signers.ECDSASigner; +import com.minamp.bc.crypto.signers.RSADigestSigner; +import com.minamp.bc.crypto.util.PublicKeyFactory; + +/** + * Minimal Web PKI path validation for the bundled TLS stack: + * hostname match, validity window, signature chain up to a bundled Mozilla root. + * Optionally one pinned leaf certificate (SHA-256) is trusted as-is, for self-signed servers. + * Shared with Minamp (tools/tlskit in the iOS ports' history); keep the copies in sync. + */ +public final class CertVerifier { + + /** Texts shown to the user; apps replace them with their own language. Formats take the host. */ + public static String msgMismatch = "The server certificate is for a different address (%1$s)."; + public static String msgDates = "The server certificate has expired, or the phone's date is wrong."; + + /** Thrown when the certificate is neither CA-trusted nor the pinned one: the app can offer to trust it. */ + public static final class Untrusted extends IOException { + public final String fingerprint, subject, issuer, expires; + public final boolean changed; // a pin was set and this is a different certificate + public final List chain; // as the server sent it, leaf first + + Untrusted(String fp, String subject, String issuer, String expires, boolean changed, List chain) { + super("untrusted certificate " + fp); + this.chain = chain; + this.fingerprint = fp; + this.subject = subject; + this.issuer = issuer; + this.expires = expires; + this.changed = changed; + } + } + + private static volatile String pin = ""; + + /** SHA-256 of the one leaf certificate to accept without a CA (hex, separators ignored); "" for none. */ + public static void setPin(String p) { + String n = norm(p); + if (!n.equals(pin)) { + pin = n; + Http.forgetSessions(); + } + } + + public static String norm(String s) { + if (s == null) return ""; + return s.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.US); + } + + /** "AB:CD:..." SHA-256 of a DER certificate. */ + public static String sha256(byte[] der) { + try { + byte[] d = MessageDigest.getInstance("SHA-256").digest(der); + StringBuilder b = new StringBuilder(); + for (int i = 0; i < d.length; i++) { + if (i > 0) b.append(':'); + String x = Integer.toHexString(d[i] & 0xff).toUpperCase(Locale.US); + if (x.length() == 1) b.append('0'); + b.append(x); + } + return b.toString(); + } catch (Exception e) { + return ""; + } + } + + private static Untrusted untrusted(List chainDer, boolean changed) { + byte[] leafDer = chainDer.get(0); + String subject = "?", issuer = "?", expires = "?"; + try { + Certificate c = Certificate.getInstance(leafDer); + subject = String.valueOf(c.getSubject()); + issuer = String.valueOf(c.getIssuer()); + expires = new java.text.SimpleDateFormat("yyyy-MM-dd", Locale.US).format(c.getEndDate().getDate()); + } catch (Exception ignored) {} + return new Untrusted(sha256(leafDer), subject, issuer, expires, changed, chainDer); + } + + /** Lookup of trusted root certificates (DER) by the DER encoding of their subject name. */ + public interface RootStore { + List find(byte[] subjectDer); + } + + private static RootStore roots; + /** Chains already proven to lead to a trusted root (keyed by a hash of the chain), so a + * reconnect only re-checks the host name and dates, not every signature again. */ + private static final java.util.Set verifiedChains = new java.util.HashSet(); + + /** Changing what's trusted invalidates everything trusted so far: cached chains and TLS sessions. */ + public static void setRootStore(RootStore store) { + roots = store; + synchronized (verifiedChains) { verifiedChains.clear(); } + Http.forgetSessions(); + } + + private CertVerifier() {} + + + public static void verify(List chainDer, String host) throws IOException { + if (chainDer == null || chainDer.isEmpty()) throw new IOException("Server sent no certificate"); + String p = pin; + if (p.length() > 0) { + // a pinned server: exactly that certificate, whatever its name or issuer + if (norm(sha256(chainDer.get(0))).equals(p)) return; + throw untrusted(chainDer, true); + } + if (roots == null) throw new IOException("No trust store configured"); + java.io.ByteArrayOutputStream all = new java.io.ByteArrayOutputStream(); + for (int i = 0; i < chainDer.size(); i++) all.write(chainDer.get(i), 0, chainDer.get(i).length); + String chainKey = sha1Hex(all.toByteArray()); + boolean known; + synchronized (verifiedChains) { known = verifiedChains.contains(chainKey); } + if (known) { + Certificate leaf = Certificate.getInstance(chainDer.get(0)); + if (!hostMatches(leaf, host)) throw new IOException(String.format(msgMismatch, host)); + if (new Date().getTime() > 1704067200000L) checkDates(leaf, new Date()); + return; + } + verifyFully(chainDer, host); + synchronized (verifiedChains) { verifiedChains.add(chainKey); } + } + + private static void verifyFully(List chainDer, String host) throws IOException { + final List chain0 = chainDer; + List chain = new ArrayList(); + for (int i = 0; i < chainDer.size(); i++) chain.add(Certificate.getInstance(chainDer.get(i))); + + Certificate leaf = chain.get(0); + if (!hostMatches(leaf, host)) { + throw new IOException(String.format(msgMismatch, host)); + } + + Date now = new Date(); + // An unset clock on an old phone (e.g. 1980 after a battery pull) would make every + // certificate look "not yet valid"; only enforce dates when the clock looks sane. + boolean clockSane = now.getTime() > 1704067200000L; // 2024-01-01 + + Certificate current = leaf; + for (int depth = 0; depth < 8; depth++) { + if (clockSane) checkDates(current, now); + + byte[] issuerDer = current.getIssuer().getEncoded(ASN1Encoding.DER); + + // 1. Anchored directly by a trusted root? + List anchors = roots.find(issuerDer); + if (anchors != null) { + byte[] currentDer = current.getEncoded(ASN1Encoding.DER); + for (int i = 0; i < anchors.size(); i++) { + // the very certificate the user chose to trust (e.g. self-signed) + if (java.util.Arrays.equals(anchors.get(i), currentDer)) return; + Certificate root = Certificate.getInstance(anchors.get(i)); + // anything that signs must be a CA: a trusted site's own certificate can't vouch for another + if (isCa(root) && signatureValid(current, root)) return; // trusted + } + } + + // 2. Otherwise find the issuing intermediate among what the server sent. + Certificate next = null; + for (int i = 1; i < chain.size(); i++) { + Certificate cand = chain.get(i); + if (cand == current) continue; + if (!java.util.Arrays.equals(cand.getSubject().getEncoded(ASN1Encoding.DER), issuerDer)) continue; + if (!isCa(cand)) continue; + if (signatureValid(current, cand)) { next = cand; break; } + } + if (next == null) throw untrusted(chain0, false); + current = next; + } + throw new IOException("Certificate chain too long"); + } + + private static void checkDates(Certificate c, Date now) throws IOException { + Date from = c.getStartDate().getDate(); + Date to = c.getEndDate().getDate(); + if (now.before(from) || now.after(to)) { + throw new IOException(msgDates); + } + } + + private static boolean isCa(Certificate c) { + Extensions ext = c.getTBSCertificate().getExtensions(); + if (ext == null) return c.getTBSCertificate().getVersionNumber() < 3; // v1 roots/intermediates + BasicConstraints bc = BasicConstraints.fromExtensions(ext); + return bc != null && bc.isCA(); + } + + static boolean signatureValid(Certificate subject, Certificate issuer) { + try { + ASN1ObjectIdentifier alg = subject.getSignatureAlgorithm().getAlgorithm(); + byte[] tbs = subject.getTBSCertificate().getEncoded(ASN1Encoding.DER); + byte[] sig = subject.getSignature().getOctets(); + AsymmetricKeyParameter key = PublicKeyFactory.createKey(issuer.getSubjectPublicKeyInfo()); + + if (alg.equals(PKCSObjectIdentifiers.sha256WithRSAEncryption)) return rsa(new SHA256Digest(), key, tbs, sig); + if (alg.equals(PKCSObjectIdentifiers.sha384WithRSAEncryption)) return rsa(new SHA384Digest(), key, tbs, sig); + if (alg.equals(PKCSObjectIdentifiers.sha512WithRSAEncryption)) return rsa(new SHA512Digest(), key, tbs, sig); + if (alg.equals(PKCSObjectIdentifiers.sha1WithRSAEncryption)) return rsa(new SHA1Digest(), key, tbs, sig); + if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA256)) return ecdsa(new SHA256Digest(), key, tbs, sig); + if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA384)) return ecdsa(new SHA384Digest(), key, tbs, sig); + if (alg.equals(X9ObjectIdentifiers.ecdsa_with_SHA512)) return ecdsa(new SHA512Digest(), key, tbs, sig); + return false; + } catch (Exception e) { + return false; + } + } + + private static boolean rsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) { + RSADigestSigner s = new RSADigestSigner(d); + s.init(false, key); + s.update(tbs, 0, tbs.length); + return s.verifySignature(sig); + } + + private static boolean ecdsa(Digest d, AsymmetricKeyParameter key, byte[] tbs, byte[] sig) { + DSADigestSigner s = new DSADigestSigner(new ECDSASigner(), d); + s.init(false, key); + s.update(tbs, 0, tbs.length); + return s.verifySignature(sig); + } + + // ---- hostname checking ---- + + static boolean hostMatches(Certificate leaf, String host) { + host = host.toLowerCase(Locale.US); + boolean ip = isIpLiteral(host); + boolean sawDns = false; + Extensions ext = leaf.getTBSCertificate().getExtensions(); + if (ext != null) { + GeneralNames names = GeneralNames.fromExtensions(ext, Extension.subjectAlternativeName); + if (names != null) { + GeneralName[] arr = names.getNames(); + for (int i = 0; i < arr.length; i++) { + GeneralName n = arr[i]; + if (n.getTagNo() == GeneralName.dNSName) { + sawDns = true; + // String.valueOf, not getName().toString(): Dalvik < 4.0 can't call Object methods via an interface + if (!ip && dnsMatch(String.valueOf(n.getName()), host)) return true; + } else if (n.getTagNo() == GeneralName.iPAddress && ip) { + byte[] a = ASN1OctetString.getInstance(n.getName()).getOctets(); + if (ipString(a).equals(host)) return true; + } + } + } + } + if (!sawDns && !ip) { + X500Name subject = X500Name.getInstance(leaf.getSubject()); + RDN[] cns = subject.getRDNs(BCStyle.CN); + for (int i = 0; i < cns.length; i++) { + String cn = IETFUtils.valueToString(cns[i].getFirst().getValue()); + if (dnsMatch(cn, host)) return true; + } + } + return false; + } + + static boolean dnsMatch(String pattern, String host) { + pattern = pattern.toLowerCase(Locale.US); + if (pattern.endsWith(".")) pattern = pattern.substring(0, pattern.length() - 1); + if (host.endsWith(".")) host = host.substring(0, host.length() - 1); + if (pattern.startsWith("*.")) { + String suffix = pattern.substring(1); // ".example.com" + if (!host.endsWith(suffix)) return false; + String label = host.substring(0, host.length() - suffix.length()); + return label.length() > 0 && label.indexOf('.') < 0; + } + return pattern.equals(host); + } + + static boolean isIpLiteral(String host) { + if (host.indexOf(':') >= 0) return true; + for (int i = 0; i < host.length(); i++) { + char c = host.charAt(i); + if (!(c == '.' || (c >= '0' && c <= '9'))) return false; + } + return true; + } + + private static String ipString(byte[] a) { + if (a.length == 4) { + return (a[0] & 255) + "." + (a[1] & 255) + "." + (a[2] & 255) + "." + (a[3] & 255); + } + return ""; + } + + public static String sha1Hex(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] h = md.digest(data); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < h.length; i++) { + String s = Integer.toHexString(h[i] & 255); + if (s.length() == 1) sb.append('0'); + sb.append(s); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/com/miniabs/net/DiagLog.java b/src/com/miniabs/net/DiagLog.java new file mode 100644 index 0000000..5d4c2e5 --- /dev/null +++ b/src/com/miniabs/net/DiagLog.java @@ -0,0 +1,47 @@ +package com.miniabs.net; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * In-memory diagnostics log (last few hundred lines): every request with its status and timing, + * sign-in steps, stream attempts. Viewable in the app and savable to the SD card, so a problem + * on a real server can be reported precisely. Tokens are never logged. + */ +public final class DiagLog { + private static final int MAX = 400; + private static final List lines = new ArrayList(); + private static final SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.US); + + private DiagLog() {} + + public static void log(String msg) { + String line; + synchronized (fmt) { line = fmt.format(new Date()) + " " + msg; } + synchronized (lines) { + lines.add(line); + if (lines.size() > MAX) lines.remove(0); + } + } + + public static String dump() { + StringBuilder sb = new StringBuilder(); + synchronized (lines) { + for (int i = 0; i < lines.size(); i++) sb.append(lines.get(i)).append('\n'); + } + return sb.toString(); + } + + public static void clear() { + synchronized (lines) { lines.clear(); } + } + + /** URL without the token value, safe to show and share. */ + public static String redact(String url) { + if (url == null) return ""; + return url.replaceAll("((?:X-Plex-Token|token|T|Passwd)=)[^&]*", "$1***"); + } +} diff --git a/src/com/miniabs/net/Http.java b/src/com/miniabs/net/Http.java new file mode 100644 index 0000000..5c285a2 --- /dev/null +++ b/src/com/miniabs/net/Http.java @@ -0,0 +1,588 @@ +package com.miniabs.net; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Vector; + +import com.minamp.bc.tls.DefaultTlsClient; +import com.minamp.bc.tls.NameType; +import com.minamp.bc.tls.ServerName; +import com.minamp.bc.tls.TlsAuthentication; +import com.minamp.bc.tls.TlsClientProtocol; +import com.minamp.bc.tls.TlsCredentials; +import com.minamp.bc.tls.TlsServerCertificate; +import com.minamp.bc.tls.TlsSession; +import com.minamp.bc.tls.CertificateRequest; +import com.minamp.bc.tls.crypto.TlsCrypto; +import com.minamp.bc.tls.crypto.impl.bc.BcTlsCrypto; + +/** + * Small HTTP/1.1 client with its own TLS 1.2 (Bouncy Castle), keep-alive pooling and + * session resumption. Android 2.x's built-in SSL only speaks TLS 1.0 and lacks today's roots, + * so every request in the app goes through here. From Minamp; keep the copies in sync. + */ +public final class Http { + + public static String USER_AGENT = "Minamp/1.0"; + + /** Optional HTTP CONNECT proxy, only used when running the networking code on a desktop JVM. */ + public static String proxyHost; + public static int proxyPort; + + private static TlsCrypto crypto; + private static final Map sessions = new HashMap(); + private static final Map> pool = new HashMap>(); + private static final long IDLE_MS = 25000; + + private Http() {} + + private static synchronized TlsCrypto crypto() { + if (crypto == null) crypto = new BcTlsCrypto(new SecureRandom()); + return crypto; + } + + // ------------------------------------------------------------------ URL + + public static final class Url { + public final boolean https; + public final String host; + public final int port; + public final String path; // includes query + + public Url(String s) throws IOException { + String lower = s.toLowerCase(Locale.US); + int start; + if (lower.startsWith("https://")) { https = true; start = 8; } + else if (lower.startsWith("http://")) { https = false; start = 7; } + else throw new IOException("Bad URL: " + s); + int slash = s.indexOf('/', start); + String hostPort = slash < 0 ? s.substring(start) : s.substring(start, slash); + path = slash < 0 ? "/" : s.substring(slash); + int colon = hostPort.lastIndexOf(':'); + if (colon > 0 && hostPort.indexOf(']') < colon) { + host = hostPort.substring(0, colon); + try { port = Integer.parseInt(hostPort.substring(colon + 1)); } + catch (NumberFormatException e) { throw new IOException("Bad port in " + s); } + } else { + host = hostPort; + port = https ? 443 : 80; + } + } + + String key() { return (https ? "s:" : "p:") + host + ":" + port; } + + String hostHeader() { + boolean dflt = (https && port == 443) || (!https && port == 80); + return dflt ? host : host + ":" + port; + } + } + + // ------------------------------------------------------------------ connection + + static final class Conn { + final String key; + Socket socket; + InputStream in; + OutputStream out; + TlsClientProtocol tls; + long idleSince; + + Conn(String key) { this.key = key; } + + void close() { + try { if (tls != null) tls.close(); } catch (Exception ignored) {} + try { if (socket != null) socket.close(); } catch (Exception ignored) {} + } + } + + private static Conn take(String key) { + synchronized (pool) { + List list = pool.get(key); + if (list == null) return null; + long now = System.currentTimeMillis(); + while (!list.isEmpty()) { + Conn c = list.remove(list.size() - 1); + if (now - c.idleSince < IDLE_MS && !c.socket.isClosed()) return c; + c.close(); + } + return null; + } + } + + static void release(Conn c) { + c.idleSince = System.currentTimeMillis(); + synchronized (pool) { + List list = pool.get(c.key); + if (list == null) { list = new ArrayList(); pool.put(c.key, list); } + if (list.size() >= 4) { list.remove(0).close(); } + list.add(c); + } + } + + /** Forget resumable TLS sessions (next connection to each host does a full, verified handshake). */ + static void forgetSessions() { + synchronized (sessions) { sessions.clear(); } + evictAll(); + } + + /** Drop all pooled connections (e.g. after the network changed). */ + public static void evictAll() { + final List doomed = new ArrayList(); + synchronized (pool) { + for (Iterator> it = pool.values().iterator(); it.hasNext();) doomed.addAll(it.next()); + pool.clear(); + } + if (doomed.isEmpty()) return; + // closing TLS sends an encrypted alert: keep that off the (small-stack) caller thread + Threads.start(new Runnable() { + public void run() { for (int i = 0; i < doomed.size(); i++) doomed.get(i).close(); } + }, "http-evict"); + } + + private static Conn connect(final Url u, int timeoutMs) throws IOException { + Conn c = new Conn(u.key()); + Socket s = new Socket(); + boolean ok = false; + try { + if (proxyHost != null) { + s.connect(new InetSocketAddress(proxyHost, proxyPort), timeoutMs); + s.setSoTimeout(timeoutMs); + OutputStream po = s.getOutputStream(); + String req = "CONNECT " + u.host + ":" + u.port + " HTTP/1.1\r\nHost: " + u.host + ":" + u.port + "\r\n\r\n"; + po.write(req.getBytes("ISO-8859-1")); + po.flush(); + InputStream pi = s.getInputStream(); + String status = readLine(pi); + if (status == null || status.indexOf(" 200") < 0) throw new IOException("Proxy refused: " + status); + String line; + while ((line = readLine(pi)) != null && line.length() > 0) { /* skip headers */ } + } else { + s.connect(new InetSocketAddress(u.host, u.port), timeoutMs); + s.setSoTimeout(timeoutMs); + } + s.setTcpNoDelay(true); + c.socket = s; + if (u.https) { + TlsClientProtocol proto = new TlsClientProtocol(s.getInputStream(), s.getOutputStream()); + proto.connect(new Client(crypto(), u.host, u.key())); + c.tls = proto; + c.in = new BufferedInputStream(proto.getInputStream(), 8192); + c.out = new BufferedOutputStream(proto.getOutputStream(), 4096); + } else { + c.in = new BufferedInputStream(s.getInputStream(), 8192); + c.out = new BufferedOutputStream(s.getOutputStream(), 4096); + } + ok = true; + return c; + } finally { + if (!ok) { try { s.close(); } catch (Exception ignored) {} } + } + } + + private static final class Client extends DefaultTlsClient { + private final String host; + private final String sessionKey; + + private final long started = System.currentTimeMillis(); + + Client(TlsCrypto crypto, String host, String sessionKey) { + super(crypto); + this.host = host; + this.sessionKey = sessionKey; + } + + @Override + protected Vector getSNIServerNames() { + if (CertVerifier.isIpLiteral(host)) return null; + Vector v = new Vector(); + try { + v.addElement(new ServerName(NameType.host_name, host.getBytes("US-ASCII"))); + } catch (java.io.UnsupportedEncodingException e) { + return null; + } + return v; + } + + /** + * TLS 1.2 only: this TLS stack can resume 1.2 sessions but not 1.3 ones, and on a 600 MHz + * phone a resumed handshake (no key exchange, no signature checks) is the difference + * between ~10 s and well under a second for every new connection. + */ + @Override + protected com.minamp.bc.tls.ProtocolVersion[] getSupportedVersions() { + return com.minamp.bc.tls.ProtocolVersion.TLSv12.only(); + } + + @Override + public TlsSession getSessionToResume() { + synchronized (sessions) { return sessions.get(sessionKey); } + } + + @Override + public void notifyHandshakeComplete() throws IOException { + super.notifyHandshakeComplete(); + boolean resumed = context.getSecurityParametersConnection().isResumedSession(); + DiagLog.log("TLS " + (resumed ? "resumed" : "full handshake") + " with " + host + + " in " + (System.currentTimeMillis() - started) + "ms"); + TlsSession s = context.getSession(); + if (s != null && s.isResumable()) { + synchronized (sessions) { sessions.put(sessionKey, s); } + } + } + + public TlsAuthentication getAuthentication() throws IOException { + return new TlsAuthentication() { + public void notifyServerCertificate(TlsServerCertificate sc) throws IOException { + com.minamp.bc.tls.Certificate cert = sc.getCertificate(); + List chain = new ArrayList(); + for (int i = 0; i < cert.getLength(); i++) chain.add(cert.getCertificateAt(i).getEncoded()); + CertVerifier.verify(chain, host); + } + + public TlsCredentials getClientCredentials(CertificateRequest req) { + return null; + } + }; + } + } + + // ------------------------------------------------------------------ request / response + + public static final class Response { + public final int code; + public final String status; + public final Map headers; // lower-case names + public final InputStream body; + public final long contentLength; // -1 if unknown + public final boolean chunked; + + Response(int code, String status, Map headers, InputStream body, long len, boolean chunked) { + this.code = code; this.status = status; this.headers = headers; this.body = body; + this.contentLength = len; this.chunked = chunked; + } + + public String header(String name) { return headers.get(name.toLowerCase(Locale.US)); } + + public void close() { + try { body.close(); } catch (IOException ignored) {} + } + + public byte[] readAll() throws IOException { + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(contentLength > 0 && contentLength < 4000000 ? (int) contentLength : 8192); + byte[] buf = new byte[8192]; + int n; + while ((n = body.read(buf)) > 0) bos.write(buf, 0, n); + return bos.toByteArray(); + } finally { + close(); + } + } + + public String readString() throws IOException { + return new String(readAll(), "UTF-8"); + } + } + + public static Response get(String url, Map headers, int timeoutMs) throws IOException { + return request("GET", url, headers, null, timeoutMs); + } + + public static Response request(String method, String url, Map headers, byte[] body, int timeoutMs) throws IOException { + return request(method, url, headers, body, timeoutMs, true); + } + + /** A single request; 3xx responses are returned to the caller. */ + public static Response requestNoRedirect(String method, String url, Map headers, byte[] body, int timeoutMs) throws IOException { + return request(method, url, headers, body, timeoutMs, false); + } + + private static Response request(String method, String url, Map headers, byte[] body, int timeoutMs, boolean follow) throws IOException { + for (int redirects = 0; ; redirects++) { + long t0 = System.currentTimeMillis(); + Response r; + try { + r = requestOnce(method, url, headers, body, timeoutMs); + } catch (IOException e) { + DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED after " + (System.currentTimeMillis() - t0) + "ms: " + Threads.describe(e)); + throw e; + } catch (RuntimeException e) { + DiagLog.log(method + " " + DiagLog.redact(url) + " -> FAILED: " + Threads.describe(e)); + throw e; + } + if (!url.contains("/photo/:/transcode") && !url.contains("/cover")) { // artwork would drown out everything else + DiagLog.log(method + " " + DiagLog.redact(url) + " -> " + r.code + " (" + (System.currentTimeMillis() - t0) + "ms)"); + } + if (follow && (r.code == 301 || r.code == 302 || r.code == 307 || r.code == 308) && redirects < 4 + && ("GET".equals(method) || "HEAD".equals(method))) { + String loc = r.header("location"); + r.close(); + if (loc == null) throw new IOException("Redirect without location"); + if (loc.startsWith("/")) { + Url u = new Url(url); + loc = (u.https ? "https://" : "http://") + u.hostHeader() + loc; + } + url = loc; + continue; + } + return r; + } + } + + private static Response requestOnce(String method, String url, Map headers, byte[] body, int timeoutMs) throws IOException { + Url u = new Url(url); + // A pooled connection may have been closed by the server; retry once on a fresh one. + for (int attempt = 0; attempt < 2; attempt++) { + Conn c = take(u.key()); + boolean pooled = c != null; + if (c == null) c = connect(u, timeoutMs); + try { + c.socket.setSoTimeout(timeoutMs); + writeRequest(c, u, method, headers, body); + return readResponse(c, method); + } catch (IOException e) { + c.close(); + if (!pooled) throw e; + } + } + throw new IOException("Request failed"); + } + + private static void writeRequest(Conn c, Url u, String method, Map headers, byte[] body) throws IOException { + StringBuilder sb = new StringBuilder(512); + sb.append(method).append(' ').append(u.path).append(" HTTP/1.1\r\n"); + sb.append("Host: ").append(u.hostHeader()).append("\r\n"); + boolean hasUa = false; + if (headers != null) { + for (Map.Entry e : headers.entrySet()) { + if (e.getValue() == null) continue; + if ("user-agent".equalsIgnoreCase(e.getKey())) hasUa = true; + sb.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n"); + } + } + if (!hasUa) sb.append("User-Agent: ").append(USER_AGENT).append("\r\n"); + sb.append("Connection: keep-alive\r\n"); + if (body != null || "POST".equals(method) || "PUT".equals(method)) { + sb.append("Content-Length: ").append(body == null ? 0 : body.length).append("\r\n"); + } + sb.append("\r\n"); + c.out.write(sb.toString().getBytes("UTF-8")); + if (body != null) c.out.write(body); + c.out.flush(); + } + + private static Response readResponse(Conn c, String method) throws IOException { + String statusLine = readLine(c.in); + if (statusLine == null) throw new IOException("Connection closed"); + // HTTP/1.1 200 OK + int sp = statusLine.indexOf(' '); + if (sp < 0) throw new IOException("Bad status line"); + int sp2 = statusLine.indexOf(' ', sp + 1); + int code; + try { + code = Integer.parseInt(sp2 < 0 ? statusLine.substring(sp + 1).trim() : statusLine.substring(sp + 1, sp2)); + } catch (NumberFormatException e) { + throw new IOException("Bad status line"); + } + boolean http10 = statusLine.startsWith("HTTP/1.0"); + Map h = new LinkedHashMap(); + String line; + while ((line = readLine(c.in)) != null && line.length() > 0) { + int colon = line.indexOf(':'); + if (colon <= 0) continue; + String name = line.substring(0, colon).trim().toLowerCase(Locale.US); + String val = line.substring(colon + 1).trim(); + String prev = h.get(name); + h.put(name, prev == null ? val : prev + ", " + val); + } + String te = h.get("transfer-encoding"); + String cl = h.get("content-length"); + String conn = h.get("connection"); + boolean keepAlive = !http10 && (conn == null || conn.toLowerCase(Locale.US).indexOf("close") < 0); + boolean chunked = te != null && te.toLowerCase(Locale.US).indexOf("chunked") >= 0; + long len = -1; + if (!chunked && cl != null) { + try { len = Long.parseLong(cl.trim()); } catch (NumberFormatException ignored) {} + } + InputStream body; + boolean noBody = "HEAD".equals(method) || code == 204 || code == 304 || (code >= 100 && code < 200); + if (noBody) { + body = new FixedStream(c, 0, keepAlive); + len = "HEAD".equals(method) ? len : 0; + } else if (chunked) { + body = new ChunkedStream(c, keepAlive); + } else if (len >= 0) { + body = new FixedStream(c, len, keepAlive); + } else { + body = new EofStream(c); + } + return new Response(code, statusLine, h, body, len, chunked); + } + + static String readLine(InputStream in) throws IOException { + StringBuilder sb = new StringBuilder(64); + int b; + while ((b = in.read()) >= 0) { + if (b == '\n') { + int n = sb.length(); + if (n > 0 && sb.charAt(n - 1) == '\r') sb.setLength(n - 1); + return sb.toString(); + } + sb.append((char) b); + if (sb.length() > 16384) throw new IOException("Header line too long"); + } + return sb.length() == 0 ? null : sb.toString(); + } + + // ------------------------------------------------------------------ body streams + + private static final class FixedStream extends InputStream { + private final Conn c; private long left; private final boolean keepAlive; private boolean closed; + + FixedStream(Conn c, long len, boolean keepAlive) { + this.c = c; this.left = len; this.keepAlive = keepAlive; + if (len == 0) finish(true); + } + + private void finish(boolean complete) { + if (closed) return; + closed = true; + if (complete && keepAlive) release(c); else c.close(); + } + + @Override public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n <= 0 ? -1 : one[0] & 255; + } + + @Override public int read(byte[] b, int off, int len) throws IOException { + if (left <= 0) { finish(true); return -1; } + if (closed) return -1; + int n = c.in.read(b, off, (int) Math.min(len, left)); + if (n < 0) { finish(false); throw new IOException("Unexpected end of stream"); } + left -= n; + if (left == 0) finish(true); + return n; + } + + @Override public int available() throws IOException { + if (closed) return 0; + return (int) Math.min(c.in.available(), left); + } + + @Override public void close() { finish(left == 0); } + } + + private static final class ChunkedStream extends InputStream { + private final Conn c; private final boolean keepAlive; + private long chunkLeft = 0; private boolean eof; private boolean closed; + + ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; } + + private void finish(boolean complete) { + if (closed) return; + closed = true; + if (complete && keepAlive) release(c); else c.close(); + } + + private boolean nextChunk() throws IOException { + if (eof) return false; + String line = readLine(c.in); + if (line == null) throw new IOException("Unexpected end of chunked stream"); + if (line.length() == 0) { line = readLine(c.in); if (line == null) throw new IOException("Bad chunk"); } + int semi = line.indexOf(';'); + if (semi >= 0) line = line.substring(0, semi); + try { chunkLeft = Long.parseLong(line.trim(), 16); } + catch (NumberFormatException e) { throw new IOException("Bad chunk size"); } + if (chunkLeft == 0) { + String t; + while ((t = readLine(c.in)) != null && t.length() > 0) { /* trailers */ } + eof = true; + finish(true); + return false; + } + return true; + } + + @Override public int read() throws IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n <= 0 ? -1 : one[0] & 255; + } + + @Override public int read(byte[] b, int off, int len) throws IOException { + if (closed) return -1; + if (chunkLeft == 0) { + if (!nextChunk()) return -1; + } + int n = c.in.read(b, off, (int) Math.min(len, chunkLeft)); + if (n < 0) { finish(false); throw new IOException("Unexpected end of chunk"); } + chunkLeft -= n; + if (chunkLeft == 0) { + // consume CRLF after chunk data + String crlf = readLine(c.in); + if (crlf == null) { finish(false); } + else if (crlf.length() != 0) { finish(false); throw new IOException("Bad chunk terminator"); } + } + return n; + } + + @Override public int available() throws IOException { + if (closed) return 0; + return (int) Math.min(c.in.available(), chunkLeft); + } + + @Override public void close() { finish(eof); } + } + + private static final class EofStream extends InputStream { + private final Conn c; private boolean closed; + EofStream(Conn c) { this.c = c; } + + @Override public int read() throws IOException { + if (closed) return -1; + int b = c.in.read(); + if (b < 0) close(); + return b; + } + + @Override public int read(byte[] b, int off, int len) throws IOException { + if (closed) return -1; + int n = c.in.read(b, off, len); + if (n < 0) close(); + return n; + } + + @Override public void close() { + if (!closed) { closed = true; c.close(); } + } + } + + /** Close one in-flight response's socket from another thread (used to abort streams). */ + public static void abort(Response r) { + if (r == null) return; + InputStream b = r.body; + Conn c = null; + if (b instanceof FixedStream) c = ((FixedStream) b).c; + else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c; + else if (b instanceof EofStream) c = ((EofStream) b).c; + if (c != null) { + try { c.socket.close(); } catch (Exception ignored) {} + } + } +} diff --git a/src/com/miniabs/net/Threads.java b/src/com/miniabs/net/Threads.java new file mode 100644 index 0000000..394407f --- /dev/null +++ b/src/com/miniabs/net/Threads.java @@ -0,0 +1,43 @@ +package com.miniabs.net; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +/** + * Background threads with big stacks. Android 2.x gives new threads ~16 KB of stack, and the + * bundled TLS handshake + certificate parsing needs far more; overflowing it kills the app. + * 256 KB is the most Dalvik allows. + */ +public final class Threads { + public static final long STACK = 256 * 1024; + + private Threads() {} + + public static Thread create(Runnable r, String name) { + Thread t = new Thread(null, r, name, STACK); + t.setDaemon(true); + return t; + } + + public static void start(Runnable r, String name) { + create(r, name).start(); + } + + public static ExecutorService pool(final String name, int n) { + ThreadFactory f = new ThreadFactory() { + private int count; + public synchronized Thread newThread(Runnable r) { return create(r, name + "-" + (++count)); } + }; + return n == 1 ? Executors.newSingleThreadExecutor(f) : Executors.newFixedThreadPool(n, f); + } + + /** Readable one-liner for any failure, including Errors such as StackOverflowError. */ + public static String describe(Throwable t) { + String m = t.getMessage(); + String cls = t.getClass().getSimpleName(); + if (m == null || m.length() == 0) return cls; + if (t instanceof java.io.IOException) return m; + return cls + ": " + m; + } +} diff --git a/test/com/miniabs/TrustTest.java b/test/com/miniabs/TrustTest.java index 73310aa..287b806 100644 --- a/test/com/miniabs/TrustTest.java +++ b/test/com/miniabs/TrustTest.java @@ -70,18 +70,18 @@ public class TrustTest { int good = Integer.parseInt(a[1]), wrongHost = Integer.parseInt(a[2]), forged = Integer.parseInt(a[3]), expired = Integer.parseInt(a[4]), self = Integer.parseInt(a[5]); - System.out.println("Bundled roots"); - List roots = new ArrayList(); - for (String r : Roots.DER_BASE64) roots.add(Trust.parse(android.util.Base64.decode(r, 0))); - check(roots.size() == 12, "12 modern root certificates bundled"); - String isrg = Trust.fingerprint(roots.get(0)).replace("\n", ":"); + System.out.println("Bundled roots (Mozilla, assets/roots)"); + String[] roots = c.getAssets().list("roots"); + check(roots.length > 100, roots.length + " root certificates bundled"); + X509Certificate isrgCert = null; + for (String n : roots) { + X509Certificate x = cert("assets/roots/" + n); + if (x.getSubjectX500Principal().getName().contains("ISRG Root X1")) isrgCert = x; + } + String isrg = isrgCert == null ? "" : Trust.fingerprint(isrgCert).replace("\n", ":"); check(isrg.equals("96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6"), - "Let's Encrypt ISRG Root X1 matches its published fingerprint"); - X509Certificate root = cert(pki + "/root.crt"), inter = cert(pki + "/int.crt"), leaf = cert(pki + "/leaf.crt"); - check(Trust.verify(new X509Certificate[]{leaf, inter}, Arrays.asList(root)) == null, - "leaf + intermediate validate up to a bundled-style root"); - check("unknown issuer".equals(Trust.verify(new X509Certificate[]{leaf}, Arrays.asList(root))), - "missing intermediate is rejected"); + "Let's Encrypt ISRG Root X1 bundled, matching its published fingerprint"); + X509Certificate inter = cert(pki + "/int.crt"), leaf = cert(pki + "/leaf.crt"); System.out.println("Unknown certificate, then trusting it"); p.setServer("https://localhost:" + good); @@ -110,7 +110,7 @@ public class TrustTest { JSONObject l = log.getJSONObject(i); if (l.getString("m").equals("PATCH") && l.getString("p").equals("/api/me/progress/pod1/ep7")) patched = true; } - check(patched, "mark-as-played PATCH works over TLS (raw socket path)"); + check(patched, "mark-as-played PATCH works over TLS"); System.out.println("Streaming proxy"); check(StreamProxy.needed(c), "https server streams through the proxy"); @@ -139,13 +139,11 @@ public class TrustTest { check(wh != null, "certificate for another host rejected, even from a trusted CA"); Exception fe = tryStatus(forged); Trust.Rejected fr = Trust.recentRejection(); - check(fe != null && fr != null && fr.reason.contains("isn't a CA"), - "forged certificate signed by a normal site certificate rejected (" + (fr == null ? "-" : fr.reason) + ")"); + check(fe != null && fr != null, "forged certificate signed by a normal site certificate rejected"); Trust.pin(c, fr.chain); check(tryStatus(forged) != null, "...and still rejected even if the user taps Trust"); Exception ee = tryStatus(expired); - check(ee != null && Trust.recentRejection() != null && Trust.recentRejection().expired() - && Util.describe(ee).contains("caducou"), + check(ee != null && Util.describe(ee).contains("caducou"), "expired certificate rejected: \"" + Util.describe(ee) + "\""); System.out.println("Self-signed server"); diff --git a/test/run_tests.sh b/test/run_tests.sh index 4cb3d14..29f53e1 100755 --- a/test/run_tests.sh +++ b/test/run_tests.sh @@ -3,12 +3,14 @@ set -e cd "$(dirname "$0")/.." OUT=build/test -JSON_JAR=/usr/share/java/com.android.json.jar +JSON_JAR=${JSON_JAR:-/usr/share/java/com.android.json.jar} # any org.json works (e.g. org.json:json from Maven) +BC_SRC=${BC_SRC:-$HOME/android-toolchain/bcsrc} rm -rf "$OUT" && mkdir -p "$OUT" S=src/com/miniabs -javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -d "$OUT" $(find test/shim -name '*.java') \ +javac -nowarn -encoding UTF-8 -cp "$JSON_JAR" -sourcepath "$BC_SRC" -d "$OUT" $(find test/shim -name '*.java') \ $S/Json.java $S/Prefs.java $S/Episode.java $S/Util.java $S/Api.java $S/App.java \ - $S/Trust.java $S/Roots.java $S/StreamProxy.java \ + $S/Trust.java $S/NetConnection.java $S/net/Http.java $S/net/CertVerifier.java $S/net/AssetRoots.java \ + $S/net/DiagLog.java $S/net/Threads.java $S/StreamProxy.java \ test/com/miniabs/ApiTest.java test/com/miniabs/TrustTest.java CP="$OUT:$JSON_JAR" PIDS="" diff --git a/test/shim/android/content/Context.java b/test/shim/android/content/Context.java index f38fecf..1519878 100644 --- a/test/shim/android/content/Context.java +++ b/test/shim/android/content/Context.java @@ -6,6 +6,8 @@ public class Context { private final HashMap prefs = new HashMap(); public static boolean online = true; public Context getApplicationContext() { return this; } + private final android.content.res.AssetManager assets = new android.content.res.AssetManager(); + public android.content.res.AssetManager getAssets() { return assets; } public synchronized SharedPreferences getSharedPreferences(String name, int mode) { SharedPreferences p = prefs.get(name); if (p == null) { p = new SharedPreferences(); prefs.put(name, p); } diff --git a/test/shim/android/content/res/AssetManager.java b/test/shim/android/content/res/AssetManager.java new file mode 100644 index 0000000..1f15916 --- /dev/null +++ b/test/shim/android/content/res/AssetManager.java @@ -0,0 +1,10 @@ +package android.content.res; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +/** Desktop shim: the app's assets/ folder, read from the working directory. */ +public final class AssetManager { + public String[] list(String path) { String[] l = new File("assets", path).list(); return l == null ? new String[0] : l; } + public InputStream open(String name) throws IOException { return new FileInputStream(new File("assets", name)); } +} diff --git a/tools/gen_roots.py b/tools/gen_roots.py deleted file mode 100644 index c5e863a..0000000 --- a/tools/gen_roots.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Generates src/com/miniabs/Roots.java: modern RSA root certificates that Android 2.3's -built-in list lacks (Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL, ...). - -Only RSA roots: Android 2.x's TLS 1.0 can't use ECDSA certificates anyway. -Run: python3 tools/gen_roots.py /etc/ssl/certs -""" -import base64 -import hashlib -import os -import re -import sys - -CERT_DIR = sys.argv[1] if len(sys.argv) > 1 else "/etc/ssl/certs" -NAMES = [ - "ISRG_Root_X1", # Let's Encrypt - "GTS_Root_R1", # Google Trust Services (also Cloudflare) - "USERTrust_RSA_Certification_Authority", # Sectigo, ZeroSSL - "DigiCert_Global_Root_G2", - "Amazon_Root_CA_1", - "Microsoft_RSA_Root_Certificate_Authority_2017", - "SSL.com_Root_Certification_Authority_RSA", - "Buypass_Class_2_Root_CA", - "HARICA_TLS_RSA_Root_CA_2021", - "Certum_Trusted_Network_CA", - "Go_Daddy_Root_Certificate_Authority_-_G2", - "Starfield_Root_Certificate_Authority_-_G2", -] - -out = [] -for n in NAMES: - pem = open(os.path.join(CERT_DIR, n + ".pem")).read() - b64 = "".join(re.search(r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", pem, re.S).group(1).split()) - der = base64.b64decode(b64) - out.append((n, b64, hashlib.sha256(der).hexdigest())) - -lines = [ - "package com.miniabs;", - "", - "/** Generated by tools/gen_roots.py from the system CA store. Do not edit. */", - "final class Roots {", - " private Roots() {", - " }", - "", - " static final String[] DER_BASE64 = {", -] -for n, b64, sha in out: - lines.append(" // %s sha256=%s" % (n, sha)) - chunks = [b64[i:i + 100] for i in range(0, len(b64), 100)] - lines.append(" " + "\n + ".join('"%s"' % c for c in chunks) + ",") -lines += [" };", "}", ""] -open(os.path.join(os.path.dirname(__file__), "..", "src", "com", "miniabs", "Roots.java"), "w").write("\n".join(lines)) -print("%d roots written" % len(out))