Skip to content

feat(android): authenticate with a managed X.509 identity - #14741

Open
thomaseizinger wants to merge 84 commits into
mainfrom
feat-x509-android-identity
Open

feat(android): authenticate with a managed X.509 identity#14741
thomaseizinger wants to merge 84 commits into
mainfrom
feat-x509-android-identity

Conversation

@thomaseizinger

@thomaseizinger thomaseizinger commented Aug 19, 2026

Copy link
Copy Markdown
Member

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:

  1. We allow setting the x509CertificateAlias key 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.
  2. If the alias is set via MDM but attempting to retrieve its key returns 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.
  3. The same certificate selection is available from the X.509 screen later on.

@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from d61d7f0 to a985ed2 Compare August 19, 2026 04:55
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 67efa0c to 5b16e1c Compare August 19, 2026 05:03
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from a985ed2 to d2153ea Compare August 19, 2026 05:04
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 5b16e1c to 0fadd36 Compare August 19, 2026 07:12
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch 2 times, most recently from e8cb92d to a078e2d Compare August 19, 2026 07:46
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 0fadd36 to 3b5773b Compare August 19, 2026 08:13
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from a078e2d to e45c923 Compare August 19, 2026 08:14
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 3b5773b to c6c616b Compare August 19, 2026 08:31
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from e45c923 to 5042d9a Compare August 19, 2026 08:31
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from c6c616b to 10cba16 Compare August 19, 2026 08:41
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from 5042d9a to 6ae7482 Compare August 19, 2026 08:42
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 10cba16 to e9cce39 Compare August 19, 2026 09:03
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from 6ae7482 to 9dc4f21 Compare August 19, 2026 09:04
@coveralls

coveralls commented Aug 19, 2026

Copy link
Copy Markdown

Coverage Status

Coverage is 77.138%feat-x509-android-identity into client-ffi-tls-identity. No base build found for client-ffi-tls-identity.

@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from e9cce39 to ea8bc57 Compare August 20, 2026 00:39
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from a845962 to a092d7f Compare August 20, 2026 00:40
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch 2 times, most recently from 59ac906 to 9787c89 Compare August 20, 2026 00:54
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from 01315a3 to 72c8e32 Compare August 20, 2026 01:05
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 9787c89 to 74e690c Compare August 20, 2026 01:41
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch 3 times, most recently from 84e3e9f to 30199be Compare August 20, 2026 03:14
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 74e690c to 997afea Compare August 20, 2026 06:26
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from 28d59d7 to 52c3251 Compare August 20, 2026 06:26
@thomaseizinger
thomaseizinger force-pushed the client-ffi-tls-identity branch from 997afea to 856abcd Compare August 20, 2026 13:59
@thomaseizinger
thomaseizinger force-pushed the feat-x509-android-identity branch from 52c3251 to 5d4c9c3 Compare August 20, 2026 13:59
@jamilbk

jamilbk commented Aug 20, 2026

Copy link
Copy Markdown
Member

@thomaseizinger I think we might be able to achieve zero-touch cert selection here even on personal devices actually:

  1. Add a new certificate_alias managed configuration key
  2. Have MDM populate this with the SCEP certificate alias
  3. Read this key when connecting

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.

@thomaseizinger

Copy link
Copy Markdown
Member Author

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.

There are two requirements for zero-touch:

  • Knowing the certificate alias
  • Having the key granted to the app

The prompt that Android shows on selecting the key happens when the latter is not in place.

claude and others added 30 commits August 28, 2026 06:51
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants