Sheet POR-09 — Identity Manual
Mobile Authenticator & Recovery
Authenticator enrollment, recovery posture, and the human workflow around stronger sign-in assurance.
Scope
Strong authentication only works when enrollment, recovery, and support workflows are clear to the people who administer them. This guide keeps the operator-facing identity assurance model and removes the low-level implementation detail used by the internal product manual.
Current Scope
Portal now ships two first-party native app projects:
ios/PortalAuthenticatorandroid/PortalAuthenticator
Both apps now truthfully ship the same narrow mobile slice:
- local manual TOTP entry, edit, delete, and QR import
- local rolling-code display and on-device vault persistence
- optional local biometric or device-auth gate over already-stored app state
- fixed Cadres development Portal web and API endpoints
- generate and keep a device-bound Ed25519 key on the device
- complete the browser-issued linked-device challenge
- inventory linked devices
- revoke linked devices
- load a narrow governance approvals inbox and detail
- approve or deny governance access requests through the linked-device action-challenge flow
- (Android only, and iOS pre-Public-Vault builds) report public-vault readiness and blocker state as groundwork only
Portal’s backend now also ships the next mobile-safe workflow contract:
- governance requester catalog, submit, history, and detail facades
- governance approval and access-review read facades
- support-access read facades
- device-authenticated action challenges for governance, access-review, and support-access decisions
The current native apps consume only the governance approval subset of those workflow routes. Governance requester UI, access-review UI, and support-access UI remain non-shipped mobile scope.
Portal also now ships the mobile push delivery channel (IU-012-S01 / G-02):
- APNs (iOS) / FCM (Android) credential registration on the linked device
- transactional outbox +
MobilePushDispatchWorkerthat drains pending pushes -
number-matching code displayed in the push prompt and bound into the signed challenge payload — the user must compare the code with the browser and complete the in-app signed proof; the notification alone cannot approve, deny, or revoke
-
provider-disabled and credential-missing states fall back to the existing pull inbox without losing the underlying challenge
What this wave still does not ship:
-
live APNs/FCM delivery without operator-configured Apple/Google credentials (the code path is in place; live evidence requires APNs key and FCM service account in the deployment environment)
-
(Android) public-vault account bootstrap, sync, backup, or recovery — iOS ships this; see Public Vault (Personal Backup) — iOS
-
governance requester UI, access-review UI, or support-access UI
- mobile session review or revoke
Public Vault (Personal Backup) — iOS
ios/PortalAuthenticator’s free personal Authenticator tab (Settings →
Vault Backup & Recovery) ships a real, end-to-end-encrypted backup
account — deliberately a separate identity system from enterprise Portal
accounts, so it can offer genuine self-service deletion (Apple 5.1.1(v))
without depending on tenant/employer management:
- sign up with email + a passkey (no employer or Portal tenant required)
-
a mandatory recovery key, shown once with an explicit “I saved this” confirmation, as the backstop if every enrolled device is lost
-
live device-to-device enrollment — a new device shows a QR code, an already-trusted device scans and approves it, wrapping the vault key for the new device’s public key over ECDH (no plaintext key ever touches the server)
-
a “Your codes aren’t backed up” banner on the Authenticator tab that encrypts and pushes local entries with one tap
-
self-service account deletion, separate from the existing enterprise “unlink this authenticator” action
The backend never sees plaintext vault keys or plaintext authenticator secrets — only opaque, client-encrypted blobs. See ceremony and RLS isolation model. Not yet shipped: pulling synced ciphertext back into local entries on a second device (today’s sync pushes local entries out; the read-side merge is still open), and Android parity.
Pair A Device
Portal ships a native browser pairing UI at Profile → Mobile Devices → Pair Mobile Device (the relevant workflow). No browser DevTools or manual API calls are required.
Workflow:
- Sign in to Portal in your browser.
- Navigate to Profile → Mobile Devices → click Pair Mobile Device.
- A QR code, Pairing ID, and Short code appear (all valid for 5 minutes). The QR carries a high-entropy one-time secret in addition to the visible fields; manual entry needs both the Pairing ID and the Short code so that wrong attempts can be attributed to your pairing row.
- Open either native app:
- iOS: run
ios/PortalAuthenticatorfrom Xcode on an iPhone or simulator - Android: openandroid/PortalAuthenticatorin Android Studio and run it on an Android 13+ device or emulator, or use the checked-in Gradle wrapper from the repo root - In the app, tap Link Device. If the current shipped native build does not yet implement the QR-scan or manual Pairing ID + Short code screens, complete pairing instead through Sign In With Portal in the app (the browser-issued link-challenge path documented in Sign In To Manage Linked Devices below). The browser QR/short-code path is the supported contract for any new third-party mobile authenticator client.
The pairing flow emits:
- Audit action
mfa.mobile.pairing_initiatedwhen the QR code is generated. - Audit action
mfa.mobile.device_pairedwhen the device redeems the code. - Audit action
mfa.mobile.pairing_redeem_lockedwhen 5 wrong verifier submissions lock the pairing row (IU-012-S03 / R-0201).
Security notes:
- The QR payload carries a 32-byte high-entropy secret that is infeasible to brute-force within the 5-minute TTL; only the SHA-256 hash is stored server-side.
- Manual entry is protected by a per-pairing lockout: after 5 wrong attempts against your Pairing ID the row is invalidated and you must start a new pairing session. An IP-rotating attacker cannot exceed 5 guesses per outstanding pairing.
Active-device cap and dormant expiry (IU-012-S04 / mobile-authenticator G-03):
-
Each user can keep up to 5 active linked mobile devices by default (). Pairing while at the cap fails Profile → Mobile Devices and pair again.
-
A pairing that hits the cap leaves the QR/short-code session in
challengedstate for the rest of the 5-minute window — after revoking an old device you can complete phase 2 without restarting the QR flow. -
Active devices are auto-revoked after 180 dormant days (default; ). A device’s idle clock is its
last_seen_at(the last signed mobile action); QR-paired devices that never sign anything age from pairing time. -
Auto-revoked devices stay visible as revoked evidence in Profile → Mobile Devices and must be paired again with a fresh key if they come back into use. Auto-revoke cancels any pending action challenges on the device and is audited as
auth.mobile_device_auto_revoked.
Sign In To Manage Linked Devices
The app now uses Portal’s real first-party native auth path:
- open the
Devicestab - review the fixed Cadres development Portal web and API endpoints shown in the app
- tap
Sign In With Portal - complete the browser-based Portal login, MFA, tenant selection, or consent flow inside the native browser handoff:
- iOS uses
ASWebAuthenticationSession- Android uses Custom Tabs plus the app’s deep-link callback - Portal redirects back to the app’s registered callback URL
After sign-in, the Devices tab can:
- list the authenticated user’s linked devices for the selected tenant context
Operational Notes
- The iOS app stores the Ed25519 private key in Keychain with device-only accessibility. It does not claim Secure Enclave backing in this tranche.
- The Android app stores a software-generated Ed25519 private key encrypted at rest with an Android Keystore wrapping key. It does not claim hardware-backed or StrongBox-backed Ed25519 in this tranche.
- The native apps use Portal’s real browser-based sign-in, not embedded fake login forms.
- The backend accepts the native token path only on the explicit linked-device-management and mobile-workflow facade routes. Other browser-session-sensitive self-service routes still rely on the existing
portal_sessioncontract. - High-value mobile workflow decisions now require both the canonical Portal identity checks and a one-time linked-device action challenge signed by the exact active device key.
- Both apps now ship local biometric or device-auth gating only over already-stored app state. That local unlock is not a second auth model and not a permission bypass.
Verified Device Attestation
Pairing now requires platform attestation evidence in addition to the Ed25519
device-key proof. iOS uses Apple App Attest. Android uses Play Integrity or
Android Key Attestation. Existing linked devices that show
attestation_status = unverified must be re-paired before they can approve
high-value mobile workflow challenges.
Operators can inspect for
attestation_status, attestation_provider,
attestation_assurance_level, attestation_verified_at, and sanitized
attestation_verification_json. Raw App Attest objects, Play Integrity
tokens, X.509 chains, and private-key material are not exposed.
If a mobile action challenge returns linked_device_attestation_required,
revoke and re-pair the device from the app. Local development can use the
debug attestation provider only when both DEBUG=true and
MOBILE_ATTESTATION_DEBUG_BYPASS=true; production rejects that bypass at
startup.
Enable Push Notifications
-
Sign in to the native app and complete the linked-device pairing as described above.
-
The app prompts for notification permission immediately after sign-in (iOS user-notifications consent; Android 13+ ).
-
If you grant permission the app calls APNs/FCM token. The server stores it encrypted at rest and exposes the SHA-256 fingerprint on so operators can confirm delivery readiness without seeing the raw token.
-
The next mobile workflow action you initiate from the browser fires a push containing a 3-digit confirmation code.
-
Open the app, verify the code matches the browser, unlock locally (biometric or device passcode), and approve or deny — the in-app Ed25519 signature is what authorizes the workflow action. A lock-screen tap alone cannot approve.
-
To stop receiving pushes for this device, tap Disable Push in the app (DELETE the credential) or revoke the device. Revoking a device also invalidates its push credential and cancels any pending deliveries.