diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 7f7cdc5..c509fc6 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="7" + android:versionName="1.6"> diff --git a/README.md b/README.md index c121d33..0a519a0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ -# GingerCal — Nextcloud CalDAV for Android 2.3 (Galaxy Mini) +# Calendar Lite — Nextcloud CalDAV for Android 2.3 (Galaxy Mini) A small native calendar client for Gingerbread (API 9/10). Built for a Samsung Galaxy Mini -(GT-S5570, 240×320, Android 2.3.5) talking to Nextcloud, reached through Pangolin. +(GT-S5570, 240×320, Android 2.3.5) talking to Nextcloud at its normal `https://` address. + +**Since 1.4 it brings its own TLS 1.2** (Bouncy Castle, the same client as Plexamp Lite, with Mozilla's +root certificates and keep-alive connections), like the iPod version does with BearSSL. Put the +normal `https://` Nextcloud address in Settings, at home or away: no bridge is needed. The bridge +sections below are kept for reference; a bridge address (pinned certificate) still works. **What it does** @@ -19,11 +24,12 @@ A small native calendar client for Gingerbread (API 9/10). Built for a Samsung G they can be deleted on the phone but edited only in Nextcloud - Pick which calendars to show and which one new events go to - Background sync every 1–24 h, plus phone notifications N minutes before timed events -- Certificate pinning, so a self-signed "legacy" endpoint is still safe against interception +- Its own TLS 1.2 stack: connects to modern `https://` servers directly +- Certificate pinning, so a self-signed server (or the old legacy-TLS bridge) is still safe against interception It does not feed the stock Samsung calendar: 2.3 has no public calendar-provider API for that. -## Why HTTPS breaks on 2.3, and the fix +## Why HTTPS broke on 2.3, and the old fix (not needed since 1.4) Three separate problems, all fatal for a normal Pangolin/Traefik URL: @@ -125,22 +131,24 @@ you should turn `proxy_ssl_verify` on. ## Install on the phone 1. Settings, Applications, tick **Unknown sources**. -2. Copy `GingerCal.apk` to the SD card (USB) and open it with the file manager, or download it +2. Copy `CalendarLite.apk` to the SD card (USB) and open it with the file manager, or download it over plain `http://` (the 2.3 browser can't open most HTTPS sites either). -3. Open GingerCal, Menu, Settings. In Nextcloud create an app password: Settings, Security, +3. Open Calendar Lite, Menu, Settings. In Nextcloud create an app password: Settings, Security, Devices & sessions. Fill in server, user, app password, then **Save and test connection**. Menu key: View (Month/Agenda), New event, Sync, Today, Calendars, Settings. Tap an event for details, Edit and Delete. -If installation fails, check the file arrived whole (53 KB; some browsers save a truncated or +If installation fails, check the file arrived whole (about 1 MB; some browsers save a truncated or renamed `.apk.zip`) and that there are a few MB free on the internal storage. On a PC with -`adb`, `adb install GingerCal.apk` prints the exact reason (`INSTALL_FAILED_...`). +`adb`, `adb install CalendarLite.apk` prints the exact reason (`INSTALL_FAILED_...`). ## Building -Needs JDK 11+, Python 3 with Pillow, `d8` and `apksigner.jar` (Android SDK build-tools), and -`apktool.jar` (2.9+, which bundles Google's `aapt` and smali). No Gradle. +Needs JDK 11+, Python 3, `d8` and `apksigner.jar` (Android SDK build-tools), +`apktool.jar` (2.9+, which bundles Google's `aapt` and smali), and the Bouncy Castle sources renamed +to `com.minamp.bc` that Plexamp Lite's `tools/setup_toolchain.sh` prepares (`BC_SRC`, default +`~/android-toolchain/bcsrc`). No Gradle. ```sh D8=d8 APKTOOL=~/apktool.jar APKSIGNER=$SDK/build-tools/34.0.0/lib/apksigner.jar ./build.sh @@ -159,9 +167,11 @@ over the existing app. Tests (desktop JVM, need a CalDAV server such as Radicale on :5232 with user `xose`/`secret`): ```sh -javac -d out src/org/gingercal/*.java test/org/gingercal/*.java +javac -d out src/org/gingercal/*.java $(ls test/org/gingercal/*.java | grep -v NetTest) java -cp out org.gingercal.CoreTest # discovery, sync, recurrence, create/edit/delete -java -cp out org.gingercal.RedirectTest # forced-HTTPS / redirect-loop handling (no server needed) +java -cp out org.gingercal.RedirectTest # redirect handling (no server needed) +# the bundled TLS transport (needs the android.jar and Bouncy Castle sources to compile): +# java -cp out org.gingercal.NetTest assets/roots http://127.0.0.1:5232 xose secret java -cp out org.gingercal.I18nTest # Galician dates, titles and messages ``` @@ -172,15 +182,19 @@ declarations. ## Layout - `src/` CalDAV core, platform-neutral: discovery, PROPFIND/REPORT, iCalendar, sync, pinning -- `android/` activities, sync service, alarms, SQLite cache, Apache HttpClient transport +- `android/` activities, sync service, alarms, SQLite cache, `NetHttp` transport +- `android/org/gingercal/net/` the bundled TLS 1.2 HTTP client from Plexamp Lite; `assets/roots/` Mozilla roots - `stubs/` compile-only Android API 10 signatures (never packaged) - `bridge/` legacy-TLS endpoint: standalone nginx (config, compose file), `npm/http.conf` for Nginx Proxy Manager, certificate script -- `res/` app name; the icon is drawn at build time (mdpi + ldpi for the Galaxy Mini) -- `tools/` icon, dex disassembler helper, zipalign, dex/API checkers, d8 workaround +- `res/` app name, launcher icon (ldpi to xhdpi; master in `art/`) and `stat_notify`, the white status-bar icon for reminders +- `tools/` dex disassembler helper, zipalign, dex/API checkers, d8 workaround ## Security notes +With the bundled TLS 1.2 the connection to Nextcloud is checked like a browser would: host name, +dates, and a chain up to a Mozilla root. The notes below are about the old bridge. + TLS 1.0 with AES-CBC is old but, combined with a pinned certificate, still keeps the connection private and authenticated against anyone without the bridge's private key. The bridge exposes only the DAV endpoint. Use an app password so you can revoke the phone from Nextcloud. If the diff --git a/android/org/gingercal/AlarmReceiver.java b/android/org/gingercal/AlarmReceiver.java index f3bded0..70ab008 100644 --- a/android/org/gingercal/AlarmReceiver.java +++ b/android/org/gingercal/AlarmReceiver.java @@ -29,7 +29,7 @@ public class AlarmReceiver extends BroadcastReceiver { static synchronized void acquire(Context c) { if (lock == null) { PowerManager pm = (PowerManager) c.getApplicationContext().getSystemService("power"); - lock = pm.newWakeLock(1 /* PARTIAL_WAKE_LOCK */, "GingerCal sync"); + lock = pm.newWakeLock(1 /* PARTIAL_WAKE_LOCK */, "Calendar Lite sync"); lock.setReferenceCounted(false); } lock.acquire(5 * 60 * 1000L); diff --git a/android/org/gingercal/ApacheHttp.java b/android/org/gingercal/ApacheHttp.java deleted file mode 100644 index 1160994..0000000 --- a/android/org/gingercal/ApacheHttp.java +++ /dev/null @@ -1,177 +0,0 @@ -package org.gingercal; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Method; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.URI; -import java.security.GeneralSecurityException; - -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; - -import org.apache.http.Header; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; -import org.apache.http.conn.scheme.LayeredSocketFactory; -import org.apache.http.conn.scheme.PlainSocketFactory; -import org.apache.http.conn.scheme.Scheme; -import org.apache.http.conn.scheme.SchemeRegistry; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.DefaultHttpClient; -import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager; -import org.apache.http.params.BasicHttpParams; -import org.apache.http.params.HttpConnectionParams; -import org.apache.http.params.HttpParams; -import org.apache.http.params.HttpProtocolParams; - -/** - * Android 2.3 transport. HttpURLConnection refuses PROPFIND/REPORT there, so this uses the - * bundled Apache HttpClient, plus a TLS layer that supports certificate pinning (needed for a - * self-signed legacy-TLS bridge) and SNI where the platform offers it. - */ -public class ApacheHttp implements Http { - final DefaultHttpClient client; - final Tls.PinTrust trust; - - public ApacheHttp(String pin, int timeoutMs) throws GeneralSecurityException { - trust = new Tls.PinTrust(pin); - HttpParams p = new BasicHttpParams(); - HttpConnectionParams.setConnectionTimeout(p, timeoutMs); - HttpConnectionParams.setSoTimeout(p, timeoutMs); - HttpConnectionParams.setStaleCheckingEnabled(p, true); - HttpProtocolParams.setUseExpectContinue(p, false); - HttpProtocolParams.setUserAgent(p, "GingerCal/1.0 (Android 2.3)"); - p.setBooleanParameter("http.protocol.handle-redirects", false); - SchemeRegistry reg = new SchemeRegistry(); - reg.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); - reg.register(new Scheme("https", new TlsFactory(trust), 443)); - client = new DefaultHttpClient(new ThreadSafeClientConnManager(p, reg), p); - } - - static class Req extends HttpEntityEnclosingRequestBase { - final String m; - - Req(String m, URI u) { - this.m = m; - setURI(u); - } - - @Override - public String getMethod() { - return m; - } - } - - public Resp exec(String method, String url, String[] headers, String body, String contentType) - throws IOException { - Req r = new Req(method, URI.create(url)); - for (int i = 0; i + 1 < headers.length; i += 2) r.addHeader(headers[i], headers[i + 1]); - if (body != null) { - StringEntity e = new StringEntity(body, "UTF-8"); - e.setContentType(contentType); - r.setEntity(e); - } - trust.reset(); - HttpResponse res; - try { - res = client.execute(r); - } catch (IOException e) { - throw trust.translate(e); - } - Resp out = new Resp(); - out.code = res.getStatusLine().getStatusCode(); - Header h = res.getFirstHeader("ETag"); - if (h != null) out.etag = h.getValue(); - h = res.getFirstHeader("Location"); - if (h != null) out.location = h.getValue(); - HttpEntity en = res.getEntity(); - if (en != null) { - InputStream in = en.getContent(); - try { - ByteArrayOutputStream bo = new ByteArrayOutputStream(); - byte[] buf = new byte[8192]; - int n; - while ((n = in.read(buf)) > 0) bo.write(buf, 0, n); - out.body = new String(bo.toByteArray(), "UTF-8"); - } finally { - en.consumeContent(); - } - } - return out; - } - - public void close() { - client.getConnectionManager().shutdown(); - } - - // ------------------------------------------------------------------ TLS - - static class TlsFactory implements LayeredSocketFactory { - final SSLSocketFactory f; - final Tls.PinTrust trust; - - TlsFactory(Tls.PinTrust trust) throws GeneralSecurityException { - this.trust = trust; - SSLContext c = SSLContext.getInstance("TLS"); - c.init(null, new TrustManager[] {trust}, null); - f = c.getSocketFactory(); - } - - public Socket createSocket() throws IOException { - return f.createSocket(); - } - - public Socket connectSocket(Socket sock, String host, int port, InetAddress local, int localPort, - HttpParams params) throws IOException { - SSLSocket s = (SSLSocket) (sock != null ? sock : createSocket()); - if (local != null || localPort > 0) { - s.bind(new InetSocketAddress(local, localPort < 0 ? 0 : localPort)); - } - sni(s, host); - s.connect(new InetSocketAddress(host, port), HttpConnectionParams.getConnectionTimeout(params)); - s.setSoTimeout(HttpConnectionParams.getSoTimeout(params)); - s.startHandshake(); - verifyHost(s, host); - return s; - } - - public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException { - SSLSocket s = (SSLSocket) f.createSocket(socket, host, port, autoClose); - sni(s, host); - s.startHandshake(); - verifyHost(s, host); - return s; - } - - public boolean isSecure(Socket sock) { - return true; - } - - void verifyHost(SSLSocket s, String host) throws IOException { - if (trust.pinned()) return; // the pin identifies the exact certificate - if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(host, s.getSession())) { - s.close(); - throw new SSLException("Certificate is not valid for " + host); - } - } - - /** Gingerbread's OpenSSLSocketImpl has a hidden setHostname() for SNI. */ - static void sni(SSLSocket s, String host) { - if (host.matches("[0-9.]+") || host.indexOf(':') >= 0) return; - try { - Method m = s.getClass().getMethod("setHostname", String.class); - m.invoke(s, host); - } catch (Throwable ignored) { - } - } - } -} diff --git a/android/org/gingercal/App.java b/android/org/gingercal/App.java index d17a745..a30b3a3 100644 --- a/android/org/gingercal/App.java +++ b/android/org/gingercal/App.java @@ -67,7 +67,7 @@ public final class App { } public static Dav dav(String url, String user, String pass, String pin) throws Exception { - return new Dav(new ApacheHttp(pin, 30000), url, user, pass); + return new Dav(new NetHttp(pin, 30000), url, user, pass); } public static boolean online(Context c) { @@ -126,9 +126,9 @@ public final class App { if (t instanceof UnknownHostException) { return L.f("Can't resolve %s. No connection, or a typo in the server address.", t.getMessage()); } - if (t instanceof ConnectException) return L.f("Can't connect to the server (%s). Wrong port, or the bridge is down?", t.getMessage()); + if (t instanceof ConnectException) return L.f("Can't connect to the server (%s). Wrong address or port, or the server is down?", t.getMessage()); if (t instanceof SocketTimeoutException) return L.t("The server took too long to answer."); - if (t instanceof SSLException) return L.f("TLS error: %s", t.getMessage()); + if (t instanceof SSLException || t instanceof com.minamp.bc.tls.TlsException) return L.f("TLS error: %s", t.getMessage()); if (t instanceof IOException) return L.f("Network error: %s", t); if (t instanceof OutOfMemoryError) return L.t("Out of memory. Lower 'Days ahead' in Settings."); return L.f("Error: %s", t); @@ -144,8 +144,8 @@ public final class App { public static void askTrust(Activity a, final DavException.UntrustedCert u, final Trusted cb) { String msg = (u.pinMismatch - ? L.t("WARNING: the server certificate changed since you trusted it. That happens when you regenerate the bridge certificate - or when someone is intercepting the connection.\n\n") - : L.t("This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, or for Let's Encrypt on Android 2.3).\n\n")) + ? L.t("WARNING: the server certificate changed since you trusted it. That happens when a self-signed certificate is regenerated - or when someone is intercepting the connection.\n\n") + : L.t("The server certificate isn't signed by a known authority (normal for a self-signed server or an old TLS 1.0 bridge).\n\n")) + L.f("Subject: %s\nIssuer: %s\nExpires: %s\n\nSHA-256:\n%s\n\nOnly trust it if this matches the fingerprint printed on your server.", u.subject, u.issuer, u.expires, u.fingerprint); new AlertDialog.Builder(a) diff --git a/android/org/gingercal/GingerApp.java b/android/org/gingercal/GingerApp.java index 597a660..fe9662f 100644 --- a/android/org/gingercal/GingerApp.java +++ b/android/org/gingercal/GingerApp.java @@ -2,11 +2,20 @@ package org.gingercal; import android.app.Application; -/** Runs before any screen, service or alarm: sets the interface language (Galician by default). */ +/** Runs before any screen, service or alarm: interface language (Galician by default), bundled TLS. */ public class GingerApp extends Application { @Override public void onCreate() { super.onCreate(); L.gl = !"en".equals(getSharedPreferences("gingercal", 0).getString("lang", "gl")); + org.gingercal.net.CertVerifier.setRootStore(new org.gingercal.net.AssetRoots(this)); + org.gingercal.net.Http.USER_AGENT = "CalendarLite/1.6 (Android 2.3)"; + tlsTexts(); + } + + /** The bundled TLS stack's messages, in the current language. */ + static void tlsTexts() { + org.gingercal.net.CertVerifier.msgMismatch = L.t("The server certificate is for a different address (%1$s)."); + org.gingercal.net.CertVerifier.msgDates = L.t("The server certificate has expired, or the phone's date is wrong."); } } diff --git a/android/org/gingercal/MainActivity.java b/android/org/gingercal/MainActivity.java index fbadb79..e7615d2 100644 --- a/android/org/gingercal/MainActivity.java +++ b/android/org/gingercal/MainActivity.java @@ -159,7 +159,7 @@ public class MainActivity extends Activity implements MonthView.Listener { } TextView navArrow(String label, final int delta) { - TextView b = App.text(this, label, 20, 0xFFD66E2A); // « »: Latin-1, always in the 2.3 font + TextView b = App.text(this, label, 20, 0xFF3D8EF0); // « »: Latin-1, always in the 2.3 font b.setGravity(17 /* Gravity.CENTER */); int sel = App.sysDrawable(this, "list_selector_background"); if (sel != 0) b.setBackgroundResource(sel); // press highlight diff --git a/android/org/gingercal/MonthView.java b/android/org/gingercal/MonthView.java index 0fece48..a50d34f 100644 --- a/android/org/gingercal/MonthView.java +++ b/android/org/gingercal/MonthView.java @@ -28,7 +28,7 @@ public class MonthView extends View { } static final int BG = 0xFF101418, GRID = 0xFF2A3036, WEEKDAY = 0xFF90A4AE, NUM = 0xFFECEFF1, - OTHER = 0xFF5C666E, TODAY = 0xFFD66E2A, SELECTED = 0xFF2E3B44; + OTHER = 0xFF5C666E, TODAY = 0xFF3D8EF0, SELECTED = 0xFF2E3B44; final float density; final Paint fill = new Paint(1 /* ANTI_ALIAS_FLAG */), text = new Paint(1), line = new Paint(); diff --git a/android/org/gingercal/NetHttp.java b/android/org/gingercal/NetHttp.java new file mode 100644 index 0000000..07dc9f6 --- /dev/null +++ b/android/org/gingercal/NetHttp.java @@ -0,0 +1,78 @@ +package org.gingercal; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; + +import org.gingercal.net.CertVerifier; + +/** + * Android 2.3 transport through the bundled TLS 1.2 client (org.gingercal.net, from Plexamp Lite): + * the phone's own TLS 1.0 can't reach Nextcloud, so this talks to the normal https:// address + * directly, any method (PROPFIND/REPORT included), with keep-alive. A pinned certificate (a + * self-signed server or an old bridge) is still honoured. + */ +public class NetHttp implements Http { + /** AsyncTask and IntentService threads have Android 2.x's small default stack, which the + * TLS handshake overflows: requests run here, on big-stack threads, and the caller waits. */ + private static final ExecutorService NET = org.gingercal.net.Threads.pool("net", 3); + + final String pin; + final int timeoutMs; + + public NetHttp(String pin, int timeoutMs) { + this.pin = pin == null ? "" : pin; + this.timeoutMs = timeoutMs; + } + + public Resp exec(final String method, final String url, final String[] headers, final String body, + final String contentType) throws IOException { + try { + return NET.submit(new Callable() { + public Resp call() throws IOException { + return execHere(method, url, headers, body, contentType); + } + }).get(); + } catch (ExecutionException e) { + Throwable c = e.getCause(); + if (c instanceof IOException) throw (IOException) c; + if (c instanceof RuntimeException) throw (RuntimeException) c; + if (c instanceof Error) throw (Error) c; + throw new IOException(String.valueOf(c)); + } catch (InterruptedException e) { + throw new IOException(L.t("Interrupted")); + } + } + + Resp execHere(String method, String url, String[] headers, String body, String contentType) + throws IOException { + CertVerifier.setPin(pin); + Map h = new LinkedHashMap(); + for (int i = 0; i + 1 < headers.length; i += 2) h.put(headers[i], headers[i + 1]); + byte[] data = null; + if (body != null) { + data = body.getBytes("UTF-8"); + if (contentType != null) h.put("Content-Type", contentType); + } + org.gingercal.net.Http.Response r; + try { + // one hop at a time: Dav decides which redirects are safe to follow + r = org.gingercal.net.Http.requestNoRedirect(method, url, h, data, timeoutMs); + } catch (CertVerifier.Untrusted u) { + throw new DavException.UntrustedCert(u.fingerprint, u.subject, u.issuer, u.expires, u.changed); + } + Resp out = new Resp(); + out.code = r.code; + out.etag = r.header("etag"); + out.location = r.header("location"); + out.body = r.readString(); + return out; + } + + public void close() { + // pooled connections are shared and expire by themselves + } +} diff --git a/android/org/gingercal/Reminders.java b/android/org/gingercal/Reminders.java index 072f986..3d064f9 100644 --- a/android/org/gingercal/Reminders.java +++ b/android/org/gingercal/Reminders.java @@ -43,8 +43,9 @@ public final class Reminders { List due = Db.get(c).startingBetween(Math.max(notified, now - 30 * ICal.MIN), now + m * ICal.MIN + 30000L); NotificationManager nm = (NotificationManager) c.getSystemService("notification"); long newest = notified; - int icon = App.sysDrawable(c, "ic_popup_reminder"); - if (icon == 0) icon = App.sysDrawable(c, "ic_dialog_info"); + // our own white status-bar glyph: the system's colour alarm-clock icon looks out of place up there + int icon = c.getResources().getIdentifier("stat_notify", "drawable", c.getPackageName()); + if (icon == 0) icon = App.sysDrawable(c, "ic_popup_reminder"); for (Ev e : due) { Intent open = new Intent(c, MainActivity.class); open.setFlags(0x10000000 /* FLAG_ACTIVITY_NEW_TASK */); diff --git a/android/org/gingercal/SettingsActivity.java b/android/org/gingercal/SettingsActivity.java index 9f2b1ab..82ff437 100644 --- a/android/org/gingercal/SettingsActivity.java +++ b/android/org/gingercal/SettingsActivity.java @@ -27,12 +27,12 @@ public class SettingsActivity extends Activity { @Override protected void onCreate(Bundle b) { super.onCreate(b); - setTitle(L.t("GingerCal settings")); + setTitle(L.t("Calendar Lite settings")); LinearLayout f = App.vbox(this); int p = App.dp(this, 8); f.setPadding(p, p, p, p); - f.addView(App.text(this, L.t("Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home Wi-Fi. Use an app password, not your real one."), 12, 0xFFB0BEC5)); - url = field(f, L.t("Server address"), "https://cloud.example.com:8443", 1 | 0x10 /* URI */); + f.addView(App.text(this, L.t("Nextcloud server: your normal https:// address. This app brings its own modern TLS, so no bridge is needed, at home or away. Use an app password, not your real one."), 12, 0xFFB0BEC5)); + url = field(f, L.t("Server address"), "https://cloud.example.com", 1 | 0x10 /* URI */); user = field(f, L.t("User name"), "", 1 | 0x80000 /* no suggestions */); pass = field(f, L.t("App password"), "", 1 | 0x80 /* password */); pin = field(f, L.t("Pinned certificate SHA-256 (filled in for you)"), L.t("optional"), 1 | 0x80000); @@ -99,6 +99,7 @@ public class SettingsActivity extends Activity { save(); App.prefs(SettingsActivity.this).edit().putString("lang", toGl ? "gl" : "en").commit(); L.gl = toGl; + GingerApp.tlsTexts(); finish(); // reopen so every label is redrawn in the new language startActivity(new Intent(SettingsActivity.this, SettingsActivity.class)); } @@ -188,7 +189,7 @@ public class SettingsActivity extends Activity { } if (Dav.normalize(u).startsWith("http://") && !App.isLan(u)) { new AlertDialog.Builder(this).setTitle(L.t("Unencrypted connection")) - .setMessage(L.t("http:// sends your password in clear text over the internet. Use the legacy-TLS bridge instead, or only use http:// to a LAN address.")) + .setMessage(L.t("http:// sends your password in clear text over the internet. Use the https:// address instead, or only use http:// to a LAN address.")) .setPositiveButton(L.t("Use anyway"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { save(); diff --git a/android/org/gingercal/net/AssetRoots.java b/android/org/gingercal/net/AssetRoots.java new file mode 100644 index 0000000..e89f649 --- /dev/null +++ b/android/org/gingercal/net/AssetRoots.java @@ -0,0 +1,50 @@ +package org.gingercal.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/android/org/gingercal/net/CertVerifier.java b/android/org/gingercal/net/CertVerifier.java new file mode 100644 index 0000000..96108f9 --- /dev/null +++ b/android/org/gingercal/net/CertVerifier.java @@ -0,0 +1,331 @@ +package org.gingercal.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 Plexamp Lite (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/android/org/gingercal/net/DiagLog.java b/android/org/gingercal/net/DiagLog.java new file mode 100644 index 0000000..37224b7 --- /dev/null +++ b/android/org/gingercal/net/DiagLog.java @@ -0,0 +1,47 @@ +package org.gingercal.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/android/org/gingercal/net/Http.java b/android/org/gingercal/net/Http.java new file mode 100644 index 0000000..b46e512 --- /dev/null +++ b/android/org/gingercal/net/Http.java @@ -0,0 +1,588 @@ +package org.gingercal.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 Plexamp Lite; keep the copies in sync. + */ +public final class Http { + + public static String USER_AGENT = "PlexampLite/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/android/org/gingercal/net/Threads.java b/android/org/gingercal/net/Threads.java new file mode 100644 index 0000000..887d9bc --- /dev/null +++ b/android/org/gingercal/net/Threads.java @@ -0,0 +1,43 @@ +package org.gingercal.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/art/icon-master.png b/art/icon-master.png new file mode 100644 index 0000000..059e591 Binary files /dev/null and b/art/icon-master.png differ 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/bridge/pangolin-vps-setup.txt b/bridge/pangolin-vps-setup.txt index b099a6f..ccb1d37 100644 --- a/bridge/pangolin-vps-setup.txt +++ b/bridge/pangolin-vps-setup.txt @@ -1,5 +1,5 @@ TASK FOR CLAUDE CODE (on the Pangolin server, 141.227.129.182) -Add a raw TCP resource on port 8443 so the GingerCal app (Android 2.3) can reach Nextcloud CalDAV +Add a raw TCP resource on port 8443 so the Calendar Lite app (Android 2.3) can reach Nextcloud CalDAV ================================================================================================== CONTEXT @@ -72,7 +72,7 @@ STEPS 7. Pangolin dashboard (the user does this, or guide them through it): - Org of the home server, Resources, Add Resource, type "Raw TCP/UDP resource". - - Name: GingerCal CalDAV. Protocol: TCP. Public port: 8443. + - Name: Calendar Lite CalDAV. Protocol: TCP. Public port: 8443. - Site: the site whose newt runs on the home server 172.30.1.123 (most likely the "casa" site). - Target: IP 172.30.1.123, port 8443. Leave "Enable Proxy Protocol" OFF (the bridge doesn't expect it; enabling only one side breaks every connection). @@ -104,7 +104,7 @@ STEPS PHONE SETUP (after step 8 passes) --------------------------------- -GingerCal, Menu, Settings: +Calendar Lite, Menu, Settings: Server: https://141.227.129.182:8443 (any *.oxose.org name works too, e.g. https://cal-old.oxose.org:8443: raw TCP ignores the hostname and the app pins the certificate, not the name) diff --git a/build.sh b/build.sh index 9e04f38..17ac89c 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,7 @@ #!/bin/sh -# Builds GingerCal.apk for Android 2.3 (API 10) without Gradle. +# Builds CalendarLite.apk for Android 2.3 (API 10) without Gradle. # -# Needs: JDK 11+ (javac, keytool), python3 with Pillow (icon), and three tools: +# Needs: JDK 11+ (javac, keytool), python3, and three tools: # D8 command that runs d8 (Android SDK build-tools; default "d8" on PATH) # APKTOOL path to apktool.jar (https://apktool.org; bundles aapt and smali) # APKSIGNER path to apksigner.jar (Android SDK build-tools//lib/apksigner.jar) @@ -15,11 +15,12 @@ D8=${D8:-d8} APKTOOL=${APKTOOL:?set APKTOOL=/path/to/apktool.jar} APKSIGNER=${APKSIGNER:?set APKSIGNER=/path/to/apksigner.jar} KS=${KEYSTORE:-gingercal.keystore} +BC_SRC=${BC_SRC:-$HOME/android-toolchain/bcsrc} # Bouncy Castle renamed to com.minamp.bc (Minamp's tools/setup_toolchain.sh) VERSION_CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml) VERSION_NAME=$(sed -n 's/.*android:versionName="\([^"]*\)".*/\1/p' AndroidManifest.xml) rm -rf "$OUT" -mkdir -p "$OUT/stubs" "$OUT/classes" "$OUT/dex" "$OUT/apk/res/drawable" "$OUT/apk/res/drawable-ldpi" +mkdir -p "$OUT/stubs" "$OUT/classes" "$OUT/dex" "$OUT/apk/res" # 0. every interface string must have its Galician translation python3 tools/check_i18n.py . @@ -32,7 +33,8 @@ else CP="$OUT/stubs" fi # Java 8 bytecode, Java 6-era APIs only (Android 2.3 runs a Harmony class library). -javac --release 8 -encoding UTF-8 -Xlint:-options -cp "$CP" -d "$OUT/classes" $(find src android -name '*.java') +javac --release 8 -encoding UTF-8 -Xlint:-options -nowarn -cp "$CP" -sourcepath "src:android:$BC_SRC" \ + -d "$OUT/classes" $(find src android -name '*.java') python3 tools/strip_method_params.py $(find "$OUT/classes" -name '*.class') # 2. dex (format 035 for Dalvik), then disassemble so smali re-assembles a clean dex @@ -44,12 +46,11 @@ java -cp "$APKTOOL:$OUT/tools" Disassemble "$OUT/dex/classes.dex" "$OUT/apk/smal # 3. resources + manifest, compiled by real aapt (v1, the tool of the Gingerbread era) cp AndroidManifest.xml "$OUT/apk/" -cp -r res/values "$OUT/apk/res/" -python3 tools/mkicon.py "$OUT/apk/res/drawable/icon.png" 48 -python3 tools/mkicon.py "$OUT/apk/res/drawable-ldpi/icon.png" 36 +cp -r res/* "$OUT/apk/res/" # launcher icon (ldpi..xhdpi) and the status-bar reminder icon +cp -r assets "$OUT/apk/" # Mozilla root certificates for the bundled TLS cat > "$OUT/apk/apktool.yml" </dev/null + -alias gingercal -keyalg RSA -keysize 2048 -sigalg SHA1withRSA -validity 10000 -dname "CN=Calendar Lite" 2>/dev/null fi java -jar "$APKSIGNER" sign --ks "$KS" --ks-key-alias gingercal --ks-pass pass:android --key-pass pass:android \ --min-sdk-version 8 --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \ --v4-signing-enabled false --out "$OUT/signed.apk" "$OUT/unsigned.apk" -python3 tools/zipalign.py "$OUT/signed.apk" GingerCal.apk +python3 tools/zipalign.py "$OUT/signed.apk" CalendarLite.apk # 5. refuse to ship anything Android 2.3 would reject -java -jar "$APKSIGNER" verify --min-sdk-version 8 --max-sdk-version 10 GingerCal.apk -python3 tools/check_dex.py GingerCal.apk -ls -l GingerCal.apk +java -jar "$APKSIGNER" verify --min-sdk-version 8 --max-sdk-version 10 CalendarLite.apk +python3 tools/check_dex.py CalendarLite.apk +ls -l CalendarLite.apk diff --git a/res/drawable-hdpi/icon.png b/res/drawable-hdpi/icon.png new file mode 100644 index 0000000..cbd0503 Binary files /dev/null and b/res/drawable-hdpi/icon.png differ diff --git a/res/drawable-hdpi/stat_notify.png b/res/drawable-hdpi/stat_notify.png new file mode 100644 index 0000000..f30c78e Binary files /dev/null and b/res/drawable-hdpi/stat_notify.png differ diff --git a/res/drawable-ldpi/icon.png b/res/drawable-ldpi/icon.png new file mode 100644 index 0000000..c3b77dd Binary files /dev/null and b/res/drawable-ldpi/icon.png differ diff --git a/res/drawable-ldpi/stat_notify.png b/res/drawable-ldpi/stat_notify.png new file mode 100644 index 0000000..74c1e67 Binary files /dev/null and b/res/drawable-ldpi/stat_notify.png differ diff --git a/res/drawable-mdpi/icon.png b/res/drawable-mdpi/icon.png new file mode 100644 index 0000000..d14fd01 Binary files /dev/null and b/res/drawable-mdpi/icon.png differ diff --git a/res/drawable-mdpi/stat_notify.png b/res/drawable-mdpi/stat_notify.png new file mode 100644 index 0000000..d6271da Binary files /dev/null and b/res/drawable-mdpi/stat_notify.png differ diff --git a/res/drawable-xhdpi/icon.png b/res/drawable-xhdpi/icon.png new file mode 100644 index 0000000..4e68a55 Binary files /dev/null and b/res/drawable-xhdpi/icon.png differ diff --git a/res/drawable-xhdpi/stat_notify.png b/res/drawable-xhdpi/stat_notify.png new file mode 100644 index 0000000..4a5d20b Binary files /dev/null and b/res/drawable-xhdpi/stat_notify.png differ diff --git a/res/values/strings.xml b/res/values/strings.xml index a2f45c3..6db879e 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1,4 +1,4 @@ - GingerCal + Calendar Lite diff --git a/src/org/gingercal/Dav.java b/src/org/gingercal/Dav.java index 68fbdae..b5ec631 100644 --- a/src/org/gingercal/Dav.java +++ b/src/org/gingercal/Dav.java @@ -80,9 +80,9 @@ public class Dav { } /** - * Sends a request, following redirects on the same server (Apache won't for PROPFIND/REPORT). - * Redirects elsewhere, typically Nextcloud forcing its public https:// address, can't work - * on Android 2.3, so they fail with an explanation instead of a doomed TLS 1.2 attempt. + * Sends a request, following redirects on the same server (Apache won't for PROPFIND/REPORT), + * including http:// to https:// on the same host. Redirects to another server fail with an + * explanation: the password must not follow them. */ Http.Resp send(String method, String target, String depth, String body, String[] extra) throws IOException { @@ -119,16 +119,19 @@ public class Dav { throw new DavException(DavException.REDIRECT, L.t("Too many redirects from the server.")); } + static String host(String url) { + String h = URI.create(url).getHost(); + return h == null ? "" : h.toLowerCase(java.util.Locale.US); + } + static void checkRedirect(String from, String to, List visited, int code) throws DavException { String a = originKey(from), b = originKey(to); - if (a.startsWith("http:") && b.startsWith("https:")) { - throw new DavException(DavException.REDIRECT, L.f("The server redirected http:// to %s (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS').", origin(to))); - } - if (!a.equals(b)) { - throw new DavException(DavException.REDIRECT, L.f("The server redirected to %s, not the address set in the app. Nextcloud is sending its public URL; check the bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS').", origin(to))); + boolean upgrade = a.startsWith("http:") && b.startsWith("https:") && host(from).equals(host(to)); + if (!a.equals(b) && !upgrade) { + throw new DavException(DavException.REDIRECT, L.f("The server redirected to %s, not the address set in the app. Put that address in Settings.", origin(to))); } if (visited.contains(to)) { - throw new DavException(DavException.REDIRECT, L.f("Redirect loop (HTTP %d back to the same address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS').", code)); + throw new DavException(DavException.REDIRECT, L.f("Redirect loop (HTTP %d back to the same address). Use the server's https:// address in Settings.", code)); } } diff --git a/src/org/gingercal/ICal.java b/src/org/gingercal/ICal.java index 7a2c1e1..fb97ed1 100644 --- a/src/org/gingercal/ICal.java +++ b/src/org/gingercal/ICal.java @@ -431,7 +431,7 @@ public class ICal { List l = new ArrayList(); l.add("BEGIN:VCALENDAR"); l.add("VERSION:2.0"); - l.add("PRODID:-//GingerCal//Android 2.3//EN"); + l.add("PRODID:-//CalendarLite//Android 2.3//EN"); l.add("BEGIN:VEVENT"); l.add("UID:" + uid); long now = System.currentTimeMillis(); diff --git a/src/org/gingercal/L.java b/src/org/gingercal/L.java index 070310f..5bd421c 100644 --- a/src/org/gingercal/L.java +++ b/src/org/gingercal/L.java @@ -42,7 +42,7 @@ public final class L { static final String[] GL_DAYS_SHORT = {"", "dom", "lun", "mar", "mér", "xov", "ven", "sáb"}; public static DateFormatSymbols symbols() { - if (!gl) return new DateFormatSymbols(Locale.getDefault()); + if (!gl) return new DateFormatSymbols(Locale.US); // English names, whatever the phone's language DateFormatSymbols s = new DateFormatSymbols(Locale.US); s.setMonths(GL_MONTHS); s.setShortMonths(GL_MONTHS_SHORT); @@ -149,9 +149,9 @@ public final class L { put("Default calendar", "Calendario predeterminado"); // settings - put("GingerCal settings", "Configuración de GingerCal"); - put("Nextcloud server. Android 2.3 can't speak TLS 1.2, so use either your legacy-TLS bridge (https://cloud.example.com:8443) or http:// to the server on your home Wi-Fi. Use an app password, not your real one.", - "Servidor Nextcloud. Android 2.3 non admite TLS 1.2, así que usa a túa ponte TLS antiga (https://cloud.example.com:8443) ou http:// ao servidor na wifi da casa. Usa un contrasinal de aplicación, non o teu contrasinal real."); + put("Calendar Lite settings", "Configuración de Calendar Lite"); + put("Nextcloud server: your normal https:// address. This app brings its own modern TLS, so no bridge is needed, at home or away. Use an app password, not your real one.", + "Servidor Nextcloud: o teu enderezo https:// de sempre. Esta aplicación trae o seu propio TLS moderno, así que non fai falta ningunha ponte, nin na casa nin fóra. Usa un contrasinal de aplicación, non o teu contrasinal real."); put("Server address", "Enderezo do servidor"); put("User name", "Usuario"); put("App password", "Contrasinal de aplicación"); @@ -173,8 +173,8 @@ public final class L { put("Re-downloading in the background", "Descargando todo de novo en segundo plano"); put("Server address and user name are required.", "O enderezo do servidor e o usuario son obrigatorios."); put("Unencrypted connection", "Conexión sen cifrar"); - put("http:// sends your password in clear text over the internet. Use the legacy-TLS bridge instead, or only use http:// to a LAN address.", - "Con http:// o contrasinal viaxa sen cifrar por internet. Usa a ponte TLS antiga ou usa http:// só cun enderezo da rede local."); + put("http:// sends your password in clear text over the internet. Use the https:// address instead, or only use http:// to a LAN address.", + "Con http:// o contrasinal viaxa sen cifrar por internet. Usa o enderezo https://, ou usa http:// só cun enderezo da rede local."); put("Use anyway", "Usar igualmente"); put("Connecting…", "Conectando…"); put("Connection failed", "Erro de conexión"); @@ -183,21 +183,25 @@ public final class L { put("Sync now", "Sincronizar agora"); // certificate trust - put("WARNING: the server certificate changed since you trusted it. That happens when you regenerate the bridge certificate - or when someone is intercepting the connection.\n\n", - "AVISO: o certificado do servidor cambiou desde que confiaches nel. Isto pasa se rexeneras o certificado da ponte, ou se alguén está a interceptar a conexión.\n\n"); - put("This phone doesn't trust the server certificate (normal for the self-signed legacy bridge, or for Let's Encrypt on Android 2.3).\n\n", - "Este teléfono non confía no certificado do servidor (é normal coa ponte antiga autoasinada ou con Let's Encrypt en Android 2.3).\n\n"); + put("WARNING: the server certificate changed since you trusted it. That happens when a self-signed certificate is regenerated - or when someone is intercepting the connection.\n\n", + "AVISO: o certificado do servidor cambiou desde que confiaches nel. Isto pasa se se rexenera un certificado autoasinado, ou se alguén está a interceptar a conexión.\n\n"); + put("The server certificate isn't signed by a known authority (normal for a self-signed server or an old TLS 1.0 bridge).\n\n", + "O certificado do servidor non está asinado por unha autoridade coñecida (é normal nun servidor autoasinado ou nunha ponte TLS 1.0 antiga).\n\n"); put("Subject: %s\nIssuer: %s\nExpires: %s\n\nSHA-256:\n%s\n\nOnly trust it if this matches the fingerprint printed on your server.", "Suxeito: %s\nEmisor: %s\nCaduca: %s\n\nSHA-256:\n%s\n\nConfía nel só se coincide coa pegada dixital que mostra o teu servidor."); put("Certificate changed!", "O certificado cambiou!"); put("Trust this certificate?", "Confiar neste certificado?"); put("Trust", "Confiar"); + put("Interrupted", "Interrompido"); + put("The server certificate is for a different address (%1$s).", "O certificado do servidor é doutro enderezo (%1$s)."); + put("The server certificate has expired, or the phone's date is wrong.", "O certificado do servidor caducou ou a data do teléfono é incorrecta."); put("Server certificate changed (does not match the pinned fingerprint)", "O certificado do servidor cambiou (non coincide coa pegada fixada)"); put("Server certificate is not trusted by this phone", "Este teléfono non confía no certificado do servidor"); // errors put("Can't resolve %s. No connection, or a typo in the server address.", "Non se pode resolver %s. Non hai conexión ou hai un erro no enderezo do servidor."); - put("Can't connect to the server (%s). Wrong port, or the bridge is down?", "Non se pode conectar co servidor (%s). Porto incorrecto ou ponte caída?"); + put("Can't connect to the server (%s). Wrong address or port, or the server is down?", + "Non se pode conectar co servidor (%s). Enderezo ou porto incorrecto, ou servidor caído?"); put("The server took too long to answer.", "O servidor tardou de máis en responder."); put("TLS error: %s", "Erro TLS: %s"); put("Network error: %s", "Erro de rede: %s"); @@ -207,12 +211,10 @@ public final class L { "Acceso rexeitado (401). Comproba o usuario e usa un contrasinal de aplicación de Nextcloud (Configuración > Seguranza > Dispositivos e sesións)."); put("Not allowed to write to this calendar (403). It may be read-only/shared.", "Non tes permiso para escribir neste calendario (403). Pode ser de só lectura ou compartido."); put("Too many redirects from the server.", "O servidor fixo demasiadas redireccións."); - put("The server redirected http:// to %s (Nextcloud forces HTTPS / HSTS). Android 2.3 can't follow that: use the legacy-TLS bridge address instead (see README, 'Forced HTTPS / HSTS').", - "O servidor redirixiu http:// a %s (Nextcloud obriga a usar HTTPS / HSTS). Android 2.3 non pode seguir esa redirección: usa o enderezo da ponte TLS antiga (consulta o README, «Forced HTTPS / HSTS»)."); - put("The server redirected to %s, not the address set in the app. Nextcloud is sending its public URL; check the bridge's proxy_redirect line (README, 'Forced HTTPS / HSTS').", - "O servidor redirixiu a %s, que non é o enderezo configurado na aplicación. Nextcloud está a enviar o seu URL público; revisa a liña proxy_redirect da ponte (README, «Forced HTTPS / HSTS»)."); - put("Redirect loop (HTTP %d back to the same address). Nextcloud forces HTTPS but the bridge reaches it over plain HTTP: switch the bridge to proxy_pass https:// (README, 'Forced HTTPS / HSTS').", - "Bucle de redirección (HTTP %d ao mesmo enderezo). Nextcloud obriga a usar HTTPS pero a ponte conecta con el por HTTP: cambia a ponte a proxy_pass https:// (README, «Forced HTTPS / HSTS»)."); + put("The server redirected to %s, not the address set in the app. Put that address in Settings.", + "O servidor redirixiu a %s, que non é o enderezo configurado na aplicación. Pon ese enderezo na configuración."); + put("Redirect loop (HTTP %d back to the same address). Use the server's https:// address in Settings.", + "Bucle de redirección (HTTP %d ao mesmo enderezo). Usa o enderezo https:// do servidor na configuración."); put("%s %s failed: HTTP %d%s", "%s %s fallou: HTTP %d%s"); put(" (not found)", " (non atopado)"); put(" (not a WebDAV URL?)", " (non é un URL WebDAV?)"); @@ -221,8 +223,6 @@ public final class L { put("Saving failed: HTTP %d", "Non se puido gardar: HTTP %d"); put("Event was changed on the server meanwhile. Sync and try again.", "O evento cambiou no servidor mentres tanto. Sincroniza e téntao de novo."); put("Delete failed: HTTP %d", "Non se puido eliminar: HTTP %d"); - put("TLS handshake failed before any certificate was sent. The server most likely requires TLS 1.2+, which Android 2.3 cannot do. Point the app at the legacy-TLS bridge (see README) or use http:// on your LAN. (%s)", - "A negociación TLS fallou antes de recibir ningún certificado. Seguramente o servidor esixe TLS 1.2 ou superior, que Android 2.3 non admite. Configura a aplicación coa ponte TLS antiga (consulta o README) ou usa http:// na rede local. (%s)"); put("Bad XML from server: %s", "XML incorrecto do servidor: %s"); put("Empty XML from server", "XML baleiro do servidor"); put("%d calendars: %d refreshed (%d events)", "Calendarios: %d, actualizados: %d (eventos: %d)"); diff --git a/src/org/gingercal/Tls.java b/src/org/gingercal/Tls.java index 0f455c7..d2d88ac 100644 --- a/src/org/gingercal/Tls.java +++ b/src/org/gingercal/Tls.java @@ -1,20 +1,10 @@ package org.gingercal; -import java.io.IOException; import java.security.GeneralSecurityException; -import java.security.KeyStore; import java.security.MessageDigest; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.text.SimpleDateFormat; import java.util.Locale; -import javax.net.ssl.SSLException; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; - -/** Certificate pinning for servers the phone can't validate itself (self-signed bridge). */ +/** Certificate fingerprints for pinning servers that aren't CA-signed (self-signed, old bridge). */ public final class Tls { private Tls() {} @@ -38,73 +28,4 @@ public final class Tls { if (s == null) return ""; return s.replaceAll("[^0-9A-Fa-f]", "").toUpperCase(Locale.US); } - - /** Accepts the system CAs, or exactly one pinned leaf certificate (SHA-256). */ - public static class PinTrust implements X509TrustManager { - final String pin; - final X509TrustManager system; - String seenFp, seenSubject, seenIssuer, seenExpiry; - boolean rejected, mismatch; - - public PinTrust(String pin) throws GeneralSecurityException { - this.pin = normPin(pin); - TrustManagerFactory f = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - f.init((KeyStore) null); - X509TrustManager s = null; - TrustManager[] tms = f.getTrustManagers(); - for (int i = 0; i < tms.length; i++) if (tms[i] instanceof X509TrustManager) s = (X509TrustManager) tms[i]; - system = s; - } - - public boolean pinned() { - return pin.length() > 0; - } - - public void reset() { - seenFp = null; - rejected = false; - mismatch = false; - } - - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - throw new CertificateException("client certs not supported"); - } - - public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { - if (chain == null || chain.length == 0) throw new CertificateException("empty chain"); - X509Certificate leaf = chain[0]; - seenFp = sha256(leaf.getEncoded()); - seenSubject = leaf.getSubjectDN().getName(); - seenIssuer = leaf.getIssuerDN().getName(); - seenExpiry = new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(leaf.getNotAfter()); - if (pinned()) { - if (normPin(seenFp).equals(pin)) return; - rejected = true; - mismatch = true; - throw new CertificateException("certificate does not match pin"); - } - try { - if (system == null) throw new CertificateException("no system trust store"); - system.checkServerTrusted(chain, authType); - } catch (CertificateException e) { - rejected = true; - throw e; - } - } - - public X509Certificate[] getAcceptedIssuers() { - return system == null ? new X509Certificate[0] : system.getAcceptedIssuers(); - } - - public IOException translate(IOException e) { - if (rejected && seenFp != null) { - return new DavException.UntrustedCert(seenFp, seenSubject, seenIssuer, seenExpiry, mismatch); - } - if (e instanceof SSLException && seenFp == null) { - return new DavException(0, L.f("TLS handshake failed before any certificate was sent. The server most likely requires TLS 1.2+, which Android 2.3 cannot do. Point the app at the legacy-TLS bridge (see README) or use http:// on your LAN. (%s)", e.getMessage())); - } - return e; - } - } - } diff --git a/stubs/android/content/Context.java b/stubs/android/content/Context.java index 2bae2a6..b4cb558 100644 --- a/stubs/android/content/Context.java +++ b/stubs/android/content/Context.java @@ -6,6 +6,7 @@ public abstract class Context { public abstract android.content.SharedPreferences getSharedPreferences(String name, int mode); public abstract android.content.res.Resources getResources(); public abstract Context getApplicationContext(); + public abstract android.content.res.AssetManager getAssets(); public abstract void startActivity(Intent intent); public abstract ComponentName startService(Intent service); public abstract void sendBroadcast(Intent intent); diff --git a/stubs/android/content/ContextWrapper.java b/stubs/android/content/ContextWrapper.java index 8d88556..0756661 100644 --- a/stubs/android/content/ContextWrapper.java +++ b/stubs/android/content/ContextWrapper.java @@ -7,6 +7,7 @@ public class ContextWrapper extends Context { public SharedPreferences getSharedPreferences(String name, int mode) { throw new RuntimeException("Stub!"); } public android.content.res.Resources getResources() { throw new RuntimeException("Stub!"); } public Context getApplicationContext() { throw new RuntimeException("Stub!"); } + public android.content.res.AssetManager getAssets() { throw new RuntimeException("Stub!"); } public void startActivity(Intent intent) { throw new RuntimeException("Stub!"); } public ComponentName startService(Intent service) { throw new RuntimeException("Stub!"); } public void sendBroadcast(Intent intent) { throw new RuntimeException("Stub!"); } diff --git a/stubs/android/content/res/AssetManager.java b/stubs/android/content/res/AssetManager.java new file mode 100644 index 0000000..a83cfb3 --- /dev/null +++ b/stubs/android/content/res/AssetManager.java @@ -0,0 +1,7 @@ +// Compile-only stub (API level 10 signatures). Never packaged into the APK. +package android.content.res; + +public final class AssetManager { + public final String[] list(String path) throws java.io.IOException { throw new RuntimeException("Stub!"); } + public final java.io.InputStream open(String fileName) throws java.io.IOException { throw new RuntimeException("Stub!"); } +} diff --git a/test/org/gingercal/BridgeTest.java b/test/org/gingercal/BridgeTest.java deleted file mode 100644 index b22d19f..0000000 --- a/test/org/gingercal/BridgeTest.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.gingercal; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.List; -import java.util.UUID; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.TrustManager; - -import org.gingercal.Model.Cal; - -/** Talks to the legacy bridge the way Android 2.3 does: TLS 1.0, AES128-SHA, pinned cert. */ -public class BridgeTest { - static int fails; - - static void check(boolean ok, String what) { - System.out.println((ok ? " ok " : " FAIL ") + what); - if (!ok) fails++; - } - - static class GingerbreadHttp implements Http { - final Tls.PinTrust trust; - final HttpClient c; - - GingerbreadHttp(String pin) throws Exception { - trust = new Tls.PinTrust(pin); - SSLContext ctx = SSLContext.getInstance("TLSv1"); - ctx.init(null, new TrustManager[] {trust}, null); - SSLParameters p = new SSLParameters(new String[] {"TLS_RSA_WITH_AES_128_CBC_SHA"}, new String[] {"TLSv1"}); - c = HttpClient.newBuilder().sslContext(ctx).sslParameters(p).build(); - } - - public Resp exec(String method, String url, String[] h, String body, String ct) throws IOException { - HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).method(method, - body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); - for (int i = 0; i + 1 < h.length; i += 2) b.header(h[i], h[i + 1]); - if (ct != null) b.header("Content-Type", ct); - trust.reset(); - try { - HttpResponse r = c.send(b.build(), HttpResponse.BodyHandlers.ofString()); - Resp o = new Resp(); - o.code = r.statusCode(); - o.body = r.body(); - o.etag = r.headers().firstValue("ETag").orElse(null); - o.location = r.headers().firstValue("Location").orElse(null); - return o; - } catch (IOException e) { - throw trust.translate(e); - } catch (InterruptedException e) { - throw new IOException(e); - } - } - - public void close() {} - } - - public static void main(String[] a) throws Exception { - String url = "https://127.0.0.1:8443"; - String expected = a[0]; - // 1. first contact: not trusted -> app shows fingerprint - try { - new Dav(new GingerbreadHttp(""), url, "xose", "secret").calendarHome(); - check(false, "untrusted cert reported"); - } catch (DavException.UntrustedCert u) { - check(u.fingerprint.equals(expected) && !u.pinMismatch, "untrusted cert reported with fingerprint " + u.fingerprint.substring(0, 11) + "..."); - } - // 2. wrong pin -> mismatch warning - try { - new Dav(new GingerbreadHttp(expected.replace('1', '2')), url, "xose", "secret").calendarHome(); - check(false, "pin mismatch reported"); - } catch (DavException.UntrustedCert u) { - check(u.pinMismatch, "pin mismatch reported"); - } - // 3. pinned: full flow through the bridge - Dav dav = new Dav(new GingerbreadHttp(expected), url, "xose", "secret"); - String home = dav.calendarHome(); - check(home.equals(url + "/remote.php/dav/xose/"), "discovered home via bridge: " + home); - List cals = dav.calendars(home); - check(cals.size() == 2, "calendars via bridge: " + cals.size()); - CoreTest.MemStore st = new CoreTest.MemStore(); - Syncer.Result r = Syncer.sync(dav, st, System.currentTimeMillis(), 1, 60, false); - check(r.events > 0, "sync via bridge: " + r.summary()); - String uid = UUID.randomUUID().toString(); - long s = ICal.startOfDay(System.currentTimeMillis()) + 2 * ICal.DAY + 9 * ICal.HOUR; - Cal w = cals.get(0); - dav.create(w.href, uid, ICal.newEvent(uid, "Via bridge", "", "", s, s + ICal.HOUR, false, 10)); - String[] got = dav.get(w.href + uid + ".ics"); - check(ICal.parse(got[0]).get(0).summary.equals("Via bridge"), "create+get via bridge"); - dav.delete(w.href + uid + ".ics", got[1]); - check(true, "delete via bridge"); - System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED"); - System.exit(fails == 0 ? 0 : 1); - } -} diff --git a/test/org/gingercal/I18nTest.java b/test/org/gingercal/I18nTest.java index be997f7..75ee075 100644 --- a/test/org/gingercal/I18nTest.java +++ b/test/org/gingercal/I18nTest.java @@ -42,7 +42,7 @@ public class I18nTest { Dav.checkRedirect("http://192.168.1.20/remote.php/dav/", "https://nxc.anosanube.ovh/remote.php/dav/", new java.util.ArrayList(), 301); } catch (DavException e) { - check(e.getMessage().substring(0, 58), "O servidor redirixiu http:// a https://nxc.anosanube.ovh ("); + check(e.getMessage().substring(0, 44), "O servidor redirixiu a https://nxc.anosanube"); } check(L.t("not in the table"), "not in the table"); // unknown text falls back to English diff --git a/test/org/gingercal/NetTest.java b/test/org/gingercal/NetTest.java new file mode 100644 index 0000000..ef8056e --- /dev/null +++ b/test/org/gingercal/NetTest.java @@ -0,0 +1,55 @@ +package org.gingercal; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import org.gingercal.net.CertVerifier; + +/** + * The phone's transport (NetHttp: bundled TLS 1.2 client, any method, keep-alive) on a desktop JVM: + * CalDAV discovery against a local Radicale, and certificate checks against real HTTPS servers. + * java -cp out org.gingercal.NetTest assets/roots [http://127.0.0.1:5232 user pass] + */ +public class NetTest { + static int fails; + + static void check(boolean ok, String what) { + System.out.println((ok ? " ok " : " FAIL ") + what); + if (!ok) fails++; + } + + public static void main(String[] a) throws Exception { + final File roots = new File(a[0]); + CertVerifier.setRootStore(new CertVerifier.RootStore() { + public List find(byte[] subject) { + File f = new File(roots, CertVerifier.sha1Hex(subject) + "-0.der"); + try { + return f.exists() ? Collections.singletonList(java.nio.file.Files.readAllBytes(f.toPath())) : null; + } catch (IOException e) { + return null; + } + } + }); + NetHttp h = new NetHttp("", 20000); + Http.Resp r = h.execHere("GET", "https://nxc.anosanube.ovh/status.php", new String[0], null, null); + check(r.code == 200 && r.body.contains("installed"), "Nextcloud over TLS 1.2 with Mozilla roots"); + try { + h.execHere("GET", "https://self-signed.badssl.com/", new String[0], null, null); + check(false, "self-signed refused"); + } catch (DavException.UntrustedCert u) { + check(!u.pinMismatch && u.fingerprint.length() == 95, "self-signed -> trust prompt data"); + Http.Resp p = new NetHttp(u.fingerprint, 20000).execHere("GET", "https://self-signed.badssl.com/", new String[0], null, null); + check(p.code == 200, "pinned self-signed accepted"); + } + if (a.length >= 4) { + Dav d = new Dav(new NetHttp("", 20000), a[1], a[2], a[3]); + String home = d.calendarHome(); + List cals = d.calendars(home); + check(home != null && !cals.isEmpty(), "CalDAV discovery: " + cals.size() + " calendars at " + home); + } + System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED"); + System.exit(fails == 0 ? 0 : 1); + } +} diff --git a/test/org/gingercal/RedirectTest.java b/test/org/gingercal/RedirectTest.java index 51c834b..6b8b327 100644 --- a/test/org/gingercal/RedirectTest.java +++ b/test/org/gingercal/RedirectTest.java @@ -57,15 +57,19 @@ public class RedirectTest { } public static void main(String[] a) throws Exception { - // 1. LAN http:// straight at a Nextcloud that forces HTTPS + // 1. LAN http:// address, Nextcloud sends its public https:// one: another server, not followed String m = expectError("http://192.168.1.20", new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}}); - check(m.startsWith("REDIRECT") && m.contains("forces HTTPS") && m.contains("https://cloud.example.com"), "http->https upgrade explained: " + m.substring(0, 70)); - // 2. bridge passes Nextcloud's absolute public URL through + check(m.startsWith("REDIRECT") && m.contains("Put that address") && m.contains("https://cloud.example.com"), "LAN -> public redirect explained: " + m.substring(0, 70)); + // 1b. http:// -> https:// on the same host is followed now (the app has its own TLS 1.2) + Scripted up = new Scripted(new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}}); + new Dav(up, "http://cloud.example.com", "u", "p").multistatus("PROPFIND", "http://cloud.example.com/remote.php/dav/", "0", ""); + check(up.urls.size() == 2 && up.urls.get(1).startsWith("https://cloud.example.com/"), "http -> https upgrade followed"); + // 2. another server's absolute URL m = expectError("https://vps.example.com:8443", new String[][] {{"302", "https://cloud.example.com/remote.php/dav/"}}); - check(m.startsWith("REDIRECT") && m.contains("proxy_redirect"), "cross-host redirect explained"); - // 3. bridge with http upstream to a forced-HTTPS Nextcloud: same URL again + check(m.startsWith("REDIRECT") && m.contains("Put that address"), "cross-host redirect explained"); + // 3. the same URL again m = expectError("https://vps.example.com:8443", new String[][] {{"301", "/remote.php/dav/"}, {"301", "/remote.php/dav/"}}); - check(m.startsWith("REDIRECT") && m.contains("loop") && m.contains("proxy_pass https://"), "loop explained after 1 retry"); + check(m.startsWith("REDIRECT") && m.contains("loop"), "loop explained after 1 retry"); // 4. default port written out is the same server Scripted s = new Scripted(new String[][] {{"301", "https://vps.example.com:443/remote.php/dav/"}}); new Dav(s, "https://vps.example.com", "u", "p").multistatus("PROPFIND", "https://vps.example.com/remote.php/dav/", "0", ""); @@ -78,9 +82,9 @@ public class RedirectTest { s = new Scripted(new String[][] {{"301", "https://cloud.example.com/remote.php/dav/"}}); try { new Dav(s, "http://192.168.1.20", "u", "p").calendarHome(); - check(false, "discovery reports forced HTTPS"); + check(false, "discovery reports the redirect"); } catch (DavException e) { - check(e.code == DavException.REDIRECT && s.urls.size() == 1, "discovery reports forced HTTPS immediately"); + check(e.code == DavException.REDIRECT && s.urls.size() == 1, "discovery reports a cross-host redirect immediately"); } System.out.println(fails == 0 ? "ALL PASSED" : fails + " FAILED"); System.exit(fails == 0 ? 0 : 1); diff --git a/tools/check_i18n.py b/tools/check_i18n.py index 542f094..e2b1980 100644 --- a/tools/check_i18n.py +++ b/tools/check_i18n.py @@ -49,6 +49,7 @@ UI_CALL = re.compile(r"\b(setText|setTitle|setMessage|setHint|setPositiveButton| r"new DavException\([^,]+,|new IOException\(|label\(|field\([^,]+,)\s*\(?\s*" + LIT) for d in ("src", "android"): for dirpath, _, files in os.walk(os.path.join(root, d)): + if "/net" in dirpath: continue # bundled TLS client: internal messages, not UI text for f in files: if not f.endswith(".java") or f == "L.java": continue diff --git a/tools/mkicon.py b/tools/mkicon.py deleted file mode 100644 index d461462..0000000 --- a/tools/mkicon.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -"""Draws the launcher icon: a little calendar page in ginger tones. -usage: mkicon.py out.png [size] (48 = mdpi, 36 = ldpi like the Galaxy Mini)""" -import sys -from PIL import Image, ImageDraw - -S = 4 # supersample then shrink for smooth edges -W = 48 * S -img = Image.new("RGBA", (W, W), (0, 0, 0, 0)) -d = ImageDraw.Draw(img) -# shadow -d.rounded_rectangle((5 * S, 7 * S, 45 * S, 46 * S), radius=6 * S, fill=(0, 0, 0, 90)) -# page -d.rounded_rectangle((3 * S, 5 * S, 43 * S, 44 * S), radius=6 * S, fill=(250, 246, 238, 255)) -# ginger header band -d.rounded_rectangle((3 * S, 5 * S, 43 * S, 17 * S), radius=6 * S, fill=(214, 110, 42, 255)) -d.rectangle((3 * S, 11 * S, 43 * S, 17 * S), fill=(214, 110, 42, 255)) -# binder rings -for x in (13, 33): - d.rounded_rectangle(((x - 2) * S, 2 * S, (x + 2) * S, 10 * S), radius=2 * S, fill=(90, 60, 40, 255)) -# day grid, one day highlighted -for row in range(3): - for col in range(4): - x0 = (8 + col * 8.5) * S - y0 = (21 + row * 7.5) * S - box = (x0, y0, x0 + 5.5 * S, y0 + 5 * S) - if (row, col) == (1, 2): - d.rounded_rectangle(box, radius=1.5 * S, fill=(0, 130, 201, 255)) # Nextcloud blue - else: - d.rounded_rectangle(box, radius=1.5 * S, fill=(200, 188, 172, 255)) -size = int(sys.argv[2]) if len(sys.argv) > 2 else 48 -img = img.resize((size, size), Image.LANCZOS) -img.save(sys.argv[1] if len(sys.argv) > 1 else "icon.png", optimize=True)