fix: describe the client certificate the iOS way on iPhone and iPad

This commit is contained in:
Linus Rath
2026-10-31 16:24:09 +01:00
parent d254d7b5ab
commit 48a0590a93
4 changed files with 85 additions and 5 deletions
+3
View File
@@ -1518,9 +1518,12 @@
"choose_another": "Choose another",
"clear": "Clear",
"clear_message": "New requests will no longer present a certificate. The certificate stays installed in Android.",
"clear_message_ios": "New requests will no longer present a certificate. The certificate is removed from the app; to use it again, pick the file again.",
"clear_title": "Stop using client certificate?",
"description": "Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.",
"description_ios": "Imports a certificate file to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.",
"hint": "Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.",
"hint_ios": "Pick the certificate file (.p12 or .pfx) in Files and enter its password.",
"not_set": "Not set",
"pick": "Pick certificate",
"pick_failed": "Pick failed",
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator } from 'react-native';
import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator, Platform } from 'react-native';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import {
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
@@ -20,6 +20,7 @@ import {
import { useLocaleStore } from '../../stores/locale-store';
import { useAuthStore } from '../../stores/auth-store';
import { passwordChangeOutcome } from './password-change';
import { clientCertCopy } from './client-cert-copy';
import {
AccountSecurityForbiddenError,
isLimitedStalwartSignIn,
@@ -50,7 +51,7 @@ import {
import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp';
// ── TLS client certificate (mobile-only) ──────────────────
// Picks an installed Android cert for mTLS handshakes. Independent of the
// Picks an installed Android cert (iOS: imports a .p12 file) for mTLS handshakes. Independent of the
// Stalwart account features below, so it stays visible even on non-Stalwart
// servers.
function ClientCertSection() {
@@ -59,6 +60,7 @@ function ClientCertSection() {
const t = useLocaleStore((s) => s.t);
const [certAlias, setCertAlias] = useState<string | null>(null);
const [certBusy, setCertBusy] = useState(false);
const copy = clientCertCopy(Platform.OS, t);
useEffect(() => {
void getClientCertAlias().then(setCertAlias);
@@ -82,7 +84,7 @@ function ClientCertSection() {
if (certBusy) return;
Alert.alert(
t('settings.security.client_cert.clear_title', "Stop using client certificate?"),
t('settings.security.client_cert.clear_message', "New requests will no longer present a certificate. The certificate stays installed in Android."),
copy.clearMessage,
[
{ text: t('common.cancel', "Cancel"), style: 'cancel' },
{
@@ -105,7 +107,7 @@ function ClientCertSection() {
return (
<SettingsSection
title={t('settings.security.client_cert.title', "TLS client certificate")}
description={t('settings.security.client_cert.description', "Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.")}
description={copy.description}
>
<View style={styles.certRow}>
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
@@ -116,7 +118,7 @@ function ClientCertSection() {
: t('settings.security.client_cert.not_set', "Not set")}
</Text>
<Text style={styles.certHint}>
{t('settings.security.client_cert.hint', "Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.")}
{copy.hint}
</Text>
</View>
</View>
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { clientCertCopy } from '../client-cert-copy';
// Settings > Security > TLS client certificate told iPhone users to install
// the certificate in Android settings. iOS imports a .p12/.pfx file into the
// app instead, and Clear removes it from the app.
const t = (_key: string, fallback: string) => fallback;
const keys = (os: string) => {
const used: string[] = [];
clientCertCopy(os, (key, fallback) => { used.push(key); return fallback; });
return used;
};
describe('client certificate texts', () => {
it('speaks of a certificate file on iOS', () => {
const copy = clientCertCopy('ios', t);
expect(copy.hint).toBe('Pick the certificate file (.p12 or .pfx) in Files and enter its password.');
expect(copy.clearMessage).toContain('removed from the app');
expect(`${copy.description} ${copy.hint} ${copy.clearMessage}`).not.toMatch(/Android/);
expect(keys('ios')).toEqual([
'settings.security.client_cert.description_ios',
'settings.security.client_cert.hint_ios',
'settings.security.client_cert.clear_message_ios',
]);
});
it('keeps the Android texts on Android', () => {
expect(keys('android')).toEqual([
'settings.security.client_cert.description',
'settings.security.client_cert.hint',
'settings.security.client_cert.clear_message',
]);
expect(clientCertCopy('android', t).clearMessage).toContain('stays installed in Android');
});
it('is what the settings section shows', () => {
const src = fs.readFileSync(path.resolve(__dirname, '..', 'AccountSecuritySettings.tsx'), 'utf8');
expect(src).toMatch(/const copy = clientCertCopy\(Platform\.OS, t\);/);
expect(src).toMatch(/description=\{copy\.description\}/);
expect(src).toMatch(/\{copy\.hint\}/);
expect(src).toMatch(/copy\.clearMessage,/);
expect(src).not.toMatch(/t\('settings\.security\.client_cert\.(hint|description|clear_message)'/);
});
});
@@ -0,0 +1,28 @@
// The texts of Settings > Security > TLS client certificate, per platform.
// Android picks a certificate installed in the system (KeyChain); iOS has no
// such store apps can pick from, so the user brings a .p12 or .pfx file that
// is imported into the app's keychain and removed again on Clear
// (plugins/ios-client-cert).
type Translate = (key: string, fallback: string) => string;
export interface ClientCertCopy {
description: string;
hint: string;
clearMessage: string;
}
export function clientCertCopy(os: string, t: Translate): ClientCertCopy {
if (os === 'ios') {
return {
description: t('settings.security.client_cert.description_ios', 'Imports a certificate file to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.'),
hint: t('settings.security.client_cert.hint_ios', 'Pick the certificate file (.p12 or .pfx) in Files and enter its password.'),
clearMessage: t('settings.security.client_cert.clear_message_ios', 'New requests will no longer present a certificate. The certificate is removed from the app; to use it again, pick the file again.'),
};
}
return {
description: t('settings.security.client_cert.description', 'Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.'),
hint: t('settings.security.client_cert.hint', 'Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.'),
clearMessage: t('settings.security.client_cert.clear_message', 'New requests will no longer present a certificate. The certificate stays installed in Android.'),
};
}