feat(native): client certificates for servers behind mutual TLS

A proxy in front of TREK (Cloudflare, nginx, Caddy, Traefik, HAProxy) can
let in only devices that show a client certificate. The apps could not, so
such a server was unusable from them.

- Address screen: the server check runs natively and notices when the
  server asks for a certificate. Instead of "no server answered" it says so
  and offers to add a .p12/.pfx file; a wrong password asks again, an
  expired, not yet valid or keyless file is named as such.
- The certificate is shown only to the host and port it was added for.
  iOS keeps it in the keychain (this device only), Android seals the key
  with an Android Keystore key. The file's password is never stored.
- The web view answers the server's certificate request on both platforms;
  WebSocket, service worker and API requests go through with it. The
  offline copy, watch requests and the check use it too.
- When the server stops letting the app in (certificate removed, expired
  or no longer trusted) while the page still comes from the service worker
  or the offline copy, a native check sends the app to the address screen,
  which offers to add another. A server that only asks and lets everyone
  in is left alone.
- Cloudflare-style 403 and nginx-style 400 refusals without a certificate
  lead to the same screen instead of staying up as an error page.
- Settings, Account: a Client certificate card (only with one stored)
  shows subject, issuer, server and expiry, with Replace and Remove.
- Wiki: Native-Apps "Client certificates (mTLS)" with Cloudflare, nginx,
  Caddy and Traefik setup and how to make a certificate for the app.
- The app icon on the unreachable screen is black on the light surface.
This commit is contained in:
Maurice
2026-11-09 12:03:31 +01:00
parent d3bdc6de62
commit 07448e662c
56 changed files with 2864 additions and 32 deletions
+68
View File
@@ -119,6 +119,33 @@ export interface TrekShellPlugin {
addToGoogleWallet(options: { url: string }): Promise<{ result: 'opened' }>;
/** Android, feature windowLayout: the window's size classes and a foldable's posture and hinge. */
getWindowLayout(): Promise<NativeWindowLayout>;
/**
* Feature clientCertificate: a GET against the user's server for the address
* screen, made natively. It shows the stored client certificate to that server
* when asked and reports whether the server asked (`certRequested`) and got
* one (`certSent`). A failed connection resolves with status 0.
*/
requestServer(options: { url: string; timeoutMs?: number }): Promise<NativeServerResponse>;
/**
* Feature clientCertificate: the stored client certificate, or null, and the
* last server that asked for one while the app ran (`served`: it got one).
*/
clientCertificate(): Promise<{
certificate: ClientCertificateInfo | null;
request?: { host: string; port: number; served: boolean };
}>;
/**
* Feature clientCertificate: picks a .p12/.pfx file, asks for its password
* (with `labels`, the UI's words) until it opens, and keeps it for the server
* at host and port, replacing any other.
*/
importClientCertificate(options: {
host: string;
port: number;
labels: ClientCertificateLabels;
}): Promise<ClientCertificateImport>;
/** Feature clientCertificate: forgets the stored client certificate. */
removeClientCertificate(): Promise<void>;
addListener(
event: 'windowLayout',
listener: (payload: NativeWindowLayout) => void
@@ -229,6 +256,47 @@ export type NativeLocationPayload =
}
| { error: { code: number; message: string } };
interface NativeServerResponse {
status: number;
data: unknown;
url: string;
headers?: Record<string, string>;
certRequested: boolean;
certSent: boolean;
/** host:port of the server that asked. */
certHost?: string;
/** Why the connection failed, when status is 0. */
error?: string;
}
export interface ClientCertificateInfo {
host: string;
port: number;
/** The certificate's common name. */
subject: string;
/** The issuer's common name. */
issuer: string;
/** Expiry, in milliseconds since 1970. */
notAfter: number;
}
export interface ClientCertificateLabels {
title: string;
message: string;
placeholder: string;
ok: string;
cancel: string;
wrongPassword: string;
}
export type ClientCertificateFailure = 'format' | 'no_key' | 'expired' | 'not_yet_valid' | 'store';
export interface ClientCertificateImport {
result: 'imported' | 'cancelled' | 'failed';
failure?: ClientCertificateFailure;
certificate?: ClientCertificateInfo;
}
export const TrekShell = registerPlugin<TrekShellPlugin>('TrekShell');
export const APP_URL_SCHEME = 'com.liketrek.trek';