Skip to content

Taranac MFA

Taranac MFA is the second factor the project builds itself, rather than renting. It is two halves: a service you run — its own container, its own database, its own encryption key — and a phone app your users install. Between them there is no third party holding anything: no publisher backend to sign in to, no tenant to provision, no shared secret to leak. Firebase, where you switch it on, is a delivery van and nothing more — it cannot approve anything, because an approval is only valid signed by a key that never left the phone.

It is one of five MFA providers Taranac supports. This page is about that one, end to end, because it is the only one where both sides are ours and the whole path can be described honestly.

Two things make it worth the trouble of hosting:

  • It survives losing Google. Push arrives over Firebase when Firebase is available, over the app’s own polling when it is not, and if there is no network at all the same enrollment still produces a six-digit offline code. A phone with no Google services is a first-class citizen, not a degraded one.
  • Approvals are signed in both directions. The challenge is signed by the server; the answer is signed by the phone, with a key generated on the device that never leaves it. Neither side holds a secret the other could lose.

The service is deliberately shaped so it can live in a DMZ. It exposes one inbound port — 8443, HTTPS — and opens no connection back into your platform. The Taranac core is always the client; the phone reaches the service from the internet. Nothing about that arrangement requires a hole pointing inward.

Every arrow points away from the trusted network. The core calls out to the MFA service; the phone calls in to the same port. The service never initiates anything toward the platform.

What the service keeps to itself:

Its own databasetaranac_mfa — four tables: tokens, token info, owners, challenges
Its own encryption key96 bytes, three AES-256 keys. Deliberately not the platform’s MASTER_KEY — losing one does not expose the other
Only UUIDsIt knows users by identifier. Usernames reach it only as the cosmetic label printed on a QR code

How the core reaches it. Three settings on Integrations → MFA: mfa.taranac_mfa.base_url (default https://taranac-mfa:8443), mfa.taranac_mfa.api_key — sent as an X-API-Key header on every call, and held encrypted — and mfa.taranac_mfa.verify_tls, on by default. Verification trusts the CA certificate at the path in TARANAC_MFA_CA_CERT (/opt/taranac/mfa-certs/ca.crt unless you say otherwise), which the MFA container shares out through a volume; if that file is not there, the system trust store is used instead — which is what lets a DMZ host with a real certificate work with no extra configuration. Turn verify_tls off only for a self-signed endpoint inside a network you trust.

A Flutter application, version 1.0.0+6, shipping on Android (7.0 and newer). The UI is translated into seven languages — English, German, Spanish, French, Portuguese, Russian and Ukrainian — with full parity across all of them.

The app's account list: four accounts, three marked TOTP and one marked Push
Accounts. A Taranac account carries a Push badge and shows which server it belongs to.
An account's detail screen showing a six-digit fallback code and a countdown ring
The same account, offline. The app calls it a fallback code — one enrollment, both ways in.
Settings: app protection, biometrics, lock timeout, cloud backup and language
Settings. App lock and biometrics are opt-in; encrypted backup to the user’s own platform account is on by default.

A single QR code enrolls both a push identity and a TOTP secret. That is the design decision that makes the rest of it work: the user never sets up a “backup method”, because the backup was always there.

It takes two round trips, and the phone is only in the second one:

  1. Taranac asks for a token. The core calls POST /token/init with the user’s UUID; the service mints a serial (TMFA + eight hex characters) and a one-time enrollment credential, parks the token in clientwait, and hands back the QR. Starting a new enrollment deletes that user’s earlier incomplete ones, so a stale QR can never be finished later. Active enrollments are left alone — the swap to a new one is gap-free.
  2. The phone finishes it. The app posts the serial, the credential and its own public key to /ttype/push — plus an FCM token if it has one. The service compares the credential in constant time, spends it, generates its own RSA-4096 keypair, marks the token enrolled, and answers with its public key and the TOTP secret. That answer is where the offline code comes from; it is not a second enrollment.
  3. The user confirms once — one approved push, or one typed code — and Taranac marks the enrollment active.
The server picks between Firebase and polling per request, from whether Firebase is actually working and whether that token has an FCM token at all. The user is never asked, and the offline code sits underneath both.

Three consequences worth stating plainly:

  • A phone with no Google services enrolls poll-only and works. It is not refused, and the user is not told to go and install anything.
  • If Firebase stops working server-side, everyone falls back to polling — the signed challenge payload is stored for every request regardless of channel, so there is nothing to switch on.
  • Polling runs only while the app is open, every 5 seconds. That is the honest cost of the tier: a poll-only user has to open the app when they log in.

The challenge tells the user what they are approving — which service, which device, which account, from which address — because “approve this login?” with no context is how people are trained to tap yes.

A push challenge showing TACACS+, the target device, the user and the source address, with a countdown
A TACACS+ login to a switch. The icon and label come from the service that raised it — TACACS+, RADIUS, web login, 802.1X or captive portal.
A push challenge with no context fields, showing only the request serial and a countdown
The same screen when the raising service sends no context. The serial is always shown, so a user can match the prompt to the request.

The countdown is real: a challenge lives 120 seconds. When it runs out the app stops offering the buttons and says so, and the server rejects a late answer with a 410. Re-triggering inside 10 seconds reuses the same challenge and sends nothing, rather than firing a second notification at the user; past that, the same challenge is signed and delivered again — the nonce and the transaction survive, so an approval already on its way is never orphaned by a resend.

Two signatures, opposite directions, no shared secret. The phone’s private key is generated on the device after the QR is scanned and never leaves it.
  • The QR carries one secret, and only until it is used. It holds a URL, a serial and a one-time enrollment credential — and that credential is spent by the first step 2 that presents it, so photographing the code off someone’s screen after they have enrolled yields nothing. Before that it is live: the ttl printed in the QR is advice to the app, not a server-side expiry. What bounds it instead is that the next enrollment the user starts deletes this one, and an enrollment left unfinished is purged after 24 hours. Treat an unscanned QR as a credential on screen, not as a picture.
  • The phone generates RSA-4096 on the device and sends only the public half. The server keeps that; it never has the private key to lose. It also refuses anything that is not RSA or is below 2048 bits, at the moment the key is bound rather than the first time an approval matters. The server sends its own public key back, so the phone can reject a challenge that is not really from the server. Both directions sign with RSA-PSS over SHA-256.
  • The URL is inside the server’s signature, so a man-in-the-middle cannot redirect an approval somewhere else without breaking it.
  • Every other operation is signed too — polling for pending challenges, liveness checks, FCM token updates, deletion — each over a fresh ISO-8601 timestamp that must land within 120 seconds of the server’s clock, so a captured request cannot be replayed later. Deletion is the one that makes this matter: it is irreversible.

Rate limits are per token serial, not per address, so one user cannot exhaust another’s budget — a floor full of phones behind one NAT egress does not share a bucket, and each class of operation (approve/deny, polling, liveness, management) gets its own. The ban that follows repeated bad signatures — three in a row, five minutes — is keyed on the client address instead, precisely because the serial is not a secret and keying the ban on it would let anyone lock out a stranger’s phone.

MFA is not bolted onto one entry point. Each path has its own shape, and the differences are protocol facts rather than choices:

PathHow the second factor is asked forNotes
Web admin UIPush or code, in a dedicated stepThe login returns no session tokens until the factor verifies
TACACS+Code appended to the password, or pushTACACS+ has no way to ask a second question — with a combined enrollment, Taranac tries the appended code first, then falls back to push
RADIUS — PAPCode appended to the password, or push — and it can also ask with a real Access-ChallengeThe full path: every mode is available here
RADIUS — MS-CHAPv2Push onlyThe packet carries a hash, not a password, so there is nothing to append a code to. This is the transport that MikroTik administrators arrive on
RADIUS — CHAPNot supportedCHAP needs a cleartext password on the server; Taranac stores argon2id hashes
802.1X / captive portalPush onlyPEAP and EAP-TTLS cannot prompt, EAP-TLS has no password, MAB has no user at all

Requiring MFA is layered, and all three layers must agree. A user’s enrollment decides which factor; a group’s mfa_required flag decides whether; and an AAA policy rule or NAC rule decides where. Each daemon then has a master switch above all of that.

That last one is the trap worth knowing: if a rule requires MFA but the daemon’s master switch is off, the login succeeds without a second factor and the daemon logs a warning. It is the one place in the design that fails open, and it fails open on configuration, never on an outage.

Everything else fails closed. If the MFA service is unreachable, if a challenge cannot be created, if a poll times out — TACACS+, RADIUS, 802.1X and the web UI all reject. There is no “let them in, the service is down” path.

Health. The service reports db, crypto and firebase separately, and Firebase being absent is not unhealthy — poll-only is a fully supported state. The distinction is wired into the two probes: /healthz answers 200 for as long as the process can answer at all and puts the verdict in the body, while /readyz returns 503 when the database or the crypto subsystem is down — and never for a missing Firebase key, because an orchestrator acting on that would restart a perfectly working poll-only deployment forever. A backend watchdog checks every enabled provider every 90 seconds and raises a mfa:<provider> alert when one degrades.

Tokens expire from disuse, and that is deliberate. An enrolled token untouched for 90 days is deleted by the service’s own sweep, as is an enrollment that never finished step 2 after 24 hours. Any login or any liveness check from the app resets the clock — and because push and the fallback code share one token, either keeps it alive. A user who comes back after a long absence therefore finds the token gone; Taranac drops the now-dangling enrollment and asks them to re-enroll rather than pushing at something that no longer exists. One more bound worth knowing: the service caps a user at five tokens.

Under HA, the MFA service runs on every node and its encryption key is part of the cluster secret set, so a token enrolled against one node verifies on all of them. One caveat to handle deliberately: the push registration URL is still node-scoped. Point it at a cluster name or VIP rather than a single node’s address, or enrollment links will stop working when that node does.

The app is not on Google Play. That is a funding matter, and it is worth being straight about it: publishing costs money, Taranac is free and self-hosted, and the project does not currently have it. Everything else for the listing is finished — the store texts in seven languages, the privacy policy, the data-safety answers, the screenshots — and it will go up when the account can be paid for. Until then the build is distributed as an APK you install directly.

Download the APK 72.8 MB
File
taranac-mfa-1.0.0.apk · Android 7.0 and newer
SHA-256
b06994523e147fbc8119e0291b2b711173c163d249cd334ba1c1c22f74f0b28d
Signing certificate
1480d2345359c71d5b3e1b444bf0eceb8188dbc270f20a9bb6a8e18e70b338d2

Check both numbers before you install it. The first says you got the file intact; the second says this project built it. A matching checksum on a file signed by somebody else is worth nothing, which is why the certificate digest is published too:

Terminal window
sha256sum taranac-mfa-1.0.0.apk # → the SHA-256 above
apksigner verify --print-certs taranac-mfa-1.0.0.apk # → the certificate digest above

The checksum is also in SHA256SUMS alongside the appliance images.

Two consequences of installing outside a store, and neither is hidden:

  • A directly-installed APK does not update itself. There is no store channel to push a new version down, so come back here when a release is announced.
  • Android will ask you to allow installing from this source, once, for the browser or file manager you use. That prompt is the platform doing its job; the checks above are how you make it a safe yes.

If you would like this to change sooner, supporting the project is what pays for the listing.

  • iOS is not available. Same codebase, untested, unsigned, unreleased.
  • Polling only runs while the app is open. On a phone without Google services, a login means opening the app.
  • Instant push travels through Google. Where Firebase delivery is switched on, the notification goes out as an FCM data message — and the context the screen shows (the service, the target device, the username, the source address) is in that message, readable by the transport. The approval cannot be forged there, because only the phone’s signature settles it; the context is simply visible in transit. Poll-only delivery never touches Google at all.
  • The app lock is a convenience, not a vault. A PIN protects against someone picking up an unlocked phone; it is not resistance against an attacker who can extract the phone’s storage.
  • Secrets are held by the platform’s secure storage — encrypted under a key held in the Android Keystore or the iOS Keychain. That is strong, and it is not the same claim as the seeds themselves living inside a hardware element.
  • Encrypted cloud backup is on by default — to the user’s own Google or Apple account, never to us — which is what stops a lost phone becoming a lost authenticator. Users who would rather not can turn it off in Settings.