feat(android): authenticate with a managed X.509 identity - #14741
Open
thomaseizinger wants to merge 84 commits into
Open
feat(android): authenticate with a managed X.509 identity#14741thomaseizinger wants to merge 84 commits into
thomaseizinger wants to merge 84 commits into
Conversation
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 19, 2026 04:55
d61d7f0 to
a985ed2
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 19, 2026 05:03
67efa0c to
5b16e1c
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 19, 2026 05:04
a985ed2 to
d2153ea
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 19, 2026 07:12
5b16e1c to
0fadd36
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
2 times, most recently
from
August 19, 2026 07:46
e8cb92d to
a078e2d
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 19, 2026 08:13
0fadd36 to
3b5773b
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 19, 2026 08:14
a078e2d to
e45c923
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 19, 2026 08:31
3b5773b to
c6c616b
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 19, 2026 08:31
e45c923 to
5042d9a
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 19, 2026 08:41
c6c616b to
10cba16
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 19, 2026 08:42
5042d9a to
6ae7482
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 19, 2026 09:03
10cba16 to
e9cce39
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 19, 2026 09:04
6ae7482 to
9dc4f21
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 20, 2026 00:39
e9cce39 to
ea8bc57
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 20, 2026 00:40
a845962 to
a092d7f
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
2 times, most recently
from
August 20, 2026 00:54
59ac906 to
9787c89
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 20, 2026 01:05
01315a3 to
72c8e32
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 20, 2026 01:41
9787c89 to
74e690c
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
3 times, most recently
from
August 20, 2026 03:14
84e3e9f to
30199be
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 20, 2026 06:26
74e690c to
997afea
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 20, 2026 06:26
28d59d7 to
52c3251
Compare
thomaseizinger
force-pushed
the
client-ffi-tls-identity
branch
from
August 20, 2026 13:59
997afea to
856abcd
Compare
thomaseizinger
force-pushed
the
feat-x509-android-identity
branch
from
August 20, 2026 13:59
52c3251 to
5d4c9c3
Compare
Member
|
@thomaseizinger I think we might be able to achieve zero-touch cert selection here even on personal devices actually:
So essentially the MDM resolves the alias and stores it in our app's managed configuration for us to read without user intervention. I still need to test this in using Intune. |
Member
Author
There are two requirements for zero-touch:
The prompt that Android shows on selecting the key happens when the latter is not in place. |
The row was aligned three different ways across the clients and none of them centred: a baseline puts a circular glyph above the text's centre, a bare flex stretches, and a Compose Row starts at the top.
The identity was handed to connlib whatever the certificate said, so an expired one dialled the portal's mTLS host and failed the handshake rather than falling back to the saved sign-in token.
A certificate the parser cannot read failed no rule, so it counted as usable: connlib was handed it and the settings screen said Firezone would present it. Report the parse failure as its own rule instead, so it reaches the portal the same way every other unusable certificate does, by not being presented at all.
The other clients all mock the same user, so the screenshot fixtures should not invent a second one.
A field the parser refuses to attest stays on the certificate, so the screen was overstating what happens to it. Each rejection now names itself underneath the value, without a prefix wrapping it.
The claim is on the certificate either way, so saying it is not an email address reads as if the attribute were something else entirely. What the parser refused is the value.
The screenshot fixtures wrote the organisation with a trailing period and the previews still named a second user, so the same certificate read differently depending on which one drew it. Both now name one user and one organisation, spelled the way the rest of the clients spell them.
A certificate whose bytes will not parse now reaches the settings screen as one Firezone will not present, and no capture showed what the reader is told when that happens.
The parser now refuses a certificate that names a user it cannot resolve, so the X.509 screen needs a sentence for that rule and the row test needs a fixture that names one it can.
The screens were deriving who to connect as from an email that only a complete user identity carried, which read a service account and a refused identity alike as nobody and sent both through the browser. The parser already answers that question, so every control that starts or ends a session is worded from its verdict: a certificate that names somebody connects over mutual TLS, one whose name the parser will not attest says so and connects nothing, and only a certificate that claims no identity still signs in with a token.
The portal accepts every client certificate at the TLS layer on purpose and validates it at the application layer, where it can answer with a reason an administrator can act on. Withholding one that fails our own rules meant never asking for that answer, and connecting with a token the administrator did not configure instead. The rules a certificate fails stay on the X.509 settings screen as diagnostics.
The portal accepts every client certificate at the TLS layer and decides at the application layer whether it authenticates anybody, so any rule the client applies first can only withhold a certificate the portal would have taken. The identity is now just absent or claimed, which leaves no local refusal to raise a dialog about, and the X.509 screen presents what was read instead of judging it. Bytes that will not parse still have nothing to present, so that stays a read failure the screen reports as such.
The parser no longer wraps a claim rejection in a one-variant enum, so the screen reads it directly rather than matching the wrapper.
Every certificate rejection the portal sends already ends with "Please contact your administrator.", so appending our own copy said it twice. The notification now shows the message it was given and nothing more.
`ClaimValue` collapsed into an `Option<String>`, so a row's value reaches Kotlin as a plain `String?`, and `RejectionReason` is now `ValidationError`. The screen follows that vocabulary: nothing on the client rejects or attests a value, it only reports that the text the certificate gave a claim is not usable as it. Whether the certificate is accepted stays the portal's answer.
Disconnect notifications now carry connlib's own message, which for a rejected certificate is the reason the portal gave. Branching on the certificate to show that same title and text a second time is redundant, and `isCertificateError` no longer exists to branch on.
KeyChain treats the alias we pass as a pre-selection, so the user can grant access to a different certificate. Treating any non-null answer as success declared the configured certificate released when it was not.
An X509IdentityException without a message rendered its body as "null Contact your administrator for support.".
An empty value already turns certificate authentication off for a managed configuration; a user-set alias now reads the same way.
The TLS identity is built from the KeyChain chain and key before the claims parser runs, so a leaf we cannot read still has everything the handshake needs. Withholding it turned a local parser limitation into a refusal to authenticate. Parsing is there to describe the certificate, not to decide whether the portal gets to see it.
A `LoadedX509Identity` only exists once the KeyChain has released the chain and the key, so the client presents that certificate whether or not the parser could read its claims. Reporting `Identity.Absent` for one it could not read sent the user to a browser sign-in instead of connecting with the certificate.
A screenshot of the sign-in screen needs the same label the fragment computes. Copying the logic into the test would let the two drift, and the picture would then show a button the client never draws.
The gallery only showed the screen with nothing loaded, where the button reads "Sign In". With a usable certificate it offers to connect as whoever that certificate claims, which is now on record too.
The fixture overrode only `notAfter`, so it inherited a `Not Before` a year after its `Not After` and described a certificate that stops being valid before it starts.
`isManaged` only gates the Select and Forget buttons, which sit below thirteen rows of detail, so the managed capture was byte-identical to the unmanaged one and recorded nothing. Both captures now scroll to the end of the rows, where an administrator's certificate offers the reader nothing and a self-selected one offers Forget.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Android devices under MDM can be issued an X.509 client identity into the system KeyChain, where the private key is not exportable and may be hardware-backed. Such a device should present that identity to the portal rather than sending the user through a browser sign-in.
Detecting that such a certificate is present is unfortunately rather difficult on Android.
The first hurdle is that for privacy reasons, Android does not allow enumerating the installed certificates. Instead, we can either attempt to get a handle to a certificate via a certificate alias or present the user with a prompt to choose from a list of certificates (which then returns us the alias).
The second issue is that a certificate can be installed but not granted to the app. In such a case, even when knowing such a certificate alias, attempting to obtain a handle to it for signing returns
null.This PR implements the following UX flow to work around these two constraints:
x509CertificateAliaskey via MDM or shared preferences. If present, we will use it for mTLS with the portal. If the certificate includes attributes identifying the user, the sign-in flow is skipped entirely, otherwise the user is still required to obtain a token in addition.null, we assume that this is because the certificate has not been granted to the app. In that case, we present an additional splash screen on startup that prompts the user to select the correct certificate. Doing so creates the grant. Such a grant can also be provisioned via MDM.