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
@@ -7,6 +7,9 @@ import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.activity.result.ActivityResult;
import com.liketrek.trek.clientcert.ClientCertActions;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSArray;
@@ -15,6 +18,7 @@ import com.getcapacitor.PermissionState;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
@@ -65,6 +69,7 @@ public class TrekShellPlugin extends Plugin {
"googleWallet",
"keyCommands",
"windowLayout",
"clientCertificate",
};
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
@@ -676,4 +681,129 @@ public class TrekShellPlugin extends Plugin {
// The last layout WindowManager reported, posture and hinge included; the bare window before the first.
getActivity().runOnUiThread(() -> call.resolve(com.liketrek.trek.screen.TrekWindowLayout.INSTANCE.current(getActivity())));
}
// ---- Client certificates (mutual TLS) ----
/**
* The address screen's check of a server, made natively so it notices
* whether the server asks for a client certificate and can show the stored
* one (clientcert/ClientCertActions.request).
*/
@PluginMethod
public void requestServer(PluginCall call) {
String url = call.getString("url");
Uri parsed = url == null ? null : Uri.parse(url);
if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) {
call.reject("A http(s) URL is required");
return;
}
int timeout = Math.max(1000, Math.min(call.getInt("timeoutMs", 10000), 30000));
new Thread(() -> {
try {
call.resolve(ClientCertActions.request(getContext(), url, timeout));
} catch (Exception e) {
call.reject(e.getMessage() == null ? "request failed" : e.getMessage());
}
}, "trek-request-server").start();
}
/** The stored certificate's details, or none. */
@PluginMethod
public void clientCertificate(PluginCall call) {
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
JSObject result = new JSObject();
result.put("certificate", info == null ? JSONObject.NULL : ClientCertActions.infoObject(info));
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
if (asked != null) {
JSObject request = new JSObject();
request.put("host", asked.getHost());
request.put("port", asked.getPort());
request.put("served", asked.getServed());
result.put("request", request);
}
call.resolve(result);
}
@PluginMethod
public void removeClientCertificate(PluginCall call) {
TrekClientIdentity.remove(getContext());
forgetCertificateAnswers();
call.resolve();
}
/** The WebView keeps its answer to a certificate request per host; a changed certificate needs a fresh one. */
private void forgetCertificateAnswers() {
com.liketrek.trek.clientcert.ClientCertWatch.reset();
getActivity().runOnUiThread(() -> android.webkit.WebView.clearClientCertPreferences(null));
}
/**
* Picks a .p12/.pfx file, asks for its password until it opens or the user
* gives up, and keeps it for the server at host and port. Resolves with
* result "imported" (and the certificate), "cancelled", or "failed" and why.
*/
@PluginMethod
public void importClientCertificate(PluginCall call) {
String host = call.getString("host");
if (host == null || !host.matches("[A-Za-z0-9.-]+")) {
call.reject("A host is required");
return;
}
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*");
pick.putExtra(Intent.EXTRA_MIME_TYPES, new String[] { "application/x-pkcs12", "application/pkcs12", "application/octet-stream" });
startActivityForResult(call, pick, "clientCertificatePicked");
}
@ActivityCallback
private void clientCertificatePicked(PluginCall call, ActivityResult picked) {
if (call == null) return;
Uri uri = picked.getData() == null ? null : picked.getData().getData();
if (picked.getResultCode() != android.app.Activity.RESULT_OK || uri == null) {
resolveImport(call, "cancelled", null);
return;
}
new Thread(() -> {
byte[] bytes = ClientCertActions.readFile(getContext(), uri);
if (bytes == null) resolveImport(call, "failed", TrekClientIdentity.Failure.FORMAT.name());
else askPasswordFor(call, bytes, false);
}, "trek-client-cert").start();
}
private void askPasswordFor(PluginCall call, byte[] bytes, boolean wrong) {
JSObject labels = call.getObject("labels", new JSObject());
ClientCertActions.askPassword(getActivity(), labels, wrong, password -> {
if (password == null) {
resolveImport(call, "cancelled", null);
return kotlin.Unit.INSTANCE;
}
new Thread(() -> openWith(call, bytes, password), "trek-client-cert").start();
return kotlin.Unit.INSTANCE;
});
}
private void openWith(PluginCall call, byte[] bytes, char[] password) {
try {
TrekClientIdentity.Identity identity = TrekClientIdentity.read(bytes, password);
TrekClientIdentity.save(getContext(), identity, call.getString("host"), call.getInt("port", 443));
forgetCertificateAnswers();
resolveImport(call, "imported", null);
} catch (TrekClientIdentity.ImportException e) {
if (e.getReason() == TrekClientIdentity.Failure.PASSWORD) askPasswordFor(call, bytes, true);
else resolveImport(call, "failed", e.getReason().name());
} catch (Exception e) {
Log.w(TAG, "client certificate not kept", e);
resolveImport(call, "failed", "STORE");
} finally {
java.util.Arrays.fill(password, '\0');
}
}
private void resolveImport(PluginCall call, String result, String failure) {
JSObject out = new JSObject();
out.put("result", result);
if (failure != null) out.put("failure", failure.toLowerCase(java.util.Locale.ROOT));
TrekClientIdentity.Info info = TrekClientIdentity.info(getContext());
if ("imported".equals(result) && info != null) out.put("certificate", ClientCertActions.infoObject(info));
call.resolve(out);
}
}
@@ -1,12 +1,14 @@
package com.liketrek.trek;
import android.net.Uri;
import android.webkit.ClientCertRequest;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebView;
import com.getcapacitor.Bridge;
import com.getcapacitor.BridgeWebViewClient;
import com.liketrek.trek.clientcert.TrekClientIdentity;
import java.util.Locale;
import java.util.Set;
@@ -70,6 +72,28 @@ class TrekWebViewClient extends BridgeWebViewClient {
return super.shouldInterceptRequest(view, request);
}
/**
* A proxy in front of the server asks for a client certificate (mutual TLS).
* The stored one goes only to the host and port it was imported for; every
* other site that asks is told there is none, as a browser without a
* matching certificate would. The WebView remembers either answer for the
* host until the app restarts, which a new certificate always brings along.
*/
@Override
public void onReceivedClientCertRequest(WebView view, ClientCertRequest request) {
TrekClientIdentity.Identity identity = TrekClientIdentity.identityFor(view.getContext(), request.getHost(), request.getPort());
TrekClientIdentity.noteRequest(request.getHost(), request.getPort(), identity != null);
if (identity != null) request.proceed(identity.getKey(), identity.getChain());
else request.ignore();
// The page may still come from the service worker's cache while every
// request fails: check natively whether the server lets the app in.
String server = TrekServerStore.getUrl(view.getContext());
Uri serverUri = server == null ? null : Uri.parse(server);
if (serverUri != null && TrekClientIdentity.matches(String.valueOf(serverUri.getHost()), serverUri.getPort() > 0 ? serverUri.getPort() : 443, request.getHost(), request.getPort())) {
com.liketrek.trek.clientcert.ClientCertWatch.serverAsked(view.getContext(), server, onUnreachable);
}
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) {
@@ -82,14 +106,22 @@ class TrekWebViewClient extends BridgeWebViewClient {
/**
* Only a 5xx on the page itself means the server is down (a proxy's 502 or
* 503 while TREK restarts). A 4xx page is left alone: the server answered,
* and TREK serves its UI for every other path.
* and TREK serves its UI for every other path. The exception is a proxy
* that asked this page for a client certificate and got none, as Cloudflare
* does with a 403: the address screen then says so and offers to add one.
*/
@Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
int status = errorResponse.getStatusCode();
if (!request.isForMainFrame()) {
super.onReceivedHttpError(view, request, errorResponse);
} else if (errorResponse.getStatusCode() >= 500) {
} else if (status >= 500 || (status >= 400 && wantedCertificate(request.getUrl()))) {
onUnreachable.run();
}
}
private static boolean wantedCertificate(Uri url) {
TrekClientIdentity.Request asked = TrekClientIdentity.INSTANCE.getLastRequest();
return asked != null && !asked.getServed() && TrekClientIdentity.matches(asked.getHost(), asked.getPort(), url.getHost(), url.getPort());
}
}
@@ -0,0 +1,151 @@
package com.liketrek.trek.clientcert
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.net.Uri
import android.text.InputType
import android.view.ViewGroup
import android.widget.EditText
import android.widget.FrameLayout
import com.getcapacitor.JSObject
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/**
* What TrekShellPlugin does for client certificates (TrekClientIdentity):
* check a server while noticing whether it asks for one, read a picked file,
* and ask for its password.
*/
object ClientCertActions {
private const val MAX_BODY_BYTES = 512 * 1024
private const val MAX_FILE_BYTES = 256 * 1024
private const val MAX_REDIRECTS = 5
/**
* A GET against the user's server for the address screen: status, body, final
* URL and headers, plus whether the server asked for a client certificate and
* whether one was shown. A failed connection answers status 0 instead of
* throwing, so a handshake the server broke off for want of a certificate
* still reports that it asked.
*/
@JvmStatic
fun request(context: Context, url: String, timeoutMs: Int): JSObject {
var asked: Triple<String, Int, Boolean>? = null
val factory = TrekClientIdentity.socketFactory(context) { host, port, served -> asked = Triple(host, port, served) }
val result = JSObject()
var current = URL(url)
try {
for (hop in 0..MAX_REDIRECTS) {
val connection = current.openConnection() as HttpURLConnection
if (connection is HttpsURLConnection) connection.sslSocketFactory = factory
connection.instanceFollowRedirects = false
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
try {
val status = connection.responseCode
val location = connection.getHeaderField("Location")
if (status in 300..399 && location != null && hop < MAX_REDIRECTS) {
current = URL(current, location)
continue
}
val stream = if (status >= 400) connection.errorStream else connection.inputStream
result.put("status", status)
result.put("data", stream?.use { readCapped(it, MAX_BODY_BYTES) }?.toString(Charsets.UTF_8) ?: "")
result.put("url", current.toString())
val headers = JSObject()
for ((name, values) in connection.headerFields) if (name != null && values.isNotEmpty()) headers.put(name, values.first())
result.put("headers", headers)
} finally {
connection.disconnect()
}
break
}
} catch (e: IOException) {
result.put("status", 0)
result.put("data", "")
result.put("url", current.toString())
result.put("error", e.javaClass.simpleName)
}
val seen = asked
result.put("certRequested", seen != null)
result.put("certSent", seen?.third == true)
if (seen != null) result.put("certHost", "${seen.first}:${seen.second}")
return result
}
/** The bytes of a picked file, or null when it cannot be read or is far too big for a certificate. */
@JvmStatic
fun readFile(context: Context, uri: Uri): ByteArray? = try {
context.contentResolver.openInputStream(uri)?.use { readCapped(it, MAX_FILE_BYTES + 1) }?.takeIf { it.size <= MAX_FILE_BYTES }
} catch (e: Exception) {
null
}
/**
* The password prompt, with the UI's own words (`labels`: title, message,
* placeholder, ok, cancel, wrongPassword). `wrong` shows that the last try
* did not open the file. `done` gets the password, or null when cancelled.
*/
@JvmStatic
fun askPassword(activity: Activity, labels: JSObject, wrong: Boolean, done: (CharArray?) -> Unit) {
activity.runOnUiThread {
val input = EditText(activity).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
hint = labels.getString("placeholder") ?: ""
}
val frame = FrameLayout(activity).apply {
val pad = (20 * activity.resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
addView(input, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}
val message = if (wrong) labels.getString("wrongPassword") else labels.getString("message")
var answered = false
val dialog = AlertDialog.Builder(activity)
.setTitle(labels.getString("title"))
.setMessage(message)
.setView(frame)
.setPositiveButton(labels.getString("ok") ?: "OK") { _, _ ->
answered = true
val text = input.text
val password = CharArray(text.length) { text[it] }
done(password)
}
.setNegativeButton(labels.getString("cancel") ?: "Cancel") { _, _ ->
answered = true
done(null)
}
.setOnDismissListener { if (!answered) done(null) }
.create()
dialog.show()
input.requestFocus()
}
}
/** The settings' view of the stored certificate. */
@JvmStatic
fun infoObject(info: TrekClientIdentity.Info): JSObject = JSObject().apply {
put("host", info.host)
put("port", info.port)
put("subject", info.subject)
put("issuer", info.issuer)
put("notAfter", info.notAfter)
}
private fun readCapped(stream: InputStream, max: Int): ByteArray {
val out = ByteArrayOutputStream()
val buffer = ByteArray(8192)
while (out.size() < max) {
val n = stream.read(buffer, 0, minOf(buffer.size, max - out.size()))
if (n < 0) break
out.write(buffer, 0, n)
}
return out.toByteArray()
}
}
@@ -0,0 +1,50 @@
package com.liketrek.trek.clientcert
import android.content.Context
import java.util.concurrent.atomic.AtomicLong
/**
* Notices when the user's server stops letting the app in for want of a
* client certificate: one removed, expired, or no longer trusted by the proxy.
*
* The page alone does not show it. The service worker serves TREK's start page
* from its cache, so the page loads and only its requests fail, and the UI
* reports a server it cannot reach with no way to add a certificate. So when
* the server asks for one, the app checks once natively whether TREK answers
* with what it holds. Only a check that fails at the certificate (the handshake
* broke off, or the proxy answered with its own 4xx) sends the app to the
* address screen, which says so and offers to add one. A server that merely
* asks and lets everyone in answers TREK's 200, and nothing happens.
*/
object ClientCertWatch {
private const val MIN_INTERVAL_MS = 60_000L
private const val FEATURES_PATH = "/api/health/features"
private const val TIMEOUT_MS = 8000
private val lastCheck = AtomicLong(0)
/** Called where the server asked for a certificate; `onRefused` runs at most once a minute, on a background thread. */
@JvmStatic
fun serverAsked(context: Context, serverUrl: String?, onRefused: Runnable) {
if (serverUrl == null) return
val now = System.currentTimeMillis()
val last = lastCheck.get()
if (now - last < MIN_INTERVAL_MS || !lastCheck.compareAndSet(last, now)) return
val appContext = context.applicationContext
Thread({
val result = ClientCertActions.request(appContext, serverUrl.trimEnd('/') + FEATURES_PATH, TIMEOUT_MS)
if (refused(result.optBoolean("certRequested"), result.optInt("status"))) onRefused.run()
}, "trek-client-cert-watch").start()
}
/** A new or removed certificate is worth a check of its own, whenever the last one ran. */
@JvmStatic
fun reset() {
lastCheck.set(0)
}
/** The server asked for a certificate and did not answer with TREK. */
@JvmStatic
fun refused(certRequested: Boolean, status: Int): Boolean = certRequested && (status == 0 || status in 400..499)
}
@@ -0,0 +1,261 @@
package com.liketrek.trek.clientcert
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.io.ByteArrayInputStream
import java.io.IOException
import java.net.Socket
import java.security.KeyFactory
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.UnrecoverableKeyException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Date
import java.util.Locale
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509ExtendedKeyManager
/**
* The client certificate a user's TREK server asks for (mutual TLS): a proxy
* such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through only
* devices that show one. The app holds at most one, for one server.
*
* It comes in as a PKCS#12 file (.p12, .pfx) and its password. Neither is kept:
* the private key is sealed with an AES key of the Android keystore, which
* never leaves it, and stored with the certificate chain in the app's private
* preferences. In memory the key is an ordinary one, which TLS 1.2 and 1.3 and
* the WebView all take, whatever its kind; a key imported into the keystore
* itself would be refused by some of them for its padding or digest rules.
*
* Only the host and port it was imported for ever see it (`identityFor`), so a
* login page, a map tile provider or liketrek.com never learns of it.
*/
object TrekClientIdentity {
private const val PREFS = "trek_client_identity"
private const val WRAP_ALIAS = "trek-client-identity-wrap"
private const val KEYMANAGER_ALIAS = "trek-client-identity"
private const val GCM_TAG_BITS = 128
/** Why a file could not be taken. */
enum class Failure { PASSWORD, FORMAT, NO_KEY, EXPIRED, NOT_YET_VALID }
class ImportException(val reason: Failure) : Exception(reason.name)
class Identity(val key: PrivateKey, val chain: Array<X509Certificate>)
/** What the settings show: who the certificate names, who issued it, until when, and for which server. */
data class Info(val host: String, val port: Int, val subject: String, val issuer: String, val notAfter: Long)
/** The opened identity, with the sealed key text it was opened from: opened once, not per request. */
@Volatile private var cached: Pair<String, Identity>? = null
/** The last time a server asked for a certificate while the app showed it: for the address screen. */
@Volatile var lastRequest: Request? = null
private set
data class Request(val host: String, val port: Int, val served: Boolean)
private class Stored(val host: String, val port: Int, val algorithm: String, val sealedText: String, val iv: ByteArray, val chain: List<ByteArray>)
/**
* Reads a PKCS#12 file: the first entry that holds a private key, with its chain.
* Throws ImportException naming what is wrong with it.
*/
@JvmStatic
@JvmOverloads
@Throws(ImportException::class)
fun read(bytes: ByteArray, password: CharArray, now: Date = Date()): Identity {
val store = KeyStore.getInstance("PKCS12")
try {
store.load(ByteArrayInputStream(bytes), password)
} catch (e: IOException) {
throw ImportException(if (isPasswordFailure(e)) Failure.PASSWORD else Failure.FORMAT)
} catch (e: Exception) {
throw ImportException(Failure.FORMAT)
}
val alias = store.aliases().toList().firstOrNull { store.isKeyEntry(it) } ?: throw ImportException(Failure.NO_KEY)
val key = try {
store.getKey(alias, password) as? PrivateKey
} catch (e: UnrecoverableKeyException) {
throw ImportException(Failure.PASSWORD)
} ?: throw ImportException(Failure.NO_KEY)
val chain = store.getCertificateChain(alias)?.mapNotNull { it as? X509Certificate }?.toTypedArray()
if (chain.isNullOrEmpty()) throw ImportException(Failure.NO_KEY)
val leaf = chain[0]
if (now.after(leaf.notAfter)) throw ImportException(Failure.EXPIRED)
if (now.before(leaf.notBefore)) throw ImportException(Failure.NOT_YET_VALID)
return Identity(key, chain)
}
/** A wrong password shows up as a failed MAC check or an unrecoverable key, wrapped in an IOException. */
private fun isPasswordFailure(e: Throwable): Boolean {
var cause: Throwable? = e
while (cause != null) {
if (cause is UnrecoverableKeyException || cause is javax.crypto.BadPaddingException) return true
if (cause.message?.lowercase(Locale.ROOT)?.contains("password") == true) return true
cause = cause.cause
}
return false
}
/** Keeps the identity for this host and port, replacing any other. */
@JvmStatic
fun save(context: Context, identity: Identity, host: String, port: Int) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, wrapKey())
val sealed = cipher.doFinal(identity.key.encoded)
prefs(context).edit()
.putString("host", host.lowercase(Locale.ROOT))
.putInt("port", port)
.putString("algorithm", identity.key.algorithm)
.putString("key", encode(sealed))
.putString("iv", encode(cipher.iv))
.putString("chain", identity.chain.joinToString(",") { encode(it.encoded) })
.apply()
cached = null
}
@JvmStatic
fun remove(context: Context) {
prefs(context).edit().clear().apply()
cached = null
}
@JvmStatic
fun info(context: Context): Info? {
val stored = stored(context) ?: return null
val leaf = runCatching { certificate(stored.chain.first()) }.getOrNull() ?: return null
return Info(stored.host, stored.port, commonName(leaf.subjectX500Principal), commonName(leaf.issuerX500Principal), leaf.notAfter.time)
}
/** The identity for a server at this host and port, or null when there is none for it. */
@JvmStatic
fun identityFor(context: Context, host: String?, port: Int): Identity? {
val stored = stored(context) ?: return null
if (!matches(stored.host, stored.port, host, port)) return null
return opened(stored)
}
private fun opened(stored: Stored): Identity? {
cached?.let { (from, identity) -> if (from == stored.sealedText) return identity }
val identity = runCatching { open(stored) }.getOrNull() ?: return null
cached = stored.sealedText to identity
return identity
}
/** Whether a request to host and port (-1 or 0 for the scheme's default) goes to the server the identity is for. */
@JvmStatic
fun matches(storedHost: String, storedPort: Int, host: String?, port: Int): Boolean {
if (host == null || !host.equals(storedHost, ignoreCase = true)) return false
val effective = if (port <= 0) 443 else port
return effective == storedPort
}
/** Called where a server asked for a certificate, so the address screen can offer one. */
@JvmStatic
fun noteRequest(host: String?, port: Int, served: Boolean) {
if (host == null) return
lastRequest = Request(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, served)
}
/**
* A socket factory for the app's own requests (the server check, map tiles).
* It offers the identity only when the server it is talking to asks for one
* and is the one the identity is for, and tells `onAsked` about every ask.
*/
@JvmStatic
fun socketFactory(context: Context, onAsked: ((host: String, port: Int, served: Boolean) -> Unit)? = null): SSLSocketFactory {
val appContext = context.applicationContext
val manager = object : X509ExtendedKeyManager() {
// Decided per handshake in choose(); the key and chain are then read for
// the one alias, so parallel connections never see each other's choice.
private fun choose(host: String?, port: Int): String? {
val identity = identityFor(appContext, host, port)
if (host != null) {
noteRequest(host, port, identity != null)
onAsked?.invoke(host.lowercase(Locale.ROOT), if (port <= 0) 443 else port, identity != null)
}
return if (identity != null) KEYMANAGER_ALIAS else null
}
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? {
val session = (socket as? SSLSocket)?.handshakeSession
return choose(session?.peerHost, session?.peerPort ?: -1)
}
override fun chooseEngineClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, engine: SSLEngine?): String? =
choose(engine?.peerHost, engine?.peerPort ?: -1)
override fun getCertificateChain(alias: String?): Array<X509Certificate>? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.chain else null
override fun getPrivateKey(alias: String?): PrivateKey? =
if (alias == KEYMANAGER_ALIAS) stored(appContext)?.let { opened(it) }?.key else null
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String? = null
}
val tls = SSLContext.getInstance("TLS")
tls.init(arrayOf(manager), null, null)
return tls.socketFactory
}
private fun open(stored: Stored): Identity {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, wrapKey(), GCMParameterSpec(GCM_TAG_BITS, stored.iv))
val key = KeyFactory.getInstance(stored.algorithm).generatePrivate(PKCS8EncodedKeySpec(cipher.doFinal(decode(stored.sealedText))))
return Identity(key, stored.chain.map { certificate(it) }.toTypedArray())
}
private fun stored(context: Context): Stored? {
val p = prefs(context)
val host = p.getString("host", null) ?: return null
val algorithm = p.getString("algorithm", null) ?: return null
val key = p.getString("key", null) ?: return null
val iv = p.getString("iv", null) ?: return null
val chain = p.getString("chain", null)?.split(",")?.filter { it.isNotEmpty() }?.map { decode(it) } ?: return null
if (chain.isEmpty()) return null
return Stored(host, p.getInt("port", 443), algorithm, key, decode(iv), chain)
}
/** The keystore's AES key that seals the private key; made once, never leaves the keystore. */
private fun wrapKey(): SecretKey {
val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keystore.getKey(WRAP_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(WRAP_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
return generator.generateKey()
}
private fun certificate(der: ByteArray): X509Certificate =
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der)) as X509Certificate
/** The CN of a name, or the whole name when it has none. */
@JvmStatic
fun commonName(principal: javax.security.auth.x500.X500Principal): String {
val name = principal.getName(javax.security.auth.x500.X500Principal.RFC2253)
return Regex("(?:^|,)CN=((?:\\\\.|[^,])*)").find(name)?.groupValues?.get(1)?.replace("\\", "") ?: name
}
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun encode(bytes: ByteArray) = Base64.encodeToString(bytes, Base64.NO_WRAP)
private fun decode(text: String) = Base64.decode(text, Base64.NO_WRAP)
}
@@ -0,0 +1,89 @@
package com.liketrek.trek
import com.liketrek.trek.clientcert.ClientCertWatch
import com.liketrek.trek.clientcert.TrekClientIdentity
import com.liketrek.trek.clientcert.TrekClientIdentity.Failure
import java.util.Date
import javax.security.auth.x500.X500Principal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
/**
* Reading a client certificate file (TrekClientIdentity.read) and the rule for
* which server sees it. The files in test/resources/clientcert are test-only:
* a throwaway CA, password "trek-test". valid.p12 has OpenSSL 3's default
* encryption (AES-256, PBKDF2), valid-legacy.p12 the older RC2/3DES one that
* many routers and NAS systems still export.
*/
class ClientIdentityTest {
private val password = "trek-test".toCharArray()
private val inside = Date(1_800_000_000_000L) // 2027-01-15, inside every valid test certificate
private fun file(name: String): ByteArray =
requireNotNull(javaClass.classLoader?.getResourceAsStream("clientcert/$name")) { name }.use { it.readBytes() }
private fun failure(name: String, pass: CharArray = password, now: Date = inside): Failure {
try {
TrekClientIdentity.read(file(name), pass, now)
} catch (e: TrekClientIdentity.ImportException) {
return e.reason
}
fail("$name opened")
throw AssertionError()
}
@Test
fun opensModernAndLegacyFiles() {
for (name in listOf("valid.p12", "valid-legacy.p12")) {
val identity = TrekClientIdentity.read(file(name), password, inside)
assertEquals("trek-test-valid", TrekClientIdentity.commonName(identity.chain[0].subjectX500Principal))
assertEquals("TREK mTLS Test CA", TrekClientIdentity.commonName(identity.chain[0].issuerX500Principal))
assertEquals("RSA", identity.key.algorithm)
}
}
@Test
fun namesWhatIsWrongWithAFile() {
assertEquals(Failure.PASSWORD, failure("valid.p12", "wrong".toCharArray()))
assertEquals(Failure.PASSWORD, failure("valid-legacy.p12", "wrong".toCharArray()))
assertEquals(Failure.EXPIRED, failure("expired.p12"))
assertEquals(Failure.NOT_YET_VALID, failure("future.p12"))
assertEquals(Failure.NO_KEY, failure("cert-only.p12"))
try {
TrekClientIdentity.read("not a certificate".toByteArray(), password, inside)
fail("text opened")
} catch (e: TrekClientIdentity.ImportException) {
assertEquals(Failure.FORMAT, e.reason)
}
}
@Test
fun showsTheCertificateOnlyToItsHostAndPort() {
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "Trek.Example.com", -1))
assertTrue(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 443))
assertTrue(TrekClientIdentity.matches("nas.local", 8443, "nas.local", 8443))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "auth.example.com", 443))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, "trek.example.com", 8443))
assertFalse(TrekClientIdentity.matches("nas.local", 8443, "nas.local", -1))
assertFalse(TrekClientIdentity.matches("trek.example.com", 443, null, 443))
}
@Test
fun leavesForTheAddressScreenOnlyWhenTheCertificateIsWhatFails() {
assertTrue(ClientCertWatch.refused(true, 0)) // handshake broken off: nginx, Caddy, Traefik
assertTrue(ClientCertWatch.refused(true, 403)) // Cloudflare's block page
assertTrue(ClientCertWatch.refused(true, 400)) // nginx without a valid certificate
assertFalse(ClientCertWatch.refused(true, 200)) // asks, but lets everyone in
assertFalse(ClientCertWatch.refused(true, 502)) // TREK restarting behind the proxy
assertFalse(ClientCertWatch.refused(false, 0)) // simply offline
}
@Test
fun readsTheCommonNameOrFallsBackToTheWholeName() {
assertEquals("Phone, home", TrekClientIdentity.commonName(X500Principal("CN=Phone\\, home,O=Family")))
assertEquals("O=Family", TrekClientIdentity.commonName(X500Principal("O=Family")))
}
}
@@ -51,6 +51,7 @@
81E4582AD7514CA1AF373E48 /* WatchSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842D54F96D80A1D1C4C21457 /* WatchSnapshot.swift */; };
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */; };
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */; };
896952F864B35C647FF6910A /* WatchSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10FB55D221F4BA18795F33B5 /* WatchSample.swift */; };
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
8FFB2B488E43AD339D0BB450 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3A6054202DCC03D83C2C9602 /* Localizable.strings */; };
@@ -172,6 +173,7 @@
10FB55D221F4BA18795F33B5 /* WatchSample.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSample.swift; sourceTree = "<group>"; };
114C13FB91B554AE8DC4372B /* et */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekWatchLink.swift; sourceTree = "<group>"; };
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekClientIdentity.swift; sourceTree = "<group>"; };
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
1335F1EEB49D744B27A00F31 /* th */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -420,6 +422,7 @@
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
125A1BB381BBF7E58B8902FD /* TrekWatchLink.swift */,
A1C3E5F70913B5D7F9E1A302 /* TrekClientIdentity.swift */,
);
path = App;
sourceTree = "<group>";
@@ -823,6 +826,7 @@
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */,
0C2D60A4950D7293E1F282CB /* TripDayAttributes.swift in Sources */,
8925D4B7322D6930F3C02959 /* TrekWatchLink.swift in Sources */,
A1C3E5F70913B5D7F9E1A301 /* TrekClientIdentity.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -479,6 +479,49 @@ final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
}
}
/// A proxy in front of the server asks for a client certificate (mutual TLS):
/// the stored one goes to the host and port it was imported for and nowhere
/// else (TrekClientIdentity). Every other challenge is Capacitor's as before.
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
if TrekClientIdentity.answer(challenge, completionHandler) {
let space = challenge.protectionSpace
TrekClientCertWatch.serverAsked(host: space.host, port: space.port) { [weak self, weak webView] in
guard let self, let webView else { return }
// Capacitor's own answer to a failed load: the address screen (errorPath).
self.inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRejected))
}
return
}
let selector = NSSelectorFromString("webView:didReceiveAuthenticationChallenge:completionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
completionHandler(.performDefaultHandling, nil)
return
}
inner.webView?(webView, didReceive: challenge, completionHandler: completionHandler)
}
/// A proxy that asked the page for a client certificate, got none and
/// answered with its own refusal (Cloudflare's 403, nginx's 400) sends the
/// app to the address screen, which says so and offers to add one, rather
/// than leaving the proxy's error page up.
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if navigationResponse.isForMainFrame, let http = navigationResponse.response as? HTTPURLResponse,
http.statusCode >= 400, http.statusCode < 500, let asked = TrekClientIdentity.lastRequest, !asked.served,
let url = http.url, url.host?.lowercased() == asked.host, (url.port ?? 443) == asked.port {
decisionHandler(.cancel)
inner.webView?(webView, didFailProvisionalNavigation: nil, withError: URLError(.clientCertificateRequired))
return
}
let selector = NSSelectorFromString("webView:decidePolicyForNavigationResponse:decisionHandler:")
guard (inner as AnyObject).responds(to: selector) else {
decisionHandler(.allow)
return
}
inner.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: decisionHandler)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
if Self.isReplaced(error) { return }
inner.webView?(webView, didFail: navigation, withError: error)
+393
View File
@@ -0,0 +1,393 @@
import Foundation
import Security
import UIKit
import UniformTypeIdentifiers
/// The client certificate a user's TREK server asks for (mutual TLS): a proxy
/// such as Cloudflare, nginx, Caddy or Traefik in front of TREK lets through
/// only devices that show one. The app holds at most one, for one server.
///
/// It comes in as a PKCS#12 file (.p12, .pfx) and its password. The password is
/// never kept; the identity goes into the keychain, this device only, readable
/// after the first unlock (so a background refresh can show it too). Only the
/// host and port it was imported for ever see it (`identity(forHost:port:)`), so a
/// sign-in page, a map tile provider or liketrek.com never learns of it.
enum TrekClientIdentity {
private static let label = "trek-client-identity"
private static let hostKey = "TrekClientIdentityHost"
private static let portKey = "TrekClientIdentityPort"
enum Failure: String, Error {
case password, format, noKey = "no_key", expired, notYetValid = "not_yet_valid", store
}
/// The last server that asked for a certificate while the app ran, and whether it got one.
struct Request {
let host: String
let port: Int
let served: Bool
}
private static let lock = NSLock()
private static var _lastRequest: Request?
static var lastRequest: Request? {
lock.lock(); defer { lock.unlock() }
return _lastRequest
}
static func noteRequest(host: String, port: Int, served: Bool) {
lock.lock(); defer { lock.unlock() }
_lastRequest = Request(host: host.lowercased(), port: port > 0 ? port : 443, served: served)
}
// MARK: Reading a file
/// Opens a PKCS#12 file: the identity and the rest of its chain.
static func read(_ data: Data, password: String, now: Date = Date()) -> Result<(SecIdentity, [SecCertificate]), Failure> {
var items: CFArray?
let status = SecPKCS12Import(data as CFData, [kSecImportExportPassphrase as String: password] as CFDictionary, &items)
switch status {
case errSecSuccess: break
case errSecAuthFailed, errSecPkcs12VerifyFailure: return .failure(.password)
default: return .failure(.format)
}
guard let first = (items as? [[String: Any]])?.first,
let value = first[kSecImportItemIdentity as String] else { return .failure(.noKey) }
let identity = value as! SecIdentity
var leaf: SecCertificate?
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return .failure(.noKey) }
if let validity = validity(of: leaf) {
if now > validity.notAfter { return .failure(.expired) }
if now < validity.notBefore { return .failure(.notYetValid) }
}
let leafData = SecCertificateCopyData(leaf) as Data
let chain = (first[kSecImportItemCertChain as String] as? [SecCertificate] ?? []).filter {
SecCertificateCopyData($0) as Data != leafData
}
return .success((identity, chain))
}
// MARK: Keeping it
/// Keeps the identity for this host and port, replacing any other.
static func save(_ identity: SecIdentity, chain: [SecCertificate], host: String, port: Int) -> Bool {
remove()
let query: [String: Any] = [
kSecValueRef as String: identity,
kSecAttrLabel as String: label,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { return false }
// The chain's intermediates go along so a proxy that wants them gets them.
let encoded = chain.map { SecCertificateCopyData($0) as Data }
UserDefaults.standard.set(encoded, forKey: chainKey)
UserDefaults.standard.set(host.lowercased(), forKey: hostKey)
UserDefaults.standard.set(port, forKey: portKey)
return true
}
private static let chainKey = "TrekClientIdentityChain"
static func remove() {
TrekClientCertWatch.reset()
for itemClass in [kSecClassIdentity, kSecClassCertificate, kSecClassKey] {
SecItemDelete([kSecClass as String: itemClass, kSecAttrLabel as String: label] as CFDictionary)
}
UserDefaults.standard.removeObject(forKey: hostKey)
UserDefaults.standard.removeObject(forKey: portKey)
UserDefaults.standard.removeObject(forKey: chainKey)
}
private static func storedIdentity() -> SecIdentity? {
var item: CFTypeRef?
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: label,
kSecReturnRef as String: true,
]
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { return nil }
return (item as! SecIdentity)
}
/// Whether a request to host and port (0 or less for https' default) goes to the server the identity is for.
static func matches(host: String?, port: Int) -> Bool {
guard let host, let stored = UserDefaults.standard.string(forKey: hostKey) else { return false }
let storedPort = UserDefaults.standard.integer(forKey: portKey)
return host.lowercased() == stored && (port > 0 ? port : 443) == (storedPort > 0 ? storedPort : 443)
}
/// The credential for a server at host and port, or nil when the stored one is for another.
static func credential(forHost host: String, port: Int) -> URLCredential? {
guard matches(host: host, port: port), let identity = storedIdentity() else { return nil }
let chain = (UserDefaults.standard.array(forKey: chainKey) as? [Data] ?? []).compactMap {
SecCertificateCreateWithData(nil, $0 as CFData)
}
// Per session: a replaced certificate takes effect on the next connection.
return URLCredential(identity: identity, certificates: chain.isEmpty ? nil : chain, persistence: .forSession)
}
/// Answers a challenge if it asks for a client certificate; false leaves it to the caller.
static func answer(_ challenge: URLAuthenticationChallenge, _ completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Bool {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate else { return false }
let space = challenge.protectionSpace
let credential = credential(forHost: space.host, port: space.port)
noteRequest(host: space.host, port: space.port, served: credential != nil)
if let credential {
completion(.useCredential, credential)
} else {
// As a browser without a matching certificate: go on without one.
completion(.performDefaultHandling, nil)
}
return true
}
// MARK: What the settings show
static func info() -> [String: Any]? {
guard let host = UserDefaults.standard.string(forKey: hostKey), let identity = storedIdentity() else { return nil }
var leaf: SecCertificate?
guard SecIdentityCopyCertificate(identity, &leaf) == errSecSuccess, let leaf else { return nil }
let port = UserDefaults.standard.integer(forKey: portKey)
var result: [String: Any] = [
"host": host,
"port": port > 0 ? port : 443,
"subject": SecCertificateCopySubjectSummary(leaf) as String? ?? "",
"issuer": issuerName(of: leaf) ?? "",
]
if let validity = validity(of: leaf) { result["notAfter"] = Int(validity.notAfter.timeIntervalSince1970 * 1000) }
return result
}
// MARK: Certificate fields
/// notBefore and notAfter from the DER: iOS has no public API for them.
static func validity(of certificate: SecCertificate) -> (notBefore: Date, notAfter: Date)? {
DER.validity(SecCertificateCopyData(certificate) as Data)
}
private static func issuerName(of certificate: SecCertificate) -> String? {
DER.issuerCommonName(SecCertificateCopyData(certificate) as Data)
}
/// Just enough DER to read an X.509 certificate's validity and a name's CN.
enum DER {
private struct Node {
let tag: UInt8
let body: Range<Int>
let end: Int
}
private static func node(_ bytes: [UInt8], at offset: Int) -> Node? {
guard offset + 2 <= bytes.count else { return nil }
let tag = bytes[offset]
var length = Int(bytes[offset + 1])
var start = offset + 2
if length & 0x80 != 0 {
let count = length & 0x7f
guard count > 0, count <= 4, start + count <= bytes.count else { return nil }
length = bytes[start..<start + count].reduce(0) { $0 << 8 | Int($1) }
start += count
}
guard start + length <= bytes.count else { return nil }
return Node(tag: tag, body: start..<start + length, end: start + length)
}
private static func children(_ bytes: [UInt8], in range: Range<Int>) -> [Node] {
var result: [Node] = []
var offset = range.lowerBound
while offset < range.upperBound, let child = node(bytes, at: offset) {
result.append(child)
offset = child.end
}
return result
}
/// The fields of the TBSCertificate after the version: serial, signature algorithm, issuer, validity, ...
private static func tbsFields(_ bytes: [UInt8]) -> [Node]? {
guard let certificate = node(bytes, at: 0), let tbs = children(bytes, in: certificate.body).first else { return nil }
var fields = children(bytes, in: tbs.body)
if fields.first?.tag == 0xa0 { fields.removeFirst() }
return fields.count > 3 ? fields : nil
}
static func validity(_ data: Data) -> (notBefore: Date, notAfter: Date)? {
let bytes = [UInt8](data)
guard let fields = tbsFields(bytes) else { return nil }
let times = children(bytes, in: fields[3].body)
guard times.count == 2, let from = time(bytes, times[0]), let to = time(bytes, times[1]) else { return nil }
return (from, to)
}
private static func time(_ bytes: [UInt8], _ node: Node) -> Date? {
guard let text = String(bytes: bytes[node.body], encoding: .ascii) else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = node.tag == 0x17 ? "yyMMddHHmmss'Z'" : "yyyyMMddHHmmss'Z'"
return formatter.date(from: text)
}
/// The CN of the issuer's Name (a SEQUENCE of SETs of type and value), or nil.
static func issuerCommonName(_ data: Data) -> String? {
let bytes = [UInt8](data)
guard let fields = tbsFields(bytes) else { return nil }
for set in children(bytes, in: fields[2].body) {
for pair in children(bytes, in: set.body) {
let parts = children(bytes, in: pair.body)
guard parts.count == 2, Array(bytes[parts[0].body]) == [0x55, 0x04, 0x03] else { continue }
return String(bytes: bytes[parts[1].body], encoding: .utf8)
}
}
return nil
}
}
}
/// Notices when the user's server stops letting the app in for want of a
/// client certificate (one removed, expired, or no longer trusted). The page can
/// still be the stored offline copy while every request fails, so when the server
/// asks for a certificate the app checks once natively whether TREK answers with
/// what it holds. Only a check that fails at the certificate (the handshake broke
/// off, or the proxy's own 4xx) calls `onRefused`; a server that merely asks and
/// lets everyone in answers TREK's 200. At most once a minute.
enum TrekClientCertWatch {
private static let lock = NSLock()
private static var lastCheck = Date.distantPast
/// A new or removed certificate is worth a check of its own, whenever the last one ran.
static func reset() {
lock.lock(); defer { lock.unlock() }
lastCheck = .distantPast
}
static func serverAsked(host: String, port: Int, onRefused: @escaping () -> Void) {
guard let raw = TrekServerStore.url, let server = URL(string: raw), server.scheme == "https",
server.host?.lowercased() == host.lowercased(), (server.port ?? 443) == (port > 0 ? port : 443),
let features = URL(string: raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/api/health/features") else { return }
lock.lock()
let due = Date().timeIntervalSince(lastCheck) >= 60
if due { lastCheck = Date() }
lock.unlock()
guard due else { return }
var request = URLRequest(url: features, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 8)
request.setValue("application/json", forHTTPHeaderField: "Accept")
var task: URLSessionDataTask?
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { _, response, _ in
let asked = task.flatMap { TrekClientCertSessionDelegate.shared.ask(for: $0) } != nil
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if asked && (status == 0 || (400..<500).contains(status)) {
DispatchQueue.main.async(execute: onRefused)
}
}
task?.resume()
}
}
/// A URLSession delegate for the app's own requests to the user's server: it
/// shows the client certificate where the server asks for it and notes the ask.
final class TrekClientCertSessionDelegate: NSObject, URLSessionTaskDelegate {
static let shared = TrekClientCertSessionDelegate()
/// The app's session for the user's server (offline copy, watch, address check).
static let session = URLSession(configuration: .default, delegate: shared, delegateQueue: nil)
/// Per task: whether the server asked, and whether it got a certificate (address check only).
private let lock = NSLock()
private var asks: [Int: Bool] = [:]
func ask(for task: URLSessionTask) -> Bool? {
lock.lock(); defer { lock.unlock() }
return asks.removeValue(forKey: task.taskIdentifier)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
let handled = TrekClientIdentity.answer(challenge) { disposition, credential in
self.lock.lock()
self.asks[task.taskIdentifier] = credential != nil
self.lock.unlock()
completionHandler(disposition, credential)
}
if !handled { completionHandler(.performDefaultHandling, nil) }
}
}
/// The file picker and password prompt of an import, in the UI's words.
final class TrekClientCertImporter: NSObject, UIDocumentPickerDelegate {
private let presenter: UIViewController
private let labels: [String: String]
private let host: String
private let port: Int
private let done: ([String: Any]) -> Void
private var keepAlive: TrekClientCertImporter?
init(presenter: UIViewController, labels: [String: String], host: String, port: Int, done: @escaping ([String: Any]) -> Void) {
self.presenter = presenter
self.labels = labels
self.host = host
self.port = port
self.done = done
}
func start() {
keepAlive = self
let types = [UTType(filenameExtension: "p12"), UTType(filenameExtension: "pfx"), UTType("com.rsa.pkcs-12")].compactMap { $0 }
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true)
picker.delegate = self
presenter.present(picker, animated: true)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
finish(["result": "cancelled"])
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first,
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
(attributes[.size] as? Int ?? 0) <= 256 * 1024,
let data = try? Data(contentsOf: url) else {
finish(["result": "failed", "failure": TrekClientIdentity.Failure.format.rawValue])
return
}
try? FileManager.default.removeItem(at: url)
askPassword(for: data, wrong: false)
}
private func askPassword(for data: Data, wrong: Bool) {
let alert = UIAlertController(title: labels["title"], message: wrong ? labels["wrongPassword"] : labels["message"], preferredStyle: .alert)
alert.addTextField { field in
field.isSecureTextEntry = true
field.placeholder = self.labels["placeholder"]
field.textContentType = .password
}
alert.addAction(UIAlertAction(title: labels["cancel"] ?? "Cancel", style: .cancel) { _ in
self.finish(["result": "cancelled"])
})
alert.addAction(UIAlertAction(title: labels["ok"] ?? "OK", style: .default) { _ in
self.open(data, password: alert.textFields?.first?.text ?? "")
})
presenter.present(alert, animated: true)
}
private func open(_ data: Data, password: String) {
switch TrekClientIdentity.read(data, password: password) {
case .failure(.password):
askPassword(for: data, wrong: true)
case .failure(let failure):
finish(["result": "failed", "failure": failure.rawValue])
case .success(let (identity, chain)):
guard TrekClientIdentity.save(identity, chain: chain, host: host, port: port) else {
finish(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
return
}
var result: [String: Any] = ["result": "imported"]
if let info = TrekClientIdentity.info() { result["certificate"] = info }
finish(result)
}
}
private func finish(_ result: [String: Any]) {
done(result)
keepAlive = nil
}
}
+87 -2
View File
@@ -51,6 +51,10 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "setKeyCommands", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setQuickActions", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "takeQuickAction", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "requestServer", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -62,7 +66,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
static var features: [String] {
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions"]
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"]
if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") }
return names
@@ -429,7 +433,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
request.setValue("text/html", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: request) { data, response, _ in
// The app's session, so a server behind mutual TLS gets the client certificate.
TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, _ in
guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200,
http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true,
TrekServerStore.url == raw else {
@@ -468,6 +473,86 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
}
}
// MARK: - Client certificates (mutual TLS)
extension TrekShellPlugin {
/// The address screen's check of a server, made natively so it notices
/// whether the server asks for a client certificate and can show the stored
/// one. A failed connection resolves with status 0 instead of rejecting.
@objc func requestServer(_ call: CAPPluginCall) {
guard let raw = call.getString("url"), let url = URL(string: raw),
["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil else {
call.reject("A http(s) URL is required")
return
}
let timeout = min(max(Double(call.getInt("timeoutMs") ?? 10000), 1000), 30000) / 1000
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")
let delegate = TrekClientCertSessionDelegate.shared
var task: URLSessionDataTask?
task = TrekClientCertSessionDelegate.session.dataTask(with: request) { data, response, error in
let asked = task.flatMap { delegate.ask(for: $0) }
var result: [String: Any] = [
"certRequested": asked != nil,
"certSent": asked == true,
"url": response?.url?.absoluteString ?? raw,
]
if asked != nil, let host = url.host?.lowercased() { result["certHost"] = "\(host):\(url.port ?? 443)" }
if let http = response as? HTTPURLResponse {
result["status"] = http.statusCode
let body = (data ?? Data()).prefix(512 * 1024)
result["data"] = String(data: body, encoding: .utf8) ?? ""
var headers: [String: String] = [:]
for (name, value) in http.allHeaderFields {
if let name = name as? String, let value = value as? String { headers[name] = value }
}
result["headers"] = headers
} else {
result["status"] = 0
result["data"] = ""
if let error { result["error"] = (error as NSError).domain + ":" + String((error as NSError).code) }
}
call.resolve(result)
}
task?.resume()
}
/// The stored client certificate's details, and the last server that asked for one.
@objc func clientCertificate(_ call: CAPPluginCall) {
var result: [String: Any] = [:]
if let info = TrekClientIdentity.info() { result["certificate"] = info } else { result["certificate"] = NSNull() }
if let asked = TrekClientIdentity.lastRequest {
result["request"] = ["host": asked.host, "port": asked.port, "served": asked.served]
}
call.resolve(result)
}
@objc func removeClientCertificate(_ call: CAPPluginCall) {
TrekClientIdentity.remove()
call.resolve()
}
/// Picks a .p12/.pfx file, asks for its password until it opens or the user
/// gives up, and keeps it for the server at host and port.
@objc func importClientCertificate(_ call: CAPPluginCall) {
guard let host = call.getString("host"), host.range(of: "^[A-Za-z0-9.-]+$", options: .regularExpression) != nil else {
call.reject("A host is required")
return
}
let port = call.getInt("port") ?? 443
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
DispatchQueue.main.async { [weak self] in
guard let presenter = self?.bridge?.viewController else {
call.resolve(["result": "failed", "failure": TrekClientIdentity.Failure.store.rawValue])
return
}
TrekClientCertImporter(presenter: presenter, labels: labels, host: host, port: port) { result in
call.resolve(result)
}.start()
}
}
}
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time.
// MARK: - Apple Wallet
+1 -1
View File
@@ -237,7 +237,7 @@ enum TrekWatchWriter {
for (field, value) in HTTPCookie.requestHeaderFields(with: session) {
signed.setValue(value, forHTTPHeaderField: field)
}
URLSession.shared.dataTask(with: signed) { data, response, error in
TrekClientCertSessionDelegate.session.dataTask(with: signed) { data, response, error in
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
completion(data, error == nil && (200..<300).contains(status))
}.resume()
+167 -18
View File
@@ -11,15 +11,22 @@ import { opensSoon, refundedBeforeOpening } from '../src/native/cloudOrders';
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
import {
AUTH_PROXY_DOCS_URL,
CLIENT_CERT_DOCS_URL,
CLOUD_TERMS_URL,
CLOUD_URL,
PRIVACY_URL,
SERVER_DOCS_URL,
STORE_TERMS_URL,
} from '../src/native/links';
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
import {
probeDemo,
probeServer,
type ClientCertDemand,
type ProbeFailure,
type ProbeGet,
} from '../src/native/serverAddress';
import { passwordProblem } from '../../shared/src/auth/password-policy';
import { TrekShell } from '../src/native/trekShell';
import { TrekShell, type ClientCertificateFailure } from '../src/native/trekShell';
import { directionOf, pickLocale, translator } from './i18n';
import './shell.css';
@@ -57,11 +64,34 @@ function cloudSuffix(): string {
}
}
/** JSON bodies arrive as text from the app's own request; everything else stays text. */
function parseBody(data: unknown): unknown {
if (typeof data !== 'string') return data;
try {
return JSON.parse(data);
} catch {
return data;
}
}
// Native HTTP rather than fetch: the probe goes to a server of the user's
// choosing, which has no reason to answer this page's origin with CORS.
// choosing, which has no reason to answer this page's origin with CORS. The
// app's own request comes first: it shows a stored client certificate and
// reports whether the server asked for one. A web preview has only Capacitor's.
const get: ProbeGet = async (url) => {
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
try {
const response = await TrekShell.requestServer({ url, timeoutMs: PROBE_TIMEOUT_MS });
return {
status: response.status,
data: parseBody(response.data),
url: response.url,
headers: response.headers,
clientCert: { requested: response.certRequested, sent: response.certSent },
};
} catch {
const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS });
return { status: response.status, data: response.data, url: response.url, headers: response.headers };
}
};
function failureMessage(reason: ProbeFailure): string {
@@ -74,6 +104,8 @@ function failureMessage(reason: ProbeFailure): string {
return t('native.connect.errorNotTrek');
case 'authGate':
return t('native.gate.titleUnknown');
case 'clientCert':
return t('native.cert.title');
case 'tooOld':
return t('native.connect.errorTooOld');
}
@@ -266,13 +298,16 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
const gatePanel = el('div', { className: 'gate', role: 'alert', hidden: true });
const form = el('form', { className: 'form', noValidate: true }, field, submit, error, gatePanel);
const fail = (reason: ProbeFailure, gate?: AuthGate, server?: string): void => {
const fail = (reason: ProbeFailure, gate?: AuthGate, server?: string, cert?: ClientCertDemand): void => {
void notify(NotificationType.Error);
field.classList.add('bad');
setTimeout(() => field.classList.remove('bad'), 350);
if (reason === 'authGate' && gate && server) {
error.textContent = '';
fillGatePanel(gatePanel, gate, server);
} else if (reason === 'clientCert' && cert) {
error.textContent = '';
fillCertPanel(gatePanel, cert, () => form.requestSubmit());
} else {
error.textContent = failureMessage(reason);
}
@@ -280,7 +315,8 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
submit.disabled = false;
label.textContent = t('native.connect.submit');
submit.replaceChildren(label);
input.focus();
// A panel with its own next step stays in view; the keyboard would cover it.
if (gatePanel.hidden) input.focus();
};
form.addEventListener('submit', async (event) => {
@@ -297,7 +333,7 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
await connected(result.url);
return;
}
fail(result.reason, result.gate, result.url);
fail(result.reason, result.gate, result.url, result.cert);
});
show(
@@ -355,6 +391,87 @@ function fillGatePanel(panel: HTMLElement, gate: AuthGate, server: string): void
panel.hidden = false;
}
/**
* A proxy in front of the server lets only devices with a client certificate
* through (mutual TLS: Cloudflare, nginx, Caddy, Traefik). The panel offers to
* add the certificate file for it, or another one when the proxy refused the
* one shown; `retry` checks the server again once it is added.
*/
function fillCertPanel(panel: HTMLElement, cert: ClientCertDemand, retry: () => void): void {
const status = el('p', { className: 'gate-note', role: 'status' });
const choose = el(
'button',
{ type: 'button', className: 'btn' },
cert.sent ? t('native.cert.chooseOther') : t('native.cert.choose')
);
choose.addEventListener('click', async () => {
void tap(ImpactStyle.Medium);
choose.disabled = true;
status.textContent = '';
const outcome = await importCertificate(cert.host, cert.port);
choose.disabled = false;
if (outcome === 'imported') {
void notify(NotificationType.Success);
retry();
} else if (outcome !== 'cancelled') {
void notify(NotificationType.Error);
status.textContent = certFailureMessage(outcome);
}
});
panel.replaceChildren(
el('p', { className: 'gate-title' }, cert.sent ? t('native.cert.titleRefused') : t('native.cert.title')),
el(
'p',
{ className: 'gate-text' },
cert.sent ? t('native.cert.hintRefused') : t('native.cert.hint', { host: cert.host })
),
choose,
status,
externalLink(t('native.cert.help'), CLIENT_CERT_DOCS_URL)
);
panel.hidden = false;
}
/** Imports a certificate file for host and port: 'imported', 'cancelled', or why it failed. */
async function importCertificate(
host: string,
port: number
): Promise<'imported' | 'cancelled' | ClientCertificateFailure> {
try {
const result = await TrekShell.importClientCertificate({
host,
port,
labels: {
title: t('native.cert.passwordTitle'),
message: t('native.cert.passwordMessage'),
placeholder: t('native.cert.passwordPlaceholder'),
ok: t('native.cert.passwordOk'),
cancel: t('native.cert.passwordCancel'),
wrongPassword: t('native.cert.passwordWrong'),
},
});
if (result.result === 'failed') return result.failure ?? 'format';
return result.result;
} catch {
return 'store';
}
}
function certFailureMessage(failure: ClientCertificateFailure): string {
switch (failure) {
case 'no_key':
return t('native.cert.errorNoKey');
case 'expired':
return t('native.cert.errorExpired');
case 'not_yet_valid':
return t('native.cert.errorNotYetValid');
case 'store':
return t('native.cert.errorStore');
case 'format':
return t('native.cert.errorFormat');
}
}
// ── Get TREK Cloud ──────────────────────────────────────────────────────────
function feature(path: string, text: string): HTMLElement {
return el(
@@ -1088,8 +1205,10 @@ async function connected(url: string, signInHosts: string[] = [], welcome?: stri
});
}
function showUnreachable(server: string): void {
const retry = el('button', { type: 'button', className: 'btn' }, t('native.offline.retry'));
function showUnreachable(server: string, cert?: ClientCertDemand): void {
const certPanel = el('div', { className: 'gate', role: 'alert', hidden: true });
if (cert) fillCertPanel(certPanel, cert, () => void TrekShell.reload());
const retry = el('button', { type: 'button', className: cert ? 'linkbtn' : 'btn' }, t('native.offline.retry'));
retry.addEventListener('click', () => {
void tap();
retry.disabled = true;
@@ -1107,10 +1226,16 @@ function showUnreachable(server: string): void {
...columns(
[
el('img', { src: logoUrl, alt: '', className: 'shell-logo' }),
el('h1', { className: 'title-sm' }, t('native.offline.title')),
el('p', { className: 'sub' }, t('native.offline.hint', { server: new URL(server).host })),
el('h1', { className: 'title-sm' }, cert ? t('native.cert.unreachableTitle') : t('native.offline.title')),
el(
'p',
{ className: 'sub' },
cert
? t('native.cert.unreachableHint', { server: new URL(server).host })
: t('native.offline.hint', { server: new URL(server).host })
),
],
[retry, change, footLinks()]
[certPanel, retry, change, footLinks()]
)
)
);
@@ -1175,6 +1300,23 @@ function letKeyboardClose(): void {
}, { passive: true });
}
/**
* Whether the remembered server failed for want of a client certificate. Asked
* only when the app saw that server ask for one in this run, and then checked
* once more, so a server that is merely offline is not taken for one that
* refused the certificate.
*/
async function certificateDemand(server: string): Promise<ClientCertDemand | null> {
try {
const { request } = await TrekShell.clientCertificate();
if (!request || request.host !== new URL(server).hostname.toLowerCase()) return null;
const result = await probeServer(server, get);
return result.ok ? null : (result.cert ?? null);
} catch {
return null;
}
}
async function start(): Promise<void> {
void paintStatusBar();
followBackGesture();
@@ -1193,11 +1335,18 @@ async function start(): Promise<void> {
if (!url) {
showWelcome();
} else {
// Only reached when loading the server failed. On iOS the stored copy of
// its start page brings the offline-capable UI up anyway.
const { opened } = await TrekShell.openOfflineCopy();
if (opened) return;
showUnreachable(url);
// Only reached when loading the server failed. A proxy that wanted a client
// certificate says so here, before the offline copy would start a UI that
// cannot reach its server.
const cert = await certificateDemand(url);
if (cert) {
showUnreachable(url, cert);
} else {
// On iOS the stored copy of its start page brings the offline-capable UI up anyway.
const { opened } = await TrekShell.openOfflineCopy();
if (opened) return;
showUnreachable(url);
}
}
await SplashScreen.hide();
}
+4 -1
View File
@@ -164,10 +164,13 @@
--veil-mid: rgba(255, 255, 255, 0.74);
color-scheme: light;
}
.shell-logo,
.mark {
filter: none;
}
/* The app icon is drawn white; on the light surface it turns black. */
.shell-logo {
filter: brightness(0);
}
}
* {
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import {
removeClientCertificate,
replaceClientCertificate,
storedClientCertificate,
} from '../../../native/clientCertificate';
import { TrekShell } from '../../../native/trekShell';
import MClientCertCard from './MClientCertCard';
vi.mock('../../../native/clientCertificate', () => ({
storedClientCertificate: vi.fn(),
replaceClientCertificate: vi.fn(),
removeClientCertificate: vi.fn(),
}));
vi.mock('../../../native/trekShell', () => ({
TrekShell: { getServer: vi.fn(async () => ({ url: 'https://trek.example.com' })), reload: vi.fn(async () => {}) },
}));
const info = {
host: 'trek.example.com',
port: 443,
subject: 'Phone of the household',
issuer: 'Home CA',
notAfter: Date.UTC(2028, 10, 8),
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(storedClientCertificate).mockResolvedValue(info);
});
describe('MClientCertCard', () => {
it('stays away without a stored certificate', async () => {
vi.mocked(storedClientCertificate).mockResolvedValue(null);
const { container } = render(<MClientCertCard />);
await waitFor(() => expect(storedClientCertificate).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it('shows whom the certificate names, who issued it and the server it is for', async () => {
render(<MClientCertCard />);
expect(await screen.findByText('Phone of the household')).toBeInTheDocument();
expect(screen.getByText('Issued by Home CA')).toBeInTheDocument();
expect(screen.getByText('trek.example.com')).toBeInTheDocument();
});
it('replaces it for the server shown and reloads, or says why it could not', async () => {
vi.mocked(replaceClientCertificate).mockResolvedValueOnce({ result: 'failed', failure: 'expired' });
render(<MClientCertCard />);
fireEvent.click(await screen.findByRole('button', { name: 'Replace' }));
expect(await screen.findByRole('alert')).toHaveTextContent('This certificate has expired.');
expect(TrekShell.reload).not.toHaveBeenCalled();
vi.mocked(replaceClientCertificate).mockResolvedValueOnce({ result: 'imported', certificate: info });
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
await waitFor(() => expect(TrekShell.reload).toHaveBeenCalledTimes(1));
expect(replaceClientCertificate).toHaveBeenLastCalledWith(
'https://trek.example.com',
expect.objectContaining({ title: 'Certificate password' })
);
});
it('removes it only after the confirmation, then reloads', async () => {
vi.mocked(removeClientCertificate).mockResolvedValueOnce(true);
render(<MClientCertCard />);
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }));
expect(removeClientCertificate).not.toHaveBeenCalled();
const buttons = await screen.findAllByRole('button', { name: 'Remove' });
fireEvent.click(buttons[buttons.length - 1]);
await waitFor(() => expect(TrekShell.reload).toHaveBeenCalledTimes(1));
expect(removeClientCertificate).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,110 @@
import { FileKey } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../../i18n';
import {
removeClientCertificate,
replaceClientCertificate,
storedClientCertificate,
} from '../../../native/clientCertificate';
import { TrekShell, type ClientCertificateFailure, type ClientCertificateInfo } from '../../../native/trekShell';
import MConfirmSheet from './MConfirmSheet';
import { MSetButton, MSetCard, MSetHint, MSetRow } from './MSettingsUi';
const FAILURE_KEYS: Record<ClientCertificateFailure, string> = {
format: 'native.cert.errorFormat',
no_key: 'native.cert.errorNoKey',
expired: 'native.cert.errorExpired',
not_yet_valid: 'native.cert.errorNotYetValid',
store: 'native.cert.errorStore',
};
/**
* The client certificate the app shows to a server behind mutual TLS, inside
* the app only and only once one is stored: someone without a proxy that asks
* for one never needs to see this. Replacing or removing it reloads the server,
* so the next connection uses what is stored now.
*/
export default function MClientCertCard() {
const { t, locale } = useTranslation();
const [certificate, setCertificate] = useState<ClientCertificateInfo | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
useEffect(() => {
let live = true;
void storedClientCertificate().then((found) => {
if (live) setCertificate(found);
});
return () => {
live = false;
};
}, []);
if (!certificate) return null;
const replace = async () => {
setProblem(null);
setBusy(true);
const { url } = await TrekShell.getServer();
const outcome = await replaceClientCertificate(url ?? `https://${certificate.host}:${certificate.port}`, {
title: t('native.cert.passwordTitle'),
message: t('native.cert.passwordMessage'),
placeholder: t('native.cert.passwordPlaceholder'),
ok: t('native.cert.passwordOk'),
cancel: t('native.cert.passwordCancel'),
wrongPassword: t('native.cert.passwordWrong'),
});
setBusy(false);
if (outcome.result === 'imported') {
await TrekShell.reload();
} else if (outcome.result === 'failed') {
setProblem(t(FAILURE_KEYS[outcome.failure ?? 'format']));
}
};
const remove = async () => {
setConfirmRemove(false);
if (await removeClientCertificate()) await TrekShell.reload();
};
const validUntil = new Date(certificate.notAfter).toLocaleDateString(locale, { dateStyle: 'medium' });
return (
<MSetCard title={t('native.cert.settingTitle')} icon={FileKey} className="mt-3">
<div className="-mt-[4px]">
<MSetRow
first
label={certificate.subject}
sub={t('native.cert.settingIssuer', { issuer: certificate.issuer })}
/>
<MSetRow label={t('native.cert.settingServer')} sub={certificate.host} />
<MSetRow label={t('native.cert.settingValidUntil')} sub={validUntil} />
</div>
<MSetHint>{t('native.cert.settingHint', { host: certificate.host })}</MSetHint>
{problem && (
<p role="alert" className="mt-2 text-caption text-[color:var(--m-st-danger)]">
{problem}
</p>
)}
<div className="mt-3 flex gap-2">
<MSetButton variant="ghost" disabled={busy} onClick={() => void replace()}>
{t('native.cert.settingReplace')}
</MSetButton>
<MSetButton variant="danger" disabled={busy} onClick={() => setConfirmRemove(true)}>
{t('native.cert.settingRemove')}
</MSetButton>
</div>
<MConfirmSheet
open={confirmRemove}
onClose={() => setConfirmRemove(false)}
title={t('native.cert.removeTitle')}
message={t('native.cert.removeMessage', { host: certificate.host })}
confirmLabel={t('native.cert.settingRemove')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => void remove()}
/>
</MSetCard>
);
}
@@ -20,6 +20,7 @@ import { useTranslation } from '../../../i18n'
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet'
import MAppLockCard from './MAppLockCard'
import MClientCertCard from './MClientCertCard'
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
@@ -182,6 +183,8 @@ export default function MSettingsAccount() {
{/* ── Two-factor authentication ───────────────────────────── */}
{/* The app lock, inside the app only (Face ID, Touch ID or the passcode). */}
<MAppLockCard />
{/* The client certificate for a server behind mutual TLS, once one is stored. */}
<MClientCertCard />
<MSetCard title={t('settings.mfa.title')} icon={KeyRound} className="mt-3">
{mfaRequiredByPolicy && (
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { hasNativeFeature } from './capabilities';
import {
certificateTarget,
clientCertificateSupported,
removeClientCertificate,
replaceClientCertificate,
storedClientCertificate,
} from './clientCertificate';
import { TrekShell } from './trekShell';
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() }));
vi.mock('./reportFailure', () => ({ reportNativeFailure: vi.fn() }));
vi.mock('./trekShell', () => ({
TrekShell: {
clientCertificate: vi.fn(),
importClientCertificate: vi.fn(),
removeClientCertificate: vi.fn(),
},
}));
const info = { host: 'trek.example.com', port: 443, subject: 'phone', issuer: 'Home CA', notAfter: 1 };
const labels = { title: 't', message: 'm', placeholder: 'p', ok: 'o', cancel: 'c', wrongPassword: 'w' };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(hasNativeFeature).mockReturnValue(true);
});
describe('clientCertificate', () => {
it('asks the binary only where it can hold a certificate', async () => {
vi.mocked(hasNativeFeature).mockReturnValue(false);
expect(clientCertificateSupported()).toBe(false);
expect(await storedClientCertificate()).toBeNull();
expect(TrekShell.clientCertificate).not.toHaveBeenCalled();
});
it('returns the stored certificate, and none when the binary fails', async () => {
vi.mocked(TrekShell.clientCertificate).mockResolvedValueOnce({ certificate: info });
expect(await storedClientCertificate()).toEqual(info);
vi.mocked(TrekShell.clientCertificate).mockRejectedValueOnce(new Error('gone'));
expect(await storedClientCertificate()).toBeNull();
});
it('keeps a certificate for the https host and port of the server, never for plain http', () => {
expect(certificateTarget('https://Trek.Example.com/')).toEqual({ host: 'trek.example.com', port: 443 });
expect(certificateTarget('https://nas.local:8443/trek')).toEqual({ host: 'nas.local', port: 8443 });
expect(certificateTarget('http://nas.local:3001')).toBeNull();
expect(certificateTarget('not a url')).toBeNull();
});
it('imports for the server shown and reports a failed call as a store failure', async () => {
vi.mocked(TrekShell.importClientCertificate).mockResolvedValueOnce({ result: 'imported', certificate: info });
await replaceClientCertificate('https://trek.example.com:8443', labels);
expect(TrekShell.importClientCertificate).toHaveBeenCalledWith({ host: 'trek.example.com', port: 8443, labels });
vi.mocked(TrekShell.importClientCertificate).mockRejectedValueOnce(new Error('no picker'));
expect(await replaceClientCertificate('https://trek.example.com', labels)).toEqual({
result: 'failed',
failure: 'store',
});
expect(await replaceClientCertificate('http://trek.example.com', labels)).toEqual({
result: 'failed',
failure: 'format',
});
});
it('says whether removing worked', async () => {
vi.mocked(TrekShell.removeClientCertificate).mockResolvedValueOnce();
expect(await removeClientCertificate()).toBe(true);
vi.mocked(TrekShell.removeClientCertificate).mockRejectedValueOnce(new Error('keychain'));
expect(await removeClientCertificate()).toBe(false);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { hasNativeFeature } from './capabilities';
import { reportNativeFailure } from './reportFailure';
import {
TrekShell,
type ClientCertificateImport,
type ClientCertificateInfo,
type ClientCertificateLabels,
} from './trekShell';
/**
* The client certificate the app shows to a server behind mutual TLS (a proxy
* such as Cloudflare, nginx, Caddy or Traefik that lets in only devices with
* one). The address screen adds it when the server asks; the settings show,
* replace and remove it. The binary keeps it (TrekClientIdentity on iOS and
* Android) and shows it to the one host and port it was added for.
*/
export function clientCertificateSupported(): boolean {
return hasNativeFeature('clientCertificate');
}
/** The stored certificate, or null when there is none or the binary cannot hold one. */
export async function storedClientCertificate(): Promise<ClientCertificateInfo | null> {
if (!clientCertificateSupported()) return null;
try {
const { certificate } = await TrekShell.clientCertificate();
return certificate ?? null;
} catch (err) {
reportNativeFailure(err);
return null;
}
}
/** Host and port a certificate for this server address is kept for; null for a plain http server. */
export function certificateTarget(serverUrl: string): { host: string; port: number } | null {
let url: URL;
try {
url = new URL(serverUrl);
} catch {
return null;
}
if (url.protocol !== 'https:' || !url.hostname) return null;
return { host: url.hostname.toLowerCase(), port: url.port ? Number(url.port) : 443 };
}
/** Picks a certificate file for the server the app shows and keeps it in place of the stored one. */
export async function replaceClientCertificate(
serverUrl: string,
labels: ClientCertificateLabels
): Promise<ClientCertificateImport> {
const target = certificateTarget(serverUrl);
if (!target) return { result: 'failed', failure: 'format' };
try {
return await TrekShell.importClientCertificate({ ...target, labels });
} catch (err) {
reportNativeFailure(err);
return { result: 'failed', failure: 'store' };
}
}
export async function removeClientCertificate(): Promise<boolean> {
try {
await TrekShell.removeClientCertificate();
return true;
} catch (err) {
reportNativeFailure(err);
return false;
}
}
+3
View File
@@ -12,6 +12,9 @@ export const SERVER_DOCS_URL = 'https://github.com/liketrek/TREK#readme';
/** Running the app with a sign-in proxy (Cloudflare Access, Pangolin and the like) in front of TREK. */
export const AUTH_PROXY_DOCS_URL = 'https://github.com/liketrek/TREK/wiki/Native-Apps#behind-a-sign-in-proxy';
/** Adding a client certificate for a server behind mutual TLS (Cloudflare, nginx, Caddy, Traefik). */
export const CLIENT_CERT_DOCS_URL = 'https://github.com/liketrek/TREK/wiki/Native-Apps#client-certificates-mtls';
/** The public demo the address screen offers to someone without a server. */
export const DEMO_SERVER_URL = 'https://demo.liketrek.com';
+50
View File
@@ -141,3 +141,53 @@ describe('probeServer behind a sign-in proxy', () => {
expect(await probeServer('https://x.example.com', get)).toEqual({ ok: false, reason: 'notTrek' });
});
});
describe('probeServer behind mutual TLS', () => {
const asked = (status: number, sent: boolean, data: unknown = ''): ProbeResponse => ({
status,
data,
url: 'https://trek.example.com/api/health/features',
clientCert: { requested: true, sent },
});
it('names a handshake the proxy broke off for want of a certificate, with the host to add one for', async () => {
const get = vi.fn().mockResolvedValue(asked(0, false));
expect(await probeServer('trek.example.com', get)).toEqual({
ok: false,
reason: 'clientCert',
url: 'https://trek.example.com',
cert: { host: 'trek.example.com', port: 443, sent: false },
});
});
it("names Cloudflare's 403 without a certificate the same way, port included", async () => {
const get = vi.fn().mockResolvedValue(asked(403, false, 'Access denied'));
const result = await probeServer('https://Trek.Example.com:8443', get);
expect(result.reason).toBe('clientCert');
expect(result.cert).toEqual({ host: 'trek.example.com', port: 8443, sent: false });
});
it('says the proxy refused the certificate the app showed', async () => {
const get = vi.fn().mockResolvedValue(asked(400, true, '400 The SSL certificate error'));
expect((await probeServer('https://trek.example.com', get)).cert?.sent).toBe(true);
});
it('connects where the proxy asked and TREK answered, with or without a certificate', async () => {
const trek = { bookingImport: false, nativeApp: true, nativeApi: 1 };
for (const sent of [true, false]) {
const get = vi.fn().mockResolvedValue(asked(200, sent, trek));
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' });
}
});
it('keeps a failed connection without an ask unreachable, and ranks the ask above it', async () => {
const down = vi.fn().mockResolvedValue({ status: 0, data: '', url: 'https://trek.example.com' });
expect(await probeServer('https://trek.example.com', down)).toEqual({ ok: false, reason: 'unreachable' });
const mixed = vi
.fn()
.mockResolvedValueOnce(asked(0, false))
.mockResolvedValueOnce({ status: 0, data: '', url: 'http://trek.example.com' });
expect((await probeServer('trek.example.com', mixed)).reason).toBe('clientCert');
});
});
+43 -7
View File
@@ -6,14 +6,27 @@ import { DEMO_SERVER_URL } from './links';
* can load, or a reason it cannot.
*/
export type ProbeFailure = 'invalid' | 'unreachable' | 'notTrek' | 'authGate' | 'tooOld';
export type ProbeFailure = 'invalid' | 'unreachable' | 'notTrek' | 'authGate' | 'clientCert' | 'tooOld';
/**
* A proxy in front of the server asked for a client certificate (mutual TLS)
* and let the check through without one. `sent`: the app showed its stored
* certificate and the proxy refused it all the same.
*/
export interface ClientCertDemand {
host: string;
port: number;
sent: boolean;
}
type ProbeResult =
| { ok: true; url: string; reason?: undefined; gate?: undefined }
| { ok: true; url: string; reason?: undefined; gate?: undefined; cert?: undefined }
/**
* `authGate`: a sign-in page of the user's own stands in front of TREK
* (authGate.ts); `url` is the server address it guards.
* (authGate.ts); `url` is the server address it guards. `clientCert`: a proxy
* wants a client certificate, `cert` says for which host and whether one was shown.
*/
| { ok: false; reason: ProbeFailure; url?: string; gate?: AuthGate };
| { ok: false; reason: ProbeFailure; url?: string; gate?: AuthGate; cert?: ClientCertDemand };
export interface ProbeResponse {
status: number;
@@ -21,6 +34,8 @@ export interface ProbeResponse {
/** The URL after redirects. */
url: string;
headers?: Record<string, string>;
/** Whether the server asked for a client certificate, and whether the app showed one (native requests only). */
clientCert?: { requested: boolean; sent: boolean };
}
export type ProbeGet = (url: string) => Promise<ProbeResponse>;
@@ -74,7 +89,25 @@ function judge(response: ProbeResponse): ProbeResult {
return 'bookingImport' in response.data ? { ok: false, reason: 'tooOld' } : { ok: false, reason: 'notTrek' };
}
const FAILURE_RANK: Record<ProbeFailure, number> = { invalid: 0, unreachable: 1, notTrek: 2, authGate: 3, tooOld: 4 };
const FAILURE_RANK: Record<ProbeFailure, number> = {
invalid: 0,
unreachable: 1,
notTrek: 2,
authGate: 3,
clientCert: 4,
tooOld: 5,
};
/**
* A server that asked for a client certificate and did not answer with TREK:
* the TLS handshake failed (status 0, as nginx, Caddy and Traefik do), or the
* proxy answered with its own refusal (Cloudflare's 403, nginx's 400).
*/
function clientCertDemand(candidate: string, response: ProbeResponse): ClientCertDemand | null {
if (!response.clientCert?.requested) return null;
const url = new URL(candidate);
return { host: url.hostname.toLowerCase(), port: url.port ? Number(url.port) : 443, sent: response.clientCert.sent };
}
/**
* A gate that did not send the check to its sign-in page (an API path can get a
@@ -105,9 +138,12 @@ export async function probeServer(input: string, get: ProbeGet): Promise<ProbeRe
} catch {
continue;
}
let verdict = judge(response);
const cert = clientCertDemand(candidate, response);
let verdict = response.status === 0 && !cert ? ({ ok: false, reason: 'unreachable' } as const) : judge(response);
if (verdict.ok) return { ok: true, url: verdict.url || candidate };
if (verdict.reason === 'notTrek') {
if (cert) {
verdict = { ok: false, reason: 'clientCert', url: candidate, cert };
} else if (verdict.reason === 'notTrek') {
const gate = detectAuthGate(candidate + FEATURES_PATH, response);
if (gate)
verdict = { ok: false, reason: 'authGate', url: candidate, gate: await withLoginHost(candidate, gate, get) };
+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';
+31
View File
@@ -143,6 +143,37 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'لا يستطيع التطبيق الرد على هذا الطلب. اسمح لـ TREK بتجاوزه في الوكيل: سيظل TREK يطلب تسجيل الدخول الخاص به.',
'native.gate.help': 'استخدام التطبيق خلف وكيل تسجيل دخول',
'native.cert.title': 'يطلب هذا الخادم شهادة عميل',
'native.cert.titleRefused': 'لم يقبل الخادم الشهادة',
'native.cert.hint':
'يسمح الوكيل الموجود أمامه بمرور الأجهزة التي تقدم شهادة فقط. أضف ملف ‎.p12 أو ‎.pfx المُنشأ لهذا الجهاز. لا يعرضه التطبيق إلا على {host}.',
'native.cert.hintRefused': 'لا يثق الوكيل بالشهادة الموجودة في التطبيق، أو لم يعد يثق بها. اختر الملف الذي يتوقعه.',
'native.cert.choose': 'إضافة ملف شهادة',
'native.cert.chooseOther': 'اختيار شهادة أخرى',
'native.cert.help': 'استخدام التطبيق مع شهادة عميل',
'native.cert.passwordTitle': 'كلمة مرور الشهادة',
'native.cert.passwordMessage': 'أدخل كلمة مرور ملف الشهادة. لا يحتفظ التطبيق بها.',
'native.cert.passwordPlaceholder': 'كلمة المرور',
'native.cert.passwordOk': 'إضافة',
'native.cert.passwordCancel': 'إلغاء',
'native.cert.passwordWrong': 'كلمة المرور هذه لا تفتح الملف. حاول مرة أخرى.',
'native.cert.errorFormat':
'لا يستطيع التطبيق قراءة هذا الملف. يلزم ملف ‎.p12 أو ‎.pfx يحتوي على الشهادة ومفتاحها الخاص.',
'native.cert.errorNoKey': 'لا يحتوي هذا الملف على مفتاح خاص. صدّر الشهادة مع مفتاحها.',
'native.cert.errorExpired': 'انتهت صلاحية هذه الشهادة.',
'native.cert.errorNotYetValid': 'هذه الشهادة غير صالحة بعد. تحقق من التاريخ والوقت على هذا الجهاز.',
'native.cert.errorStore': 'تعذّر على التطبيق حفظ الشهادة. حاول مرة أخرى.',
'native.cert.unreachableTitle': 'الشهادة مطلوبة',
'native.cert.unreachableHint': 'لا يسمح {server} إلا بمرور الأجهزة التي لديها شهادة عميل موثوقة.',
'native.cert.settingTitle': 'شهادة العميل',
'native.cert.settingIssuer': 'صادرة عن {issuer}',
'native.cert.settingServer': 'الخادم',
'native.cert.settingValidUntil': 'صالحة حتى',
'native.cert.settingHint': 'لا يعرض التطبيق هذه الشهادة إلا على {host} عندما يطلبها الوكيل الخاص به.',
'native.cert.settingReplace': 'استبدال',
'native.cert.settingRemove': 'إزالة',
'native.cert.removeTitle': 'إزالة الشهادة؟',
'native.cert.removeMessage': 'من دونها لن يسمح الوكيل أمام {host} بدخول التطبيق حتى تضيف شهادة جديدة.',
};
export default native;
+31
View File
@@ -147,6 +147,37 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Tətbiq bu sorğuya cavab verə bilmir. TREK-i proksinizdə keçirin: TREK yenə öz girişini istəyir.',
'native.gate.help': 'Tətbiqi giriş proksisinin arxasında istifadə etmək',
'native.cert.title': 'Bu server müştəri sertifikatı tələb edir',
'native.cert.titleRefused': 'Server sertifikatı qəbul etmədi',
'native.cert.hint':
'Onun qarşısındakı proksi yalnız sertifikat göstərən cihazları buraxır. Bu cihaz üçün yaradılmış .p12 və ya .pfx faylını əlavə edin. Tətbiq onu yalnız {host} serverinə göstərir.',
'native.cert.hintRefused': 'Proksi tətbiqdəki sertifikata etibar etmir və ya artıq etmir. Gözlədiyi faylı seçin.',
'native.cert.choose': 'Sertifikat faylı əlavə et',
'native.cert.chooseOther': 'Başqa sertifikat seç',
'native.cert.help': 'Tətbiqi müştəri sertifikatı ilə istifadə etmək',
'native.cert.passwordTitle': 'Sertifikat şifrəsi',
'native.cert.passwordMessage': 'Sertifikat faylının şifrəsini daxil edin. Tətbiq onu saxlamır.',
'native.cert.passwordPlaceholder': 'Şifrə',
'native.cert.passwordOk': 'Əlavə et',
'native.cert.passwordCancel': 'Ləğv et',
'native.cert.passwordWrong': 'Bu şifrə faylı açmır. Yenidən cəhd edin.',
'native.cert.errorFormat':
'Tətbiq bu faylı oxuya bilmir. Sertifikat və onun şəxsi açarı olan .p12 və ya .pfx faylı lazımdır.',
'native.cert.errorNoKey': 'Bu faylda şəxsi açar yoxdur. Sertifikatı açarı ilə birlikdə ixrac edin.',
'native.cert.errorExpired': 'Bu sertifikatın müddəti bitib.',
'native.cert.errorNotYetValid': 'Bu sertifikat hələ etibarlı deyil. Bu cihazın tarix və saatını yoxlayın.',
'native.cert.errorStore': 'Tətbiq sertifikatı saxlaya bilmədi. Yenidən cəhd edin.',
'native.cert.unreachableTitle': 'Sertifikat lazımdır',
'native.cert.unreachableHint': '{server} yalnız etibarlı müştəri sertifikatı olan cihazları buraxır.',
'native.cert.settingTitle': 'Müştəri sertifikatı',
'native.cert.settingIssuer': 'Verən: {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Etibarlıdır',
'native.cert.settingHint': 'Tətbiq bu sertifikatı yalnız {host} serverinə, proksisi soruşanda göstərir.',
'native.cert.settingReplace': 'Əvəz et',
'native.cert.settingRemove': 'Sil',
'native.cert.removeTitle': 'Sertifikat silinsin?',
'native.cert.removeMessage': 'Onsuz {host} qarşısındakı proksi yenisini əlavə edənə qədər tətbiqi buraxmayacaq.',
};
export default native;
+33
View File
@@ -147,6 +147,39 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'O app não consegue responder a este pedido. Deixe o TREK passar no seu proxy: o TREK continua pedindo o próprio login.',
'native.gate.help': 'Usar o app atrás de um proxy de login',
'native.cert.title': 'Este servidor pede um certificado de cliente',
'native.cert.titleRefused': 'O servidor não aceitou o certificado',
'native.cert.hint':
'Um proxy na frente dele só deixa entrar dispositivos que apresentam um certificado. Adicione o arquivo .p12 ou .pfx criado para este dispositivo. O app só o apresenta para {host}.',
'native.cert.hintRefused':
'O proxy não confia, ou não confia mais, no certificado do app. Escolha o arquivo que ele espera.',
'native.cert.choose': 'Adicionar arquivo de certificado',
'native.cert.chooseOther': 'Escolher outro certificado',
'native.cert.help': 'Usar o app com um certificado de cliente',
'native.cert.passwordTitle': 'Senha do certificado',
'native.cert.passwordMessage': 'Digite a senha do arquivo de certificado. O app não a guarda.',
'native.cert.passwordPlaceholder': 'Senha',
'native.cert.passwordOk': 'Adicionar',
'native.cert.passwordCancel': 'Cancelar',
'native.cert.passwordWrong': 'Esta senha não abre o arquivo. Tente de novo.',
'native.cert.errorFormat':
'O app não consegue ler este arquivo. É preciso um arquivo .p12 ou .pfx com o certificado e sua chave privada.',
'native.cert.errorNoKey': 'Este arquivo não tem chave privada. Exporte o certificado junto com a chave.',
'native.cert.errorExpired': 'Este certificado expirou.',
'native.cert.errorNotYetValid': 'Este certificado ainda não é válido. Verifique a data e a hora deste dispositivo.',
'native.cert.errorStore': 'O app não conseguiu salvar o certificado. Tente de novo.',
'native.cert.unreachableTitle': 'Certificado necessário',
'native.cert.unreachableHint': '{server} só deixa entrar dispositivos com um certificado de cliente confiável.',
'native.cert.settingTitle': 'Certificado de cliente',
'native.cert.settingIssuer': 'Emitido por {issuer}',
'native.cert.settingServer': 'Servidor',
'native.cert.settingValidUntil': 'Válido até',
'native.cert.settingHint': 'O app só apresenta este certificado para {host}, quando o proxy dele pede.',
'native.cert.settingReplace': 'Substituir',
'native.cert.settingRemove': 'Remover',
'native.cert.removeTitle': 'Remover o certificado?',
'native.cert.removeMessage':
'Sem ele, o proxy na frente de {host} não deixa mais o app entrar até você adicionar outro.',
};
export default native;
+34
View File
@@ -148,6 +148,40 @@ const native: TranslationStrings = {
'native.gate.basicHint':
"L'app no pot respondre aquesta sol·licitud. Deixa passar TREK al teu proxy: TREK continua demanant el seu propi inici de sessió.",
'native.gate.help': "Fer servir l'app darrere d'un proxy d'inici de sessió",
'native.cert.title': 'Aquest servidor demana un certificat de client',
'native.cert.titleRefused': 'El servidor no ha acceptat el certificat',
'native.cert.hint':
"Un proxy al davant només deixa passar els dispositius que mostren un certificat. Afegeix el fitxer .p12 o .pfx creat per a aquest dispositiu. L'app només el mostra a {host}.",
'native.cert.hintRefused':
"El proxy no confia, o ja no confia, en el certificat de l'app. Tria el fitxer que espera.",
'native.cert.choose': 'Afegeix un fitxer de certificat',
'native.cert.chooseOther': 'Tria un altre certificat',
'native.cert.help': "Fer servir l'app amb un certificat de client",
'native.cert.passwordTitle': 'Contrasenya del certificat',
'native.cert.passwordMessage': "Introdueix la contrasenya del fitxer de certificat. L'app no la desa.",
'native.cert.passwordPlaceholder': 'Contrasenya',
'native.cert.passwordOk': 'Afegeix',
'native.cert.passwordCancel': 'Cancel·la',
'native.cert.passwordWrong': 'Aquesta contrasenya no obre el fitxer. Torna-ho a provar.',
'native.cert.errorFormat':
"L'app no pot llegir aquest fitxer. Cal un fitxer .p12 o .pfx amb el certificat i la seva clau privada.",
'native.cert.errorNoKey': 'Aquest fitxer no conté cap clau privada. Exporta el certificat juntament amb la clau.',
'native.cert.errorExpired': 'Aquest certificat ha caducat.',
'native.cert.errorNotYetValid':
"Aquest certificat encara no és vàlid. Comprova la data i l'hora d'aquest dispositiu.",
'native.cert.errorStore': "L'app no ha pogut desar el certificat. Torna-ho a provar.",
'native.cert.unreachableTitle': 'Cal un certificat',
'native.cert.unreachableHint': '{server} només deixa passar dispositius amb un certificat de client de confiança.',
'native.cert.settingTitle': 'Certificat de client',
'native.cert.settingIssuer': 'Emès per {issuer}',
'native.cert.settingServer': 'Servidor',
'native.cert.settingValidUntil': 'Vàlid fins al',
'native.cert.settingHint': "L'app només mostra aquest certificat a {host}, quan el seu proxy el demana.",
'native.cert.settingReplace': 'Substitueix',
'native.cert.settingRemove': 'Elimina',
'native.cert.removeTitle': 'Vols eliminar el certificat?',
'native.cert.removeMessage':
"Sense ell, el proxy davant de {host} ja no deixa entrar l'app fins que n'afegeixis un altre.",
};
export default native;
+31
View File
@@ -145,6 +145,37 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Aplikace na tuto výzvu nedokáže odpovědět. Nechte TREK ve své proxy projít: TREK stále žádá své vlastní přihlášení.',
'native.gate.help': 'Používání aplikace za přihlašovací proxy',
'native.cert.title': 'Tento server vyžaduje klientský certifikát',
'native.cert.titleRefused': 'Server certifikát nepřijal',
'native.cert.hint':
'Proxy před ním pouští jen zařízení s certifikátem. Přidej soubor .p12 nebo .pfx vytvořený pro toto zařízení. Aplikace ho ukáže jen serveru {host}.',
'native.cert.hintRefused': 'Proxy certifikátu v aplikaci nedůvěřuje nebo už nedůvěřuje. Vyber soubor, který očekává.',
'native.cert.choose': 'Přidat soubor certifikátu',
'native.cert.chooseOther': 'Vybrat jiný certifikát',
'native.cert.help': 'Používání aplikace s klientským certifikátem',
'native.cert.passwordTitle': 'Heslo certifikátu',
'native.cert.passwordMessage': 'Zadej heslo souboru certifikátu. Aplikace si ho neukládá.',
'native.cert.passwordPlaceholder': 'Heslo',
'native.cert.passwordOk': 'Přidat',
'native.cert.passwordCancel': 'Zrušit',
'native.cert.passwordWrong': 'Toto heslo soubor neotevře. Zkus to znovu.',
'native.cert.errorFormat':
'Aplikace tento soubor nedokáže přečíst. Potřebuje soubor .p12 nebo .pfx s certifikátem a jeho soukromým klíčem.',
'native.cert.errorNoKey': 'Tento soubor neobsahuje soukromý klíč. Exportuj certifikát i s klíčem.',
'native.cert.errorExpired': 'Platnost tohoto certifikátu vypršela.',
'native.cert.errorNotYetValid': 'Tento certifikát ještě neplatí. Zkontroluj datum a čas na tomto zařízení.',
'native.cert.errorStore': 'Aplikace nemohla certifikát uložit. Zkus to znovu.',
'native.cert.unreachableTitle': 'Je potřeba certifikát',
'native.cert.unreachableHint': '{server} pouští jen zařízení s důvěryhodným klientským certifikátem.',
'native.cert.settingTitle': 'Klientský certifikát',
'native.cert.settingIssuer': 'Vydal: {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Platný do',
'native.cert.settingHint': 'Aplikace ukazuje tento certifikát jen serveru {host}, když o něj jeho proxy požádá.',
'native.cert.settingReplace': 'Nahradit',
'native.cert.settingRemove': 'Odebrat',
'native.cert.removeTitle': 'Odebrat certifikát?',
'native.cert.removeMessage': 'Bez něj proxy před {host} aplikaci nepustí, dokud nepřidáš nový.',
};
export default native;
+34
View File
@@ -150,6 +150,40 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Die App kann diese Abfrage nicht beantworten. Lass TREK in deinem Proxy daran vorbei: TREK fragt weiterhin nach seiner eigenen Anmeldung.',
'native.gate.help': 'Die App hinter einem Anmelde-Proxy nutzen',
'native.cert.title': 'Dieser Server verlangt ein Client-Zertifikat',
'native.cert.titleRefused': 'Der Server hat das Zertifikat nicht angenommen',
'native.cert.hint':
'Ein Proxy davor lässt nur Geräte mit Zertifikat durch. Füge die .p12- oder .pfx-Datei hinzu, die für dieses Gerät erstellt wurde. Die App zeigt sie nur {host} und sonst niemandem.',
'native.cert.hintRefused':
'Der Proxy vertraut dem Zertifikat in der App nicht oder nicht mehr. Wähle die Datei, die er erwartet.',
'native.cert.choose': 'Zertifikatsdatei hinzufügen',
'native.cert.chooseOther': 'Anderes Zertifikat wählen',
'native.cert.help': 'Die App mit Client-Zertifikat nutzen',
'native.cert.passwordTitle': 'Zertifikatspasswort',
'native.cert.passwordMessage': 'Gib das Passwort der Zertifikatsdatei ein. Die App speichert es nicht.',
'native.cert.passwordPlaceholder': 'Passwort',
'native.cert.passwordOk': 'Hinzufügen',
'native.cert.passwordCancel': 'Abbrechen',
'native.cert.passwordWrong': 'Dieses Passwort öffnet die Datei nicht. Versuch es noch einmal.',
'native.cert.errorFormat':
'Die App kann diese Datei nicht lesen. Sie braucht eine .p12- oder .pfx-Datei mit Zertifikat und privatem Schlüssel.',
'native.cert.errorNoKey':
'Diese Datei enthält keinen privaten Schlüssel. Exportiere das Zertifikat zusammen mit seinem Schlüssel.',
'native.cert.errorExpired': 'Dieses Zertifikat ist abgelaufen.',
'native.cert.errorNotYetValid': 'Dieses Zertifikat gilt noch nicht. Prüfe Datum und Uhrzeit auf diesem Gerät.',
'native.cert.errorStore': 'Die App konnte das Zertifikat nicht speichern. Versuch es noch einmal.',
'native.cert.unreachableTitle': 'Zertifikat nötig',
'native.cert.unreachableHint': '{server} lässt nur Geräte mit einem Client-Zertifikat durch, dem es vertraut.',
'native.cert.settingTitle': 'Client-Zertifikat',
'native.cert.settingIssuer': 'Ausgestellt von {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Gültig bis',
'native.cert.settingHint': 'Die App zeigt dieses Zertifikat nur {host}, wenn dessen Proxy danach fragt.',
'native.cert.settingReplace': 'Ersetzen',
'native.cert.settingRemove': 'Entfernen',
'native.cert.removeTitle': 'Zertifikat entfernen?',
'native.cert.removeMessage':
'Ohne Zertifikat lässt der Proxy vor {host} die App nicht mehr durch, bis du wieder eins hinzufügst.',
};
export default native;
+33
View File
@@ -147,6 +147,39 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'The app cannot answer this prompt. Let TREK through it in your proxy: TREK still asks for its own login.',
'native.gate.help': 'Using the app behind a sign-in proxy',
'native.cert.title': 'This server asks for a client certificate',
'native.cert.titleRefused': 'The server did not accept the certificate',
'native.cert.hint':
'A proxy in front of it lets in only devices that show a certificate. Add the .p12 or .pfx file made for this device. The app shows it to {host} and nowhere else.',
'native.cert.hintRefused':
'The proxy does not trust the certificate in the app, or no longer does. Choose the file it expects.',
'native.cert.choose': 'Add certificate file',
'native.cert.chooseOther': 'Choose another certificate',
'native.cert.help': 'Using the app with a client certificate',
'native.cert.passwordTitle': 'Certificate password',
'native.cert.passwordMessage': 'Enter the password of the certificate file. The app does not keep it.',
'native.cert.passwordPlaceholder': 'Password',
'native.cert.passwordOk': 'Add',
'native.cert.passwordCancel': 'Cancel',
'native.cert.passwordWrong': 'This password does not open the file. Try again.',
'native.cert.errorFormat':
'The app cannot read this file. It needs a .p12 or .pfx file with the certificate and its private key.',
'native.cert.errorNoKey': 'This file holds no private key. Export the certificate together with its key.',
'native.cert.errorExpired': 'This certificate has expired.',
'native.cert.errorNotYetValid': 'This certificate is not valid yet. Check the date and time on this device.',
'native.cert.errorStore': 'The app could not store the certificate. Try again.',
'native.cert.unreachableTitle': 'Certificate needed',
'native.cert.unreachableHint': '{server} lets in only devices with a client certificate it trusts.',
'native.cert.settingTitle': 'Client certificate',
'native.cert.settingIssuer': 'Issued by {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Valid until',
'native.cert.settingHint': 'The app shows this certificate to {host} only, when its proxy asks for it.',
'native.cert.settingReplace': 'Replace',
'native.cert.settingRemove': 'Remove',
'native.cert.removeTitle': 'Remove the certificate?',
'native.cert.removeMessage':
'Without it, the proxy in front of {host} no longer lets the app in until you add one again.',
};
export default native;
+33
View File
@@ -149,6 +149,39 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'La app no puede responder a esta solicitud. Deja pasar TREK en tu proxy: TREK sigue pidiendo su propio inicio de sesión.',
'native.gate.help': 'Usar la app detrás de un proxy de inicio de sesión',
'native.cert.title': 'Este servidor pide un certificado de cliente',
'native.cert.titleRefused': 'El servidor no aceptó el certificado',
'native.cert.hint':
'Un proxy delante solo deja pasar a los dispositivos que muestran un certificado. Añade el archivo .p12 o .pfx creado para este dispositivo. La app solo se lo muestra a {host}.',
'native.cert.hintRefused':
'El proxy no confía, o ya no confía, en el certificado de la app. Elige el archivo que espera.',
'native.cert.choose': 'Añadir archivo de certificado',
'native.cert.chooseOther': 'Elegir otro certificado',
'native.cert.help': 'Usar la app con un certificado de cliente',
'native.cert.passwordTitle': 'Contraseña del certificado',
'native.cert.passwordMessage': 'Introduce la contraseña del archivo de certificado. La app no la guarda.',
'native.cert.passwordPlaceholder': 'Contraseña',
'native.cert.passwordOk': 'Añadir',
'native.cert.passwordCancel': 'Cancelar',
'native.cert.passwordWrong': 'Esta contraseña no abre el archivo. Inténtalo de nuevo.',
'native.cert.errorFormat':
'La app no puede leer este archivo. Necesita un archivo .p12 o .pfx con el certificado y su clave privada.',
'native.cert.errorNoKey': 'Este archivo no contiene clave privada. Exporta el certificado junto con su clave.',
'native.cert.errorExpired': 'Este certificado ha caducado.',
'native.cert.errorNotYetValid':
'Este certificado aún no es válido. Comprueba la fecha y la hora de este dispositivo.',
'native.cert.errorStore': 'La app no pudo guardar el certificado. Inténtalo de nuevo.',
'native.cert.unreachableTitle': 'Se necesita un certificado',
'native.cert.unreachableHint': '{server} solo deja pasar a dispositivos con un certificado de cliente de confianza.',
'native.cert.settingTitle': 'Certificado de cliente',
'native.cert.settingIssuer': 'Emitido por {issuer}',
'native.cert.settingServer': 'Servidor',
'native.cert.settingValidUntil': 'Válido hasta',
'native.cert.settingHint': 'La app solo muestra este certificado a {host}, cuando su proxy lo pide.',
'native.cert.settingReplace': 'Reemplazar',
'native.cert.settingRemove': 'Eliminar',
'native.cert.removeTitle': '¿Eliminar el certificado?',
'native.cert.removeMessage': 'Sin él, el proxy delante de {host} ya no deja entrar a la app hasta que añadas otro.',
};
export default native;
+31
View File
@@ -146,6 +146,37 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Rakendus ei saa sellele päringule vastata. Lase TREK oma proksis läbi: TREK küsib endiselt oma sisselogimist.',
'native.gate.help': 'Rakenduse kasutamine sisselogimisproksi taga',
'native.cert.title': 'See server nõuab kliendisertifikaati',
'native.cert.titleRefused': 'Server ei võtnud sertifikaati vastu',
'native.cert.hint':
'Selle ees olev proksi laseb läbi ainult sertifikaadiga seadmed. Lisa sellele seadmele loodud .p12- või .pfx-fail. Rakendus näitab seda ainult serverile {host}.',
'native.cert.hintRefused': 'Proksi ei usalda (enam) rakenduses olevat sertifikaati. Vali fail, mida see ootab.',
'native.cert.choose': 'Lisa sertifikaadifail',
'native.cert.chooseOther': 'Vali teine sertifikaat',
'native.cert.help': 'Rakenduse kasutamine kliendisertifikaadiga',
'native.cert.passwordTitle': 'Sertifikaadi parool',
'native.cert.passwordMessage': 'Sisesta sertifikaadifaili parool. Rakendus seda ei salvesta.',
'native.cert.passwordPlaceholder': 'Parool',
'native.cert.passwordOk': 'Lisa',
'native.cert.passwordCancel': 'Tühista',
'native.cert.passwordWrong': 'See parool ei ava faili. Proovi uuesti.',
'native.cert.errorFormat':
'Rakendus ei saa seda faili lugeda. Vaja on .p12- või .pfx-faili, milles on sertifikaat ja selle privaatvõti.',
'native.cert.errorNoKey': 'Selles failis pole privaatvõtit. Ekspordi sertifikaat koos võtmega.',
'native.cert.errorExpired': 'See sertifikaat on aegunud.',
'native.cert.errorNotYetValid': 'See sertifikaat ei kehti veel. Kontrolli selle seadme kuupäeva ja kellaaega.',
'native.cert.errorStore': 'Rakendus ei saanud sertifikaati salvestada. Proovi uuesti.',
'native.cert.unreachableTitle': 'Sertifikaat on vajalik',
'native.cert.unreachableHint': '{server} laseb läbi ainult usaldusväärse kliendisertifikaadiga seadmed.',
'native.cert.settingTitle': 'Kliendisertifikaat',
'native.cert.settingIssuer': 'Väljastaja: {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Kehtib kuni',
'native.cert.settingHint': 'Rakendus näitab seda sertifikaati ainult serverile {host}, kui selle proksi seda küsib.',
'native.cert.settingReplace': 'Asenda',
'native.cert.settingRemove': 'Eemalda',
'native.cert.removeTitle': 'Eemaldada sertifikaat?',
'native.cert.removeMessage': 'Ilma selleta ei lase {host} ees olev proksi rakendust enam läbi, kuni lisad uue.',
};
export default native;
+34
View File
@@ -149,6 +149,40 @@ const native: TranslationStrings = {
'native.gate.basicHint':
"L'app ne peut pas répondre à cette demande. Laissez passer TREK dans votre proxy : TREK demande toujours sa propre connexion.",
'native.gate.help': "Utiliser l'app derrière un proxy de connexion",
'native.cert.title': 'Ce serveur demande un certificat client',
'native.cert.titleRefused': "Le serveur n'a pas accepté le certificat",
'native.cert.hint':
"Un proxy placé devant ne laisse passer que les appareils qui présentent un certificat. Ajoute le fichier .p12 ou .pfx créé pour cet appareil. L'app le présente uniquement à {host}.",
'native.cert.hintRefused':
"Le proxy ne fait pas ou plus confiance au certificat de l'app. Choisis le fichier qu'il attend.",
'native.cert.choose': 'Ajouter un fichier de certificat',
'native.cert.chooseOther': 'Choisir un autre certificat',
'native.cert.help': "Utiliser l'app avec un certificat client",
'native.cert.passwordTitle': 'Mot de passe du certificat',
'native.cert.passwordMessage': "Saisis le mot de passe du fichier de certificat. L'app ne le conserve pas.",
'native.cert.passwordPlaceholder': 'Mot de passe',
'native.cert.passwordOk': 'Ajouter',
'native.cert.passwordCancel': 'Annuler',
'native.cert.passwordWrong': "Ce mot de passe n'ouvre pas le fichier. Réessaie.",
'native.cert.errorFormat':
"L'app ne peut pas lire ce fichier. Il faut un fichier .p12 ou .pfx contenant le certificat et sa clé privée.",
'native.cert.errorNoKey': 'Ce fichier ne contient pas de clé privée. Exporte le certificat avec sa clé.',
'native.cert.errorExpired': 'Ce certificat a expiré.',
'native.cert.errorNotYetValid': "Ce certificat n'est pas encore valide. Vérifie la date et l'heure de cet appareil.",
'native.cert.errorStore': "L'app n'a pas pu enregistrer le certificat. Réessaie.",
'native.cert.unreachableTitle': 'Certificat requis',
'native.cert.unreachableHint':
'{server} ne laisse passer que les appareils dotés d’un certificat client de confiance.',
'native.cert.settingTitle': 'Certificat client',
'native.cert.settingIssuer': 'Émis par {issuer}',
'native.cert.settingServer': 'Serveur',
'native.cert.settingValidUntil': "Valable jusqu'au",
'native.cert.settingHint': "L'app présente ce certificat uniquement à {host}, quand son proxy le demande.",
'native.cert.settingReplace': 'Remplacer',
'native.cert.settingRemove': 'Supprimer',
'native.cert.removeTitle': 'Supprimer le certificat ?',
'native.cert.removeMessage':
"Sans lui, le proxy devant {host} ne laisse plus entrer l'app tant que tu n'en ajoutes pas un nouveau.",
};
export default native;
+36
View File
@@ -151,6 +151,42 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Η εφαρμογή δεν μπορεί να απαντήσει σε αυτό το αίτημα. Αφήστε το TREK να περνά στον proxy σας: το TREK ζητά ακόμη τη δική του σύνδεση.',
'native.gate.help': 'Χρήση της εφαρμογής πίσω από proxy σύνδεσης',
'native.cert.title': 'Αυτός ο διακομιστής ζητά πιστοποιητικό πελάτη',
'native.cert.titleRefused': 'Ο διακομιστής δεν δέχτηκε το πιστοποιητικό',
'native.cert.hint':
'Ένας διακομιστής μεσολάβησης μπροστά του αφήνει να περάσουν μόνο συσκευές με πιστοποιητικό. Πρόσθεσε το αρχείο .p12 ή .pfx που δημιουργήθηκε για αυτή τη συσκευή. Η εφαρμογή το δείχνει μόνο στο {host}.',
'native.cert.hintRefused':
'Ο διακομιστής μεσολάβησης δεν εμπιστεύεται (πια) το πιστοποιητικό της εφαρμογής. Διάλεξε το αρχείο που περιμένει.',
'native.cert.choose': 'Προσθήκη αρχείου πιστοποιητικού',
'native.cert.chooseOther': 'Επιλογή άλλου πιστοποιητικού',
'native.cert.help': 'Χρήση της εφαρμογής με πιστοποιητικό πελάτη',
'native.cert.passwordTitle': 'Κωδικός πιστοποιητικού',
'native.cert.passwordMessage': 'Πληκτρολόγησε τον κωδικό του αρχείου πιστοποιητικού. Η εφαρμογή δεν τον αποθηκεύει.',
'native.cert.passwordPlaceholder': 'Κωδικός',
'native.cert.passwordOk': 'Προσθήκη',
'native.cert.passwordCancel': 'Ακύρωση',
'native.cert.passwordWrong': 'Αυτός ο κωδικός δεν ανοίγει το αρχείο. Δοκίμασε ξανά.',
'native.cert.errorFormat':
'Η εφαρμογή δεν μπορεί να διαβάσει αυτό το αρχείο. Χρειάζεται αρχείο .p12 ή .pfx με το πιστοποιητικό και το ιδιωτικό του κλειδί.',
'native.cert.errorNoKey':
'Αυτό το αρχείο δεν περιέχει ιδιωτικό κλειδί. Εξήγαγε το πιστοποιητικό μαζί με το κλειδί του.',
'native.cert.errorExpired': 'Αυτό το πιστοποιητικό έχει λήξει.',
'native.cert.errorNotYetValid':
'Αυτό το πιστοποιητικό δεν ισχύει ακόμη. Έλεγξε την ημερομηνία και την ώρα της συσκευής.',
'native.cert.errorStore': 'Η εφαρμογή δεν μπόρεσε να αποθηκεύσει το πιστοποιητικό. Δοκίμασε ξανά.',
'native.cert.unreachableTitle': 'Απαιτείται πιστοποιητικό',
'native.cert.unreachableHint': 'Το {server} αφήνει να περάσουν μόνο συσκευές με έμπιστο πιστοποιητικό πελάτη.',
'native.cert.settingTitle': 'Πιστοποιητικό πελάτη',
'native.cert.settingIssuer': 'Εκδότης: {issuer}',
'native.cert.settingServer': 'Διακομιστής',
'native.cert.settingValidUntil': 'Ισχύει έως',
'native.cert.settingHint':
'Η εφαρμογή δείχνει αυτό το πιστοποιητικό μόνο στο {host}, όταν το ζητά ο διακομιστής μεσολάβησής του.',
'native.cert.settingReplace': 'Αντικατάσταση',
'native.cert.settingRemove': 'Αφαίρεση',
'native.cert.removeTitle': 'Αφαίρεση του πιστοποιητικού;',
'native.cert.removeMessage':
'Χωρίς αυτό, ο διακομιστής μεσολάβησης μπροστά στο {host} δεν αφήνει πια την εφαρμογή να μπει, μέχρι να προσθέσεις νέο.',
};
export default native;
+30
View File
@@ -143,6 +143,36 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'האפליקציה לא יכולה לענות לבקשה הזו. אפשר ל TREK לעבור אותה בפרוקסי שלך: TREK עדיין מבקש התחברות משלו.',
'native.gate.help': 'שימוש באפליקציה מאחורי פרוקסי התחברות',
'native.cert.title': 'השרת הזה מבקש תעודת לקוח',
'native.cert.titleRefused': 'השרת לא קיבל את התעודה',
'native.cert.hint':
'פרוקסי שלפניו מכניס רק מכשירים שמציגים תעודה. יש להוסיף את קובץ ה‎.p12 או ה‎.pfx שנוצר למכשיר הזה. האפליקציה מציגה אותו רק ל‎{host}.',
'native.cert.hintRefused': 'הפרוקסי לא סומך (או כבר לא סומך) על התעודה שבאפליקציה. יש לבחור את הקובץ שהוא מצפה לו.',
'native.cert.choose': 'הוספת קובץ תעודה',
'native.cert.chooseOther': 'בחירת תעודה אחרת',
'native.cert.help': 'שימוש באפליקציה עם תעודת לקוח',
'native.cert.passwordTitle': 'סיסמת התעודה',
'native.cert.passwordMessage': 'יש להזין את הסיסמה של קובץ התעודה. האפליקציה לא שומרת אותה.',
'native.cert.passwordPlaceholder': 'סיסמה',
'native.cert.passwordOk': 'הוספה',
'native.cert.passwordCancel': 'ביטול',
'native.cert.passwordWrong': 'הסיסמה הזו לא פותחת את הקובץ. כדאי לנסות שוב.',
'native.cert.errorFormat': 'האפליקציה לא מצליחה לקרוא את הקובץ. נדרש קובץ ‎.p12 או ‎.pfx עם התעודה והמפתח הפרטי שלה.',
'native.cert.errorNoKey': 'בקובץ הזה אין מפתח פרטי. יש לייצא את התעודה יחד עם המפתח שלה.',
'native.cert.errorExpired': 'תוקף התעודה הזו פג.',
'native.cert.errorNotYetValid': 'התעודה הזו עדיין לא בתוקף. כדאי לבדוק את התאריך והשעה במכשיר.',
'native.cert.errorStore': 'האפליקציה לא הצליחה לשמור את התעודה. כדאי לנסות שוב.',
'native.cert.unreachableTitle': 'נדרשת תעודה',
'native.cert.unreachableHint': '{server} מכניס רק מכשירים עם תעודת לקוח מהימנה.',
'native.cert.settingTitle': 'תעודת לקוח',
'native.cert.settingIssuer': 'הונפקה על ידי {issuer}',
'native.cert.settingServer': 'שרת',
'native.cert.settingValidUntil': 'בתוקף עד',
'native.cert.settingHint': 'האפליקציה מציגה את התעודה הזו רק ל‎{host}, כשהפרוקסי שלו מבקש אותה.',
'native.cert.settingReplace': 'החלפה',
'native.cert.settingRemove': 'הסרה',
'native.cert.removeTitle': 'להסיר את התעודה?',
'native.cert.removeMessage': 'בלעדיה הפרוקסי שלפני {host} לא יכניס את האפליקציה עד שתתווסף תעודה חדשה.',
};
export default native;
+32
View File
@@ -150,6 +150,38 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Az alkalmazás nem tud válaszolni erre a kérésre. Engedd át a TREK-et a proxyban: a TREK továbbra is a saját bejelentkezését kéri.',
'native.gate.help': 'Az alkalmazás használata bejelentkezési proxy mögött',
'native.cert.title': 'Ez a szerver kliens tanúsítványt kér',
'native.cert.titleRefused': 'A szerver nem fogadta el a tanúsítványt',
'native.cert.hint':
'Az előtte álló proxy csak tanúsítványt felmutató eszközöket enged be. Add hozzá az ehhez az eszközhöz készült .p12 vagy .pfx fájlt. Az app csak a(z) {host} számára mutatja meg.',
'native.cert.hintRefused':
'A proxy nem (vagy már nem) bízik az appban lévő tanúsítványban. Válaszd ki a fájlt, amit vár.',
'native.cert.choose': 'Tanúsítványfájl hozzáadása',
'native.cert.chooseOther': 'Másik tanúsítvány választása',
'native.cert.help': 'Az app használata kliens tanúsítvánnyal',
'native.cert.passwordTitle': 'Tanúsítvány jelszava',
'native.cert.passwordMessage': 'Add meg a tanúsítványfájl jelszavát. Az app nem tárolja.',
'native.cert.passwordPlaceholder': 'Jelszó',
'native.cert.passwordOk': 'Hozzáadás',
'native.cert.passwordCancel': 'Mégse',
'native.cert.passwordWrong': 'Ez a jelszó nem nyitja meg a fájlt. Próbáld újra.',
'native.cert.errorFormat':
'Az app nem tudja beolvasni ezt a fájlt. Olyan .p12 vagy .pfx fájl kell, amely a tanúsítványt és a privát kulcsát is tartalmazza.',
'native.cert.errorNoKey': 'Ebben a fájlban nincs privát kulcs. Exportáld a tanúsítványt a kulcsával együtt.',
'native.cert.errorExpired': 'Ez a tanúsítvány lejárt.',
'native.cert.errorNotYetValid': 'Ez a tanúsítvány még nem érvényes. Ellenőrizd az eszköz dátumát és idejét.',
'native.cert.errorStore': 'Az app nem tudta elmenteni a tanúsítványt. Próbáld újra.',
'native.cert.unreachableTitle': 'Tanúsítvány szükséges',
'native.cert.unreachableHint': '{server} csak megbízható kliens tanúsítvánnyal rendelkező eszközöket enged be.',
'native.cert.settingTitle': 'Kliens tanúsítvány',
'native.cert.settingIssuer': 'Kiállító: {issuer}',
'native.cert.settingServer': 'Szerver',
'native.cert.settingValidUntil': 'Érvényes eddig',
'native.cert.settingHint': 'Az app csak a(z) {host} számára mutatja meg ezt a tanúsítványt, amikor a proxyja kéri.',
'native.cert.settingReplace': 'Csere',
'native.cert.settingRemove': 'Eltávolítás',
'native.cert.removeTitle': 'Eltávolítod a tanúsítványt?',
'native.cert.removeMessage': 'Nélküle a(z) {host} előtti proxy nem engedi be az appot, amíg újat nem adsz hozzá.',
};
export default native;
+32
View File
@@ -148,6 +148,38 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Aplikasi tidak bisa menjawab permintaan ini. Loloskan TREK di proxy kamu: TREK tetap meminta login miliknya sendiri.',
'native.gate.help': 'Memakai aplikasi di balik proxy masuk',
'native.cert.title': 'Server ini meminta sertifikat klien',
'native.cert.titleRefused': 'Server tidak menerima sertifikat',
'native.cert.hint':
'Proxy di depannya hanya meloloskan perangkat yang menunjukkan sertifikat. Tambahkan file .p12 atau .pfx yang dibuat untuk perangkat ini. Aplikasi hanya menunjukkannya ke {host}.',
'native.cert.hintRefused': 'Proxy tidak (lagi) memercayai sertifikat di aplikasi. Pilih file yang diharapkannya.',
'native.cert.choose': 'Tambahkan file sertifikat',
'native.cert.chooseOther': 'Pilih sertifikat lain',
'native.cert.help': 'Menggunakan aplikasi dengan sertifikat klien',
'native.cert.passwordTitle': 'Kata sandi sertifikat',
'native.cert.passwordMessage': 'Masukkan kata sandi file sertifikat. Aplikasi tidak menyimpannya.',
'native.cert.passwordPlaceholder': 'Kata sandi',
'native.cert.passwordOk': 'Tambahkan',
'native.cert.passwordCancel': 'Batal',
'native.cert.passwordWrong': 'Kata sandi ini tidak membuka file. Coba lagi.',
'native.cert.errorFormat':
'Aplikasi tidak dapat membaca file ini. Diperlukan file .p12 atau .pfx berisi sertifikat dan kunci privatnya.',
'native.cert.errorNoKey': 'File ini tidak berisi kunci privat. Ekspor sertifikat bersama kuncinya.',
'native.cert.errorExpired': 'Sertifikat ini sudah kedaluwarsa.',
'native.cert.errorNotYetValid': 'Sertifikat ini belum berlaku. Periksa tanggal dan waktu di perangkat ini.',
'native.cert.errorStore': 'Aplikasi tidak dapat menyimpan sertifikat. Coba lagi.',
'native.cert.unreachableTitle': 'Sertifikat diperlukan',
'native.cert.unreachableHint': '{server} hanya meloloskan perangkat dengan sertifikat klien tepercaya.',
'native.cert.settingTitle': 'Sertifikat klien',
'native.cert.settingIssuer': 'Diterbitkan oleh {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Berlaku hingga',
'native.cert.settingHint': 'Aplikasi hanya menunjukkan sertifikat ini ke {host}, saat proxy-nya memintanya.',
'native.cert.settingReplace': 'Ganti',
'native.cert.settingRemove': 'Hapus',
'native.cert.removeTitle': 'Hapus sertifikat?',
'native.cert.removeMessage':
'Tanpa sertifikat, proxy di depan {host} tidak lagi meloloskan aplikasi sampai kamu menambahkan yang baru.',
};
export default native;
+34
View File
@@ -149,6 +149,40 @@ const native: TranslationStrings = {
'native.gate.basicHint':
"L'app non può rispondere a questa richiesta. Lascia passare TREK nel tuo proxy: TREK chiede comunque il proprio accesso.",
'native.gate.help': "Usare l'app dietro un proxy di accesso",
'native.cert.title': 'Questo server richiede un certificato client',
'native.cert.titleRefused': 'Il server non ha accettato il certificato',
'native.cert.hint':
"Un proxy davanti lascia passare solo i dispositivi che mostrano un certificato. Aggiungi il file .p12 o .pfx creato per questo dispositivo. L'app lo mostra solo a {host}.",
'native.cert.hintRefused':
"Il proxy non si fida, o non si fida più, del certificato nell'app. Scegli il file che si aspetta.",
'native.cert.choose': 'Aggiungi file del certificato',
'native.cert.chooseOther': 'Scegli un altro certificato',
'native.cert.help': "Usare l'app con un certificato client",
'native.cert.passwordTitle': 'Password del certificato',
'native.cert.passwordMessage': "Inserisci la password del file del certificato. L'app non la conserva.",
'native.cert.passwordPlaceholder': 'Password',
'native.cert.passwordOk': 'Aggiungi',
'native.cert.passwordCancel': 'Annulla',
'native.cert.passwordWrong': 'Questa password non apre il file. Riprova.',
'native.cert.errorFormat':
"L'app non riesce a leggere questo file. Serve un file .p12 o .pfx con il certificato e la sua chiave privata.",
'native.cert.errorNoKey':
'Questo file non contiene una chiave privata. Esporta il certificato insieme alla sua chiave.',
'native.cert.errorExpired': 'Questo certificato è scaduto.',
'native.cert.errorNotYetValid': 'Questo certificato non è ancora valido. Controlla data e ora di questo dispositivo.',
'native.cert.errorStore': "L'app non è riuscita a salvare il certificato. Riprova.",
'native.cert.unreachableTitle': 'Certificato necessario',
'native.cert.unreachableHint': '{server} lascia passare solo dispositivi con un certificato client affidabile.',
'native.cert.settingTitle': 'Certificato client',
'native.cert.settingIssuer': 'Emesso da {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Valido fino al',
'native.cert.settingHint': "L'app mostra questo certificato solo a {host}, quando il suo proxy lo chiede.",
'native.cert.settingReplace': 'Sostituisci',
'native.cert.settingRemove': 'Rimuovi',
'native.cert.removeTitle': 'Rimuovere il certificato?',
'native.cert.removeMessage':
"Senza, il proxy davanti a {host} non fa più entrare l'app finché non ne aggiungi uno nuovo.",
};
export default native;
+31
View File
@@ -148,6 +148,37 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'アプリはこの入力に応答できません。プロキシで TREK を通過させてください。TREK は引き続き独自のログインを求めます。',
'native.gate.help': 'サインインプロキシの背後でアプリを使う',
'native.cert.title': 'このサーバーはクライアント証明書を要求しています',
'native.cert.titleRefused': 'サーバーが証明書を受け入れませんでした',
'native.cert.hint':
'前段のプロキシは証明書を提示した端末だけを通します。この端末用に作成した .p12 または .pfx ファイルを追加してください。アプリは {host} にだけ提示します。',
'native.cert.hintRefused':
'プロキシはアプリ内の証明書を信頼していないか、信頼しなくなりました。プロキシが求めるファイルを選んでください。',
'native.cert.choose': '証明書ファイルを追加',
'native.cert.chooseOther': '別の証明書を選ぶ',
'native.cert.help': 'クライアント証明書でアプリを使う',
'native.cert.passwordTitle': '証明書のパスワード',
'native.cert.passwordMessage': '証明書ファイルのパスワードを入力してください。アプリはパスワードを保存しません。',
'native.cert.passwordPlaceholder': 'パスワード',
'native.cert.passwordOk': '追加',
'native.cert.passwordCancel': 'キャンセル',
'native.cert.passwordWrong': 'このパスワードではファイルを開けません。もう一度お試しください。',
'native.cert.errorFormat': 'このファイルを読み込めません。証明書と秘密鍵を含む .p12 または .pfx ファイルが必要です。',
'native.cert.errorNoKey': 'このファイルには秘密鍵がありません。証明書を鍵と一緒に書き出してください。',
'native.cert.errorExpired': 'この証明書は有効期限が切れています。',
'native.cert.errorNotYetValid': 'この証明書はまだ有効ではありません。この端末の日付と時刻を確認してください。',
'native.cert.errorStore': '証明書を保存できませんでした。もう一度お試しください。',
'native.cert.unreachableTitle': '証明書が必要です',
'native.cert.unreachableHint': '{server} は信頼されたクライアント証明書を持つ端末だけを通します。',
'native.cert.settingTitle': 'クライアント証明書',
'native.cert.settingIssuer': '発行者: {issuer}',
'native.cert.settingServer': 'サーバー',
'native.cert.settingValidUntil': '有効期限',
'native.cert.settingHint': 'アプリはこの証明書を、{host} のプロキシが求めたときにだけ提示します。',
'native.cert.settingReplace': '置き換え',
'native.cert.settingRemove': '削除',
'native.cert.removeTitle': '証明書を削除しますか?',
'native.cert.removeMessage': '削除すると、新しい証明書を追加するまで {host} の前段のプロキシはアプリを通しません。',
};
export default native;
+33
View File
@@ -144,6 +144,39 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'앱은 이 요청에 응답할 수 없습니다. 프록시에서 TREK을 통과시키세요. TREK은 여전히 자체 로그인을 요청합니다.',
'native.gate.help': '로그인 프록시 뒤에서 앱 사용하기',
'native.cert.title': '이 서버는 클라이언트 인증서를 요구합니다',
'native.cert.titleRefused': '서버가 인증서를 받아들이지 않았습니다',
'native.cert.hint':
'앞단의 프록시는 인증서를 제시하는 기기만 통과시킵니다. 이 기기용으로 만든 .p12 또는 .pfx 파일을 추가하세요. 앱은 {host}에만 이 인증서를 제시합니다.',
'native.cert.hintRefused':
'프록시가 앱의 인증서를 신뢰하지 않거나 더 이상 신뢰하지 않습니다. 프록시가 요구하는 파일을 선택하세요.',
'native.cert.choose': '인증서 파일 추가',
'native.cert.chooseOther': '다른 인증서 선택',
'native.cert.help': '클라이언트 인증서로 앱 사용하기',
'native.cert.passwordTitle': '인증서 비밀번호',
'native.cert.passwordMessage': '인증서 파일의 비밀번호를 입력하세요. 앱은 비밀번호를 저장하지 않습니다.',
'native.cert.passwordPlaceholder': '비밀번호',
'native.cert.passwordOk': '추가',
'native.cert.passwordCancel': '취소',
'native.cert.passwordWrong': '이 비밀번호로는 파일을 열 수 없습니다. 다시 시도하세요.',
'native.cert.errorFormat':
'앱이 이 파일을 읽을 수 없습니다. 인증서와 개인 키가 들어 있는 .p12 또는 .pfx 파일이 필요합니다.',
'native.cert.errorNoKey': '이 파일에는 개인 키가 없습니다. 인증서를 키와 함께 내보내세요.',
'native.cert.errorExpired': '이 인증서는 만료되었습니다.',
'native.cert.errorNotYetValid': '이 인증서는 아직 유효하지 않습니다. 이 기기의 날짜와 시간을 확인하세요.',
'native.cert.errorStore': '앱이 인증서를 저장하지 못했습니다. 다시 시도하세요.',
'native.cert.unreachableTitle': '인증서 필요',
'native.cert.unreachableHint': '{server}는 신뢰할 수 있는 클라이언트 인증서가 있는 기기만 통과시킵니다.',
'native.cert.settingTitle': '클라이언트 인증서',
'native.cert.settingIssuer': '발급자: {issuer}',
'native.cert.settingServer': '서버',
'native.cert.settingValidUntil': '유효 기간',
'native.cert.settingHint': '앱은 {host}의 프록시가 요청할 때만 이 인증서를 제시합니다.',
'native.cert.settingReplace': '교체',
'native.cert.settingRemove': '삭제',
'native.cert.removeTitle': '인증서를 삭제할까요?',
'native.cert.removeMessage':
'인증서가 없으면 새 인증서를 추가할 때까지 {host} 앞의 프록시가 앱을 통과시키지 않습니다.',
};
export default native;
+33
View File
@@ -147,6 +147,39 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'De app kan deze vraag niet beantwoorden. Laat TREK er in je proxy langs: TREK vraagt nog steeds om zijn eigen aanmelding.',
'native.gate.help': 'De app achter een aanmeldproxy gebruiken',
'native.cert.title': 'Deze server vraagt om een clientcertificaat',
'native.cert.titleRefused': 'De server heeft het certificaat niet geaccepteerd',
'native.cert.hint':
'Een proxy ervoor laat alleen apparaten met een certificaat door. Voeg het .p12- of .pfx-bestand toe dat voor dit apparaat is gemaakt. De app toont het alleen aan {host}.',
'native.cert.hintRefused':
'De proxy vertrouwt het certificaat in de app niet (meer). Kies het bestand dat hij verwacht.',
'native.cert.choose': 'Certificaatbestand toevoegen',
'native.cert.chooseOther': 'Ander certificaat kiezen',
'native.cert.help': 'De app gebruiken met een clientcertificaat',
'native.cert.passwordTitle': 'Certificaatwachtwoord',
'native.cert.passwordMessage': 'Voer het wachtwoord van het certificaatbestand in. De app bewaart het niet.',
'native.cert.passwordPlaceholder': 'Wachtwoord',
'native.cert.passwordOk': 'Toevoegen',
'native.cert.passwordCancel': 'Annuleren',
'native.cert.passwordWrong': 'Dit wachtwoord opent het bestand niet. Probeer het opnieuw.',
'native.cert.errorFormat':
'De app kan dit bestand niet lezen. Er is een .p12- of .pfx-bestand met het certificaat en de privésleutel nodig.',
'native.cert.errorNoKey': 'Dit bestand bevat geen privésleutel. Exporteer het certificaat samen met de sleutel.',
'native.cert.errorExpired': 'Dit certificaat is verlopen.',
'native.cert.errorNotYetValid': 'Dit certificaat is nog niet geldig. Controleer de datum en tijd op dit apparaat.',
'native.cert.errorStore': 'De app kon het certificaat niet opslaan. Probeer het opnieuw.',
'native.cert.unreachableTitle': 'Certificaat nodig',
'native.cert.unreachableHint': '{server} laat alleen apparaten door met een vertrouwd clientcertificaat.',
'native.cert.settingTitle': 'Clientcertificaat',
'native.cert.settingIssuer': 'Uitgegeven door {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Geldig tot',
'native.cert.settingHint': 'De app toont dit certificaat alleen aan {host}, als de proxy erom vraagt.',
'native.cert.settingReplace': 'Vervangen',
'native.cert.settingRemove': 'Verwijderen',
'native.cert.removeTitle': 'Certificaat verwijderen?',
'native.cert.removeMessage':
'Zonder certificaat laat de proxy voor {host} de app niet meer door, tot je er weer een toevoegt.',
};
export default native;
+31
View File
@@ -147,6 +147,37 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Aplikacja nie może odpowiedzieć na tę prośbę. Przepuść TREK w swoim proxy: TREK nadal pyta o własne logowanie.',
'native.gate.help': 'Korzystanie z aplikacji za proxy logowania',
'native.cert.title': 'Ten serwer wymaga certyfikatu klienta',
'native.cert.titleRefused': 'Serwer nie przyjął certyfikatu',
'native.cert.hint':
'Proxy przed nim wpuszcza tylko urządzenia z certyfikatem. Dodaj plik .p12 lub .pfx utworzony dla tego urządzenia. Aplikacja pokazuje go tylko serwerowi {host}.',
'native.cert.hintRefused': 'Proxy nie ufa (już) certyfikatowi w aplikacji. Wybierz plik, którego oczekuje.',
'native.cert.choose': 'Dodaj plik certyfikatu',
'native.cert.chooseOther': 'Wybierz inny certyfikat',
'native.cert.help': 'Korzystanie z aplikacji z certyfikatem klienta',
'native.cert.passwordTitle': 'Hasło certyfikatu',
'native.cert.passwordMessage': 'Wpisz hasło pliku certyfikatu. Aplikacja go nie zapisuje.',
'native.cert.passwordPlaceholder': 'Hasło',
'native.cert.passwordOk': 'Dodaj',
'native.cert.passwordCancel': 'Anuluj',
'native.cert.passwordWrong': 'To hasło nie otwiera pliku. Spróbuj ponownie.',
'native.cert.errorFormat':
'Aplikacja nie może odczytać tego pliku. Potrzebny jest plik .p12 lub .pfx z certyfikatem i kluczem prywatnym.',
'native.cert.errorNoKey': 'Ten plik nie zawiera klucza prywatnego. Wyeksportuj certyfikat razem z kluczem.',
'native.cert.errorExpired': 'Ten certyfikat wygasł.',
'native.cert.errorNotYetValid': 'Ten certyfikat nie jest jeszcze ważny. Sprawdź datę i godzinę na tym urządzeniu.',
'native.cert.errorStore': 'Aplikacja nie mogła zapisać certyfikatu. Spróbuj ponownie.',
'native.cert.unreachableTitle': 'Wymagany certyfikat',
'native.cert.unreachableHint': '{server} wpuszcza tylko urządzenia z zaufanym certyfikatem klienta.',
'native.cert.settingTitle': 'Certyfikat klienta',
'native.cert.settingIssuer': 'Wystawca: {issuer}',
'native.cert.settingServer': 'Serwer',
'native.cert.settingValidUntil': 'Ważny do',
'native.cert.settingHint': 'Aplikacja pokazuje ten certyfikat tylko serwerowi {host}, gdy prosi o niego jego proxy.',
'native.cert.settingReplace': 'Zastąp',
'native.cert.settingRemove': 'Usuń',
'native.cert.removeTitle': 'Usunąć certyfikat?',
'native.cert.removeMessage': 'Bez niego proxy przed {host} nie wpuści aplikacji, dopóki nie dodasz nowego.',
};
export default native;
+33
View File
@@ -148,6 +148,39 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Приложение не может ответить на этот запрос. Пропустите TREK в вашем прокси: TREK по-прежнему просит собственный вход.',
'native.gate.help': 'Приложение за прокси со входом',
'native.cert.title': 'Этот сервер запрашивает клиентский сертификат',
'native.cert.titleRefused': 'Сервер не принял сертификат',
'native.cert.hint':
'Прокси перед ним пропускает только устройства с сертификатом. Добавьте файл .p12 или .pfx, созданный для этого устройства. Приложение показывает его только серверу {host}.',
'native.cert.hintRefused':
'Прокси не доверяет (или больше не доверяет) сертификату в приложении. Выберите файл, который он ожидает.',
'native.cert.choose': 'Добавить файл сертификата',
'native.cert.chooseOther': 'Выбрать другой сертификат',
'native.cert.help': 'Использование приложения с клиентским сертификатом',
'native.cert.passwordTitle': 'Пароль сертификата',
'native.cert.passwordMessage': 'Введите пароль файла сертификата. Приложение его не сохраняет.',
'native.cert.passwordPlaceholder': 'Пароль',
'native.cert.passwordOk': 'Добавить',
'native.cert.passwordCancel': 'Отмена',
'native.cert.passwordWrong': 'Этот пароль не открывает файл. Попробуйте ещё раз.',
'native.cert.errorFormat':
'Приложение не может прочитать этот файл. Нужен файл .p12 или .pfx с сертификатом и его закрытым ключом.',
'native.cert.errorNoKey': 'В этом файле нет закрытого ключа. Экспортируйте сертификат вместе с ключом.',
'native.cert.errorExpired': 'Срок действия этого сертификата истёк.',
'native.cert.errorNotYetValid': 'Этот сертификат ещё не действует. Проверьте дату и время на этом устройстве.',
'native.cert.errorStore': 'Приложению не удалось сохранить сертификат. Попробуйте ещё раз.',
'native.cert.unreachableTitle': 'Нужен сертификат',
'native.cert.unreachableHint': '{server} пропускает только устройства с доверенным клиентским сертификатом.',
'native.cert.settingTitle': 'Клиентский сертификат',
'native.cert.settingIssuer': 'Выдан: {issuer}',
'native.cert.settingServer': 'Сервер',
'native.cert.settingValidUntil': 'Действует до',
'native.cert.settingHint':
'Приложение показывает этот сертификат только серверу {host}, когда его прокси его запрашивает.',
'native.cert.settingReplace': 'Заменить',
'native.cert.settingRemove': 'Удалить',
'native.cert.removeTitle': 'Удалить сертификат?',
'native.cert.removeMessage': 'Без него прокси перед {host} не пропустит приложение, пока вы не добавите новый.',
};
export default native;
+32
View File
@@ -146,6 +146,38 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Aplikácia nedokáže na túto výzvu odpovedať. Nechajte TREK vo svojej proxy prejsť: TREK stále žiada vlastné prihlásenie.',
'native.gate.help': 'Používanie aplikácie za prihlasovacou proxy',
'native.cert.title': 'Tento server vyžaduje klientsky certifikát',
'native.cert.titleRefused': 'Server certifikát neprijal',
'native.cert.hint':
'Proxy pred ním púšťa len zariadenia s certifikátom. Pridaj súbor .p12 alebo .pfx vytvorený pre toto zariadenie. Aplikácia ho ukáže len serveru {host}.',
'native.cert.hintRefused':
'Proxy certifikátu v aplikácii nedôveruje alebo už nedôveruje. Vyber súbor, ktorý očakáva.',
'native.cert.choose': 'Pridať súbor certifikátu',
'native.cert.chooseOther': 'Vybrať iný certifikát',
'native.cert.help': 'Používanie aplikácie s klientskym certifikátom',
'native.cert.passwordTitle': 'Heslo certifikátu',
'native.cert.passwordMessage': 'Zadaj heslo súboru certifikátu. Aplikácia si ho neukladá.',
'native.cert.passwordPlaceholder': 'Heslo',
'native.cert.passwordOk': 'Pridať',
'native.cert.passwordCancel': 'Zrušiť',
'native.cert.passwordWrong': 'Toto heslo súbor neotvorí. Skús to znova.',
'native.cert.errorFormat':
'Aplikácia tento súbor nedokáže prečítať. Potrebuje súbor .p12 alebo .pfx s certifikátom a jeho súkromným kľúčom.',
'native.cert.errorNoKey': 'Tento súbor neobsahuje súkromný kľúč. Exportuj certifikát aj s kľúčom.',
'native.cert.errorExpired': 'Platnosť tohto certifikátu vypršala.',
'native.cert.errorNotYetValid': 'Tento certifikát ešte neplatí. Skontroluj dátum a čas na tomto zariadení.',
'native.cert.errorStore': 'Aplikácia nemohla certifikát uložiť. Skús to znova.',
'native.cert.unreachableTitle': 'Je potrebný certifikát',
'native.cert.unreachableHint': '{server} púšťa len zariadenia s dôveryhodným klientskym certifikátom.',
'native.cert.settingTitle': 'Klientsky certifikát',
'native.cert.settingIssuer': 'Vydal: {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Platný do',
'native.cert.settingHint': 'Aplikácia ukazuje tento certifikát len serveru {host}, keď oň jeho proxy požiada.',
'native.cert.settingReplace': 'Nahradiť',
'native.cert.settingRemove': 'Odstrániť',
'native.cert.removeTitle': 'Odstrániť certifikát?',
'native.cert.removeMessage': 'Bez neho proxy pred {host} aplikáciu nepustí, kým nepridáš nový.',
};
export default native;
+32
View File
@@ -147,6 +147,38 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Appen kan inte svara på den här frågan. Släpp förbi TREK i din proxy: TREK frågar fortfarande efter sin egen inloggning.',
'native.gate.help': 'Använda appen bakom en inloggningsproxy',
'native.cert.title': 'Den här servern begär ett klientcertifikat',
'native.cert.titleRefused': 'Servern godtog inte certifikatet',
'native.cert.hint':
'En proxy framför den släpper bara in enheter som visar ett certifikat. Lägg till .p12- eller .pfx-filen som skapats för den här enheten. Appen visar den bara för {host}.',
'native.cert.hintRefused': 'Proxyn litar inte (längre) på certifikatet i appen. Välj filen den förväntar sig.',
'native.cert.choose': 'Lägg till certifikatfil',
'native.cert.chooseOther': 'Välj ett annat certifikat',
'native.cert.help': 'Använda appen med ett klientcertifikat',
'native.cert.passwordTitle': 'Certifikatlösenord',
'native.cert.passwordMessage': 'Ange lösenordet för certifikatfilen. Appen sparar det inte.',
'native.cert.passwordPlaceholder': 'Lösenord',
'native.cert.passwordOk': 'Lägg till',
'native.cert.passwordCancel': 'Avbryt',
'native.cert.passwordWrong': 'Lösenordet öppnar inte filen. Försök igen.',
'native.cert.errorFormat':
'Appen kan inte läsa filen. Den behöver en .p12- eller .pfx-fil med certifikatet och dess privata nyckel.',
'native.cert.errorNoKey': 'Filen innehåller ingen privat nyckel. Exportera certifikatet tillsammans med nyckeln.',
'native.cert.errorExpired': 'Certifikatet har gått ut.',
'native.cert.errorNotYetValid': 'Certifikatet är inte giltigt än. Kontrollera datum och tid på enheten.',
'native.cert.errorStore': 'Appen kunde inte spara certifikatet. Försök igen.',
'native.cert.unreachableTitle': 'Certifikat krävs',
'native.cert.unreachableHint': '{server} släpper bara in enheter med ett betrott klientcertifikat.',
'native.cert.settingTitle': 'Klientcertifikat',
'native.cert.settingIssuer': 'Utfärdat av {issuer}',
'native.cert.settingServer': 'Server',
'native.cert.settingValidUntil': 'Giltigt till',
'native.cert.settingHint': 'Appen visar certifikatet bara för {host}, när dess proxy ber om det.',
'native.cert.settingReplace': 'Ersätt',
'native.cert.settingRemove': 'Ta bort',
'native.cert.removeTitle': 'Ta bort certifikatet?',
'native.cert.removeMessage':
'Utan det släpper proxyn framför {host} inte längre in appen förrän du lägger till ett nytt.',
};
export default native;
+30
View File
@@ -142,6 +142,36 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'แอปตอบคำขอนี้ไม่ได้ ให้ปล่อย TREK ผ่านในพร็อกซีของคุณ: TREK ยังคงขอการลงชื่อเข้าใช้ของตัวเอง',
'native.gate.help': 'การใช้แอปหลังพร็อกซีลงชื่อเข้าใช้',
'native.cert.title': 'เซิร์ฟเวอร์นี้ขอใบรับรองไคลเอนต์',
'native.cert.titleRefused': 'เซิร์ฟเวอร์ไม่ยอมรับใบรับรอง',
'native.cert.hint':
'พร็อกซีที่อยู่ด้านหน้าให้ผ่านเฉพาะอุปกรณ์ที่แสดงใบรับรอง เพิ่มไฟล์ .p12 หรือ .pfx ที่สร้างไว้สำหรับอุปกรณ์นี้ แอปจะแสดงใบรับรองนี้ให้ {host} เท่านั้น',
'native.cert.hintRefused': 'พร็อกซีไม่เชื่อถือหรือเลิกเชื่อถือใบรับรองในแอป เลือกไฟล์ที่พร็อกซีต้องการ',
'native.cert.choose': 'เพิ่มไฟล์ใบรับรอง',
'native.cert.chooseOther': 'เลือกใบรับรองอื่น',
'native.cert.help': 'การใช้แอปกับใบรับรองไคลเอนต์',
'native.cert.passwordTitle': 'รหัสผ่านใบรับรอง',
'native.cert.passwordMessage': 'ป้อนรหัสผ่านของไฟล์ใบรับรอง แอปจะไม่เก็บรหัสผ่านนี้',
'native.cert.passwordPlaceholder': 'รหัสผ่าน',
'native.cert.passwordOk': 'เพิ่ม',
'native.cert.passwordCancel': 'ยกเลิก',
'native.cert.passwordWrong': 'รหัสผ่านนี้เปิดไฟล์ไม่ได้ ลองอีกครั้ง',
'native.cert.errorFormat': 'แอปอ่านไฟล์นี้ไม่ได้ ต้องใช้ไฟล์ .p12 หรือ .pfx ที่มีใบรับรองและคีย์ส่วนตัว',
'native.cert.errorNoKey': 'ไฟล์นี้ไม่มีคีย์ส่วนตัว ส่งออกใบรับรองพร้อมคีย์',
'native.cert.errorExpired': 'ใบรับรองนี้หมดอายุแล้ว',
'native.cert.errorNotYetValid': 'ใบรับรองนี้ยังไม่มีผล ตรวจสอบวันที่และเวลาของอุปกรณ์นี้',
'native.cert.errorStore': 'แอปบันทึกใบรับรองไม่ได้ ลองอีกครั้ง',
'native.cert.unreachableTitle': 'ต้องมีใบรับรอง',
'native.cert.unreachableHint': '{server} ให้ผ่านเฉพาะอุปกรณ์ที่มีใบรับรองไคลเอนต์ที่เชื่อถือได้',
'native.cert.settingTitle': 'ใบรับรองไคลเอนต์',
'native.cert.settingIssuer': 'ออกโดย {issuer}',
'native.cert.settingServer': 'เซิร์ฟเวอร์',
'native.cert.settingValidUntil': 'ใช้ได้ถึง',
'native.cert.settingHint': 'แอปแสดงใบรับรองนี้ให้ {host} เท่านั้น เมื่อพร็อกซีของเซิร์ฟเวอร์ร้องขอ',
'native.cert.settingReplace': 'แทนที่',
'native.cert.settingRemove': 'ลบ',
'native.cert.removeTitle': 'ลบใบรับรองหรือไม่',
'native.cert.removeMessage': 'หากไม่มีใบรับรอง พร็อกซีหน้า {host} จะไม่ให้แอปผ่านจนกว่าคุณจะเพิ่มใบใหม่',
};
export default native;
+32
View File
@@ -147,6 +147,38 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Uygulama bu isteğe yanıt veremez. TREK’i proxy’nizde geçirin: TREK yine kendi girişini ister.',
'native.gate.help': 'Uygulamayı bir giriş proxy’sinin arkasında kullanma',
'native.cert.title': 'Bu sunucu bir istemci sertifikası istiyor',
'native.cert.titleRefused': 'Sunucu sertifikayı kabul etmedi',
'native.cert.hint':
'Önündeki proxy yalnızca sertifika gösteren cihazları içeri alıyor. Bu cihaz için oluşturulan .p12 veya .pfx dosyasını ekle. Uygulama onu yalnızca {host} sunucusuna gösterir.',
'native.cert.hintRefused': 'Proxy uygulamadaki sertifikaya güvenmiyor ya da artık güvenmiyor. Beklediği dosyayı seç.',
'native.cert.choose': 'Sertifika dosyası ekle',
'native.cert.chooseOther': 'Başka bir sertifika seç',
'native.cert.help': 'Uygulamayı istemci sertifikasıyla kullanma',
'native.cert.passwordTitle': 'Sertifika parolası',
'native.cert.passwordMessage': 'Sertifika dosyasının parolasını gir. Uygulama onu saklamaz.',
'native.cert.passwordPlaceholder': 'Parola',
'native.cert.passwordOk': 'Ekle',
'native.cert.passwordCancel': 'İptal',
'native.cert.passwordWrong': 'Bu parola dosyayı açmıyor. Tekrar dene.',
'native.cert.errorFormat':
'Uygulama bu dosyayı okuyamıyor. Sertifikayı ve özel anahtarını içeren bir .p12 veya .pfx dosyası gerekir.',
'native.cert.errorNoKey': 'Bu dosyada özel anahtar yok. Sertifikayı anahtarıyla birlikte dışa aktar.',
'native.cert.errorExpired': 'Bu sertifikanın süresi doldu.',
'native.cert.errorNotYetValid': 'Bu sertifika henüz geçerli değil. Bu cihazın tarih ve saatini kontrol et.',
'native.cert.errorStore': 'Uygulama sertifikayı kaydedemedi. Tekrar dene.',
'native.cert.unreachableTitle': 'Sertifika gerekli',
'native.cert.unreachableHint': '{server} yalnızca güvenilir bir istemci sertifikası olan cihazları içeri alıyor.',
'native.cert.settingTitle': 'İstemci sertifikası',
'native.cert.settingIssuer': 'Veren: {issuer}',
'native.cert.settingServer': 'Sunucu',
'native.cert.settingValidUntil': 'Geçerlilik sonu',
'native.cert.settingHint': 'Uygulama bu sertifikayı yalnızca {host} sunucusuna, proxy istediğinde gösterir.',
'native.cert.settingReplace': 'Değiştir',
'native.cert.settingRemove': 'Kaldır',
'native.cert.removeTitle': 'Sertifika kaldırılsın mı?',
'native.cert.removeMessage':
'Sertifika olmadan {host} önündeki proxy, yenisini ekleyene kadar uygulamayı içeri almaz.',
};
export default native;
+32
View File
@@ -147,6 +147,38 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Застосунок не може відповісти на цей запит. Пропустіть TREK у вашому проксі: TREK і далі просить власний вхід.',
'native.gate.help': 'Застосунок за проксі з входом',
'native.cert.title': 'Цей сервер запитує клієнтський сертифікат',
'native.cert.titleRefused': 'Сервер не прийняв сертифікат',
'native.cert.hint':
'Проксі перед ним пропускає лише пристрої з сертифікатом. Додайте файл .p12 або .pfx, створений для цього пристрою. Застосунок показує його лише серверу {host}.',
'native.cert.hintRefused':
'Проксі не довіряє (або вже не довіряє) сертифікату в застосунку. Виберіть файл, який він очікує.',
'native.cert.choose': 'Додати файл сертифіката',
'native.cert.chooseOther': 'Вибрати інший сертифікат',
'native.cert.help': 'Використання застосунку з клієнтським сертифікатом',
'native.cert.passwordTitle': 'Пароль сертифіката',
'native.cert.passwordMessage': 'Введіть пароль файлу сертифіката. Застосунок його не зберігає.',
'native.cert.passwordPlaceholder': 'Пароль',
'native.cert.passwordOk': 'Додати',
'native.cert.passwordCancel': 'Скасувати',
'native.cert.passwordWrong': 'Цей пароль не відкриває файл. Спробуйте ще раз.',
'native.cert.errorFormat':
'Застосунок не може прочитати цей файл. Потрібен файл .p12 або .pfx із сертифікатом і його закритим ключем.',
'native.cert.errorNoKey': 'У цьому файлі немає закритого ключа. Експортуйте сертифікат разом із ключем.',
'native.cert.errorExpired': 'Термін дії цього сертифіката минув.',
'native.cert.errorNotYetValid': 'Цей сертифікат ще не дійсний. Перевірте дату й час на цьому пристрої.',
'native.cert.errorStore': 'Застосунку не вдалося зберегти сертифікат. Спробуйте ще раз.',
'native.cert.unreachableTitle': 'Потрібен сертифікат',
'native.cert.unreachableHint': '{server} пропускає лише пристрої з довіреним клієнтським сертифікатом.',
'native.cert.settingTitle': 'Клієнтський сертифікат',
'native.cert.settingIssuer': 'Видано: {issuer}',
'native.cert.settingServer': 'Сервер',
'native.cert.settingValidUntil': 'Дійсний до',
'native.cert.settingHint': 'Застосунок показує цей сертифікат лише серверу {host}, коли його проксі про це просить.',
'native.cert.settingReplace': 'Замінити',
'native.cert.settingRemove': 'Видалити',
'native.cert.removeTitle': 'Видалити сертифікат?',
'native.cert.removeMessage': 'Без нього проксі перед {host} не пропустить застосунок, доки ви не додасте новий.',
};
export default native;
+32
View File
@@ -148,6 +148,38 @@ const native: TranslationStrings = {
'native.gate.basicHint':
'Ứng dụng không thể trả lời yêu cầu này. Hãy cho TREK đi qua proxy của bạn: TREK vẫn hỏi đăng nhập riêng của nó.',
'native.gate.help': 'Dùng ứng dụng sau một proxy đăng nhập',
'native.cert.title': 'Máy chủ này yêu cầu chứng chỉ máy khách',
'native.cert.titleRefused': 'Máy chủ không chấp nhận chứng chỉ',
'native.cert.hint':
'Proxy phía trước chỉ cho các thiết bị xuất trình chứng chỉ đi qua. Hãy thêm tệp .p12 hoặc .pfx được tạo cho thiết bị này. Ứng dụng chỉ xuất trình nó cho {host}.',
'native.cert.hintRefused': 'Proxy không tin hoặc không còn tin chứng chỉ trong ứng dụng. Hãy chọn tệp mà nó yêu cầu.',
'native.cert.choose': 'Thêm tệp chứng chỉ',
'native.cert.chooseOther': 'Chọn chứng chỉ khác',
'native.cert.help': 'Dùng ứng dụng với chứng chỉ máy khách',
'native.cert.passwordTitle': 'Mật khẩu chứng chỉ',
'native.cert.passwordMessage': 'Nhập mật khẩu của tệp chứng chỉ. Ứng dụng không lưu mật khẩu.',
'native.cert.passwordPlaceholder': 'Mật khẩu',
'native.cert.passwordOk': 'Thêm',
'native.cert.passwordCancel': 'Hủy',
'native.cert.passwordWrong': 'Mật khẩu này không mở được tệp. Hãy thử lại.',
'native.cert.errorFormat':
'Ứng dụng không đọc được tệp này. Cần một tệp .p12 hoặc .pfx chứa chứng chỉ và khóa riêng của nó.',
'native.cert.errorNoKey': 'Tệp này không chứa khóa riêng. Hãy xuất chứng chỉ cùng với khóa.',
'native.cert.errorExpired': 'Chứng chỉ này đã hết hạn.',
'native.cert.errorNotYetValid': 'Chứng chỉ này chưa có hiệu lực. Hãy kiểm tra ngày giờ trên thiết bị.',
'native.cert.errorStore': 'Ứng dụng không lưu được chứng chỉ. Hãy thử lại.',
'native.cert.unreachableTitle': 'Cần chứng chỉ',
'native.cert.unreachableHint': '{server} chỉ cho các thiết bị có chứng chỉ máy khách đáng tin cậy đi qua.',
'native.cert.settingTitle': 'Chứng chỉ máy khách',
'native.cert.settingIssuer': 'Cấp bởi {issuer}',
'native.cert.settingServer': 'Máy chủ',
'native.cert.settingValidUntil': 'Có hiệu lực đến',
'native.cert.settingHint': 'Ứng dụng chỉ xuất trình chứng chỉ này cho {host}, khi proxy của nó yêu cầu.',
'native.cert.settingReplace': 'Thay thế',
'native.cert.settingRemove': 'Gỡ bỏ',
'native.cert.removeTitle': 'Gỡ bỏ chứng chỉ?',
'native.cert.removeMessage':
'Không có chứng chỉ, proxy trước {host} sẽ không cho ứng dụng vào cho đến khi bạn thêm chứng chỉ mới.',
};
export default native;
+30
View File
@@ -140,6 +140,36 @@ const native: TranslationStrings = {
'native.gate.googleNote': '使用 Google 登入無法在 App 內進行。如果你的使用了它,請改為讓 TREK 繞過代理。',
'native.gate.basicHint': 'App 無法回應此提示。請在你的代理中放行 TREK:TREK 仍會要求它自己的登入。',
'native.gate.help': '在登入代理後使用 App',
'native.cert.title': '此伺服器要求用戶端憑證',
'native.cert.titleRefused': '伺服器未接受該憑證',
'native.cert.hint':
'伺服器前方的代理只放行出示憑證的裝置。請新增為此裝置建立的 .p12 或 .pfx 檔案。App 只會向 {host} 出示它。',
'native.cert.hintRefused': '代理不信任或不再信任 App 中的憑證。請選擇它要求的檔案。',
'native.cert.choose': '新增憑證檔案',
'native.cert.chooseOther': '選擇其他憑證',
'native.cert.help': '在 App 中使用用戶端憑證',
'native.cert.passwordTitle': '憑證密碼',
'native.cert.passwordMessage': '請輸入憑證檔案的密碼。App 不會儲存密碼。',
'native.cert.passwordPlaceholder': '密碼',
'native.cert.passwordOk': '新增',
'native.cert.passwordCancel': '取消',
'native.cert.passwordWrong': '此密碼無法開啟檔案。請再試一次。',
'native.cert.errorFormat': 'App 無法讀取此檔案。需要包含憑證及其私密金鑰的 .p12 或 .pfx 檔案。',
'native.cert.errorNoKey': '此檔案不含私密金鑰。請將憑證與金鑰一起匯出。',
'native.cert.errorExpired': '此憑證已過期。',
'native.cert.errorNotYetValid': '此憑證尚未生效。請檢查此裝置的日期和時間。',
'native.cert.errorStore': 'App 無法儲存憑證。請再試一次。',
'native.cert.unreachableTitle': '需要憑證',
'native.cert.unreachableHint': '{server} 只放行持有受信任用戶端憑證的裝置。',
'native.cert.settingTitle': '用戶端憑證',
'native.cert.settingIssuer': '簽發者:{issuer}',
'native.cert.settingServer': '伺服器',
'native.cert.settingValidUntil': '有效期限至',
'native.cert.settingHint': 'App 只在 {host} 的代理要求時向其出示此憑證。',
'native.cert.settingReplace': '取代',
'native.cert.settingRemove': '移除',
'native.cert.removeTitle': '要移除憑證嗎?',
'native.cert.removeMessage': '移除後,在你新增憑證之前,{host} 前方的代理將不再放行 App。',
};
export default native;
+30
View File
@@ -140,6 +140,36 @@ const native: TranslationStrings = {
'native.gate.googleNote': '使用 Google 登录无法在应用内进行。如果你的使用了它,请改为让 TREK 绕过代理。',
'native.gate.basicHint': '应用无法回应此提示。请在你的代理中放行 TREK:TREK 仍会要求它自己的登录。',
'native.gate.help': '在登录代理后使用应用',
'native.cert.title': '此服务器要求客户端证书',
'native.cert.titleRefused': '服务器未接受该证书',
'native.cert.hint':
'服务器前面的代理只放行出示证书的设备。请添加为此设备创建的 .p12 或 .pfx 文件。应用只会向 {host} 出示它。',
'native.cert.hintRefused': '代理不信任或不再信任应用中的证书。请选择它要求的文件。',
'native.cert.choose': '添加证书文件',
'native.cert.chooseOther': '选择其他证书',
'native.cert.help': '在应用中使用客户端证书',
'native.cert.passwordTitle': '证书密码',
'native.cert.passwordMessage': '请输入证书文件的密码。应用不会保存密码。',
'native.cert.passwordPlaceholder': '密码',
'native.cert.passwordOk': '添加',
'native.cert.passwordCancel': '取消',
'native.cert.passwordWrong': '此密码无法打开文件。请重试。',
'native.cert.errorFormat': '应用无法读取此文件。需要包含证书及其私钥的 .p12 或 .pfx 文件。',
'native.cert.errorNoKey': '此文件不含私钥。请将证书与私钥一起导出。',
'native.cert.errorExpired': '此证书已过期。',
'native.cert.errorNotYetValid': '此证书尚未生效。请检查此设备的日期和时间。',
'native.cert.errorStore': '应用无法保存证书。请重试。',
'native.cert.unreachableTitle': '需要证书',
'native.cert.unreachableHint': '{server} 只放行持有受信任客户端证书的设备。',
'native.cert.settingTitle': '客户端证书',
'native.cert.settingIssuer': '颁发者:{issuer}',
'native.cert.settingServer': '服务器',
'native.cert.settingValidUntil': '有效期至',
'native.cert.settingHint': '应用只在 {host} 的代理请求时向其出示此证书。',
'native.cert.settingReplace': '替换',
'native.cert.settingRemove': '移除',
'native.cert.removeTitle': '移除证书?',
'native.cert.removeMessage': '移除后,在你添加新证书之前,{host} 前面的代理将不再放行应用。',
};
export default native;
+56 -1
View File
@@ -14,6 +14,7 @@ The app checks that the address answers as a TREK server that knows the apps, an
- **Not TREK**: something answered, but not a TREK server.
- **Too old**: a TREK server from before the apps. Update it to a current release.
- **A sign-in page protects this server**: a proxy of your own stands in front of TREK. See the next section.
- **This server asks for a client certificate**: the proxy in front of TREK uses mutual TLS. See [Client certificates (mTLS)](#client-certificates-mtls).
No server yet? **Try the demo** connects to the public demo at demo.liketrek.com, which everybody shares and which is reset every hour. **What is a TREK server?** leads to the project page, which explains how to run your own.
@@ -43,7 +44,61 @@ What cannot work inside an app:
- **A password prompt** (Basic Auth). The app cannot answer it.
- **Header or service-token rules only**, without a sign-in page. The app cannot add headers to the requests the TREK interface makes.
For those, let TREK through the proxy instead: a bypass rule for TREK's address in Cloudflare Access, Pangolin, authentik or Authelia, or the password prompt removed for TREK. TREK's own sign-in still protects it. With Cloudflare Access, enrolling the phone in Cloudflare WARP also lets the app through.
For those, let TREK through the proxy instead: a bypass rule for TREK's address in Cloudflare Access, Pangolin, authentik or Authelia, or the password prompt removed for TREK. TREK's own sign-in still protects it. With Cloudflare Access, enrolling the phone in Cloudflare WARP also lets the app through. To keep TREK closed to everyone but your own devices without a sign-in page, use a client certificate instead.
## Client certificates (mTLS)
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.
**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.
**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**.
### Making a certificate for the app
With your own certificate authority (`ca.pem` and `ca.key`):
```bash
openssl req -newkey rsa:2048 -nodes -keyout phone.key -out phone.csr -subj "/CN=my-phone"
printf 'extendedKeyUsage=clientAuth\n' > client.ext
openssl x509 -req -in phone.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 730 -extfile client.ext -out phone.pem
openssl pkcs12 -export -inkey phone.key -in phone.pem -out phone.p12
```
Copy `phone.p12` to the phone and add it in the app. Give every device its own certificate, so you can revoke one without the others. If the app says it cannot read the file, export it again with the older encryption that some systems still expect: `openssl pkcs12 -export -legacy -inkey phone.key -in phone.pem -out phone.p12`.
### Cloudflare
1. **SSL/TLS → Client Certificates → Create Certificate**, and under **Hosts** add TREK's hostname.
2. A WAF custom rule that blocks requests to that hostname when `cf.tls_client_auth.cert_verified` is false.
3. Cloudflare shows the certificate and its private key as PEM. Save them as `phone.pem` and `phone.key` and make the file for the app with `openssl pkcs12 -export -inkey phone.key -in phone.pem -out phone.p12`.
Without a certificate Cloudflare answers with its block page; the app recognises that and asks for the certificate.
### nginx, Caddy, Traefik
```nginx
ssl_client_certificate /etc/nginx/trek-ca.pem;
ssl_verify_client on;
```
```caddy
trek.example.com {
tls {
client_auth {
mode require_and_verify
trust_pool file /etc/caddy/trek-ca.pem
}
}
reverse_proxy localhost:3000
}
```
In Traefik, a TLS option with `clientAuth.caFiles` and `clientAuthType: RequireAndVerifyClientCert`, attached to TREK's router.
A browser on your computer needs the same certificate, imported into the browser or the system keychain; it asks which one to use on the first visit.
## Plain http, the home network and VPNs