From cc5eca5553c61afb20840d43ad905fed2c4122fc Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 12 Nov 2026 08:42:16 +0100 Subject: [PATCH] feat(native): a client certificate can be added on the address screen before connecting The import was only offered once the check saw the proxy ask for a certificate during the handshake. A proxy that refuses without asking left no way in. The wiki also points to the certificate's new place in Settings and explains why a certificate from an iOS configuration profile cannot be used by the app. --- client/native-shell/main.ts | 33 ++++++++++++++++++++++++- client/src/native/serverAddress.test.ts | 17 ++++++++++++- client/src/native/serverAddress.ts | 12 +++++++++ wiki/Native-Apps.md | 6 ++--- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts index 3fd56ef92..543b53d7b 100644 --- a/client/native-shell/main.ts +++ b/client/native-shell/main.ts @@ -19,6 +19,7 @@ import { STORE_TERMS_URL, } from '../src/native/links'; import { + certificateTargetFor, probeDemo, probeServer, type ClientCertDemand, @@ -318,12 +319,41 @@ function showConnect(prefill = '', opts: { back?: boolean; recheck?: boolean } = } input.disabled = false; submit.disabled = false; + // The certificate panel brings its own button for the same file. + addCert.hidden = !gatePanel.hidden; + addCert.disabled = false; label.textContent = t('native.connect.submit'); submit.replaceChildren(label); // A panel with its own next step stays in view; the keyboard would cover it. if (gatePanel.hidden) input.focus(); }; + // The check offers a certificate when the proxy asks for one during the + // handshake. This adds one up front: for someone who knows their proxy wants + // it, and for a proxy that refuses without asking, which the check cannot tell. + const addCert = el('button', { type: 'button', className: 'linkbtn' }, t('native.cert.choose')); + addCert.addEventListener('click', async () => { + if (submit.disabled) return; // a check is running + const target = certificateTargetFor(input.value); + if (!target) { + error.textContent = failureMessage('invalid'); + input.focus(); + return; + } + void tap(); + error.textContent = ''; + addCert.disabled = true; + const outcome = await importCertificate(target.host, target.port); + addCert.disabled = false; + if (outcome === 'imported') { + void notify(NotificationType.Success); + form.requestSubmit(); + } else if (outcome !== 'cancelled') { + void notify(NotificationType.Error); + error.textContent = certFailureMessage(outcome); + } + }); + form.addEventListener('submit', async (event) => { event.preventDefault(); void tap(ImpactStyle.Medium); @@ -332,6 +362,7 @@ function showConnect(prefill = '', opts: { back?: boolean; recheck?: boolean } = gatePanel.replaceChildren(); input.disabled = true; submit.disabled = true; + addCert.disabled = true; submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking')); const result = await probeServer(input.value, get); if (result.ok) { @@ -351,7 +382,7 @@ function showConnect(prefill = '', opts: { back?: boolean; recheck?: boolean } = el('h1', { className: 'title-sm' }, t('native.connect.title')), el('p', { className: 'sub' }, t(opts.recheck ? 'native.connect.recheckHint' : 'native.connect.hint')), ], - [form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))] + [form, addCert, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))] ) ), opts diff --git a/client/src/native/serverAddress.test.ts b/client/src/native/serverAddress.test.ts index ef57307da..4d1528c08 100644 --- a/client/src/native/serverAddress.test.ts +++ b/client/src/native/serverAddress.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { candidateUrls, probeDemo, probeServer, type ProbeResponse } from './serverAddress'; +import { candidateUrls, certificateTargetFor, probeDemo, probeServer, type ProbeResponse } from './serverAddress'; const features = ( url: string, @@ -24,6 +24,21 @@ describe('candidateUrls', () => { }); }); +describe('certificateTargetFor', () => { + it('keeps a certificate for the https address the typed text stands for', () => { + expect(certificateTargetFor('Trek.Example.com')).toEqual({ host: 'trek.example.com', port: 443 }); + expect(certificateTargetFor(' nas.local:8443/trek ')).toEqual({ host: 'nas.local', port: 8443 }); + expect(certificateTargetFor('https://trek.example.com:443/')).toEqual({ host: 'trek.example.com', port: 443 }); + }); + + it('has no target for a plain http address or nothing typed', () => { + expect(certificateTargetFor('http://nas.local:3001')).toBeNull(); + expect(certificateTargetFor('')).toBeNull(); + expect(certificateTargetFor(' ')).toBeNull(); + expect(certificateTargetFor('ftp://trek.example.com')).toBeNull(); + }); +}); + describe('probeServer', () => { it('accepts a server at the minimum native API version or above', async () => { for (const nativeApi of [2, 3]) { diff --git a/client/src/native/serverAddress.ts b/client/src/native/serverAddress.ts index a3ad65e46..a14523146 100644 --- a/client/src/native/serverAddress.ts +++ b/client/src/native/serverAddress.ts @@ -74,6 +74,18 @@ export function candidateUrls(input: string): string[] { return candidates.filter((url): url is string => url !== null); } +/** + * The host and port a client certificate added on the address screen, before + * any check, is kept for: the https address the typed text stands for. Null + * for nothing typed or a plain http address, which never gets a certificate. + */ +export function certificateTargetFor(input: string): { host: string; port: number } | null { + const https = candidateUrls(input).find((url) => url.startsWith('https://')); + if (!https) return null; + const url = new URL(https); + return { host: url.hostname.toLowerCase(), port: url.port ? Number(url.port) : 443 }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } diff --git a/wiki/Native-Apps.md b/wiki/Native-Apps.md index 696cf60bc..6b97a14f0 100644 --- a/wiki/Native-Apps.md +++ b/wiki/Native-Apps.md @@ -50,11 +50,11 @@ For those, let TREK through the proxy instead: a bypass rule for TREK's address A proxy can let in only devices that show a certificate signed by your own certificate authority: mutual TLS, offered by Cloudflare, nginx, Caddy, Traefik and HAProxy. The app supports it, on iOS and Android. -**Adding the certificate.** Enter your server's address as usual. When the proxy asks for a certificate, the app says **This server asks for a client certificate** and offers **Add certificate file**. Pick the `.p12` or `.pfx` file you made for this phone (from Files, iCloud Drive, Google Drive or your downloads), enter its password, and the app connects. The same happens when a stored certificate expires or the proxy stops accepting it: the app returns to that screen instead of showing the proxy's error page. +**Adding the certificate.** Enter your server's address as usual. When the proxy asks for a certificate, the app says **This server asks for a client certificate** and offers **Add certificate file**. You can also add it before connecting: type the address and tap **Add certificate file** below **Connect**. Pick the `.p12` or `.pfx` file you made for this phone (from Files, iCloud Drive, Google Drive or your downloads), enter its password, and the app connects. The same happens when a stored certificate expires or the proxy stops accepting it: the app returns to that screen instead of showing the proxy's error page. -**Where it is kept.** On iOS in the keychain, for this device only and not in backups. On Android the private key is sealed with a key of the Android Keystore, which never leaves it. The password of the file is not stored. The app shows the certificate only to the server it was added for, the exact host and port, and never to a sign-in page, a map provider or any other site. It holds one certificate at a time; certificates installed in the system settings are not used. +**Where it is kept.** On iOS in the keychain, for this device only and not in backups. On Android the private key is sealed with a key of the Android Keystore, which never leaves it. The password of the file is not stored. The app shows the certificate only to the server it was added for, the exact host and port, and never to a sign-in page, a map provider or any other site. It holds one certificate at a time. Certificates installed in the system settings are not used: on iOS, a certificate from a configuration profile is open to Safari and the system but not to other apps, so add the `.p12` to the app as well. -**Changing or removing it.** **Settings → Account → Client certificate** shows whom the certificate names, who issued it, the server it is for and how long it is valid, with **Replace** and **Remove**. +**Changing or removing it.** **Settings → App → Client certificate** shows whom the certificate names, who issued it, the server it is for and how long it is valid, with **Replace** and **Remove**. ### Making a certificate for the app