<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Android on Lessons learned</title><link>https://eghuro.cz/tags/android/</link><description>Recent content in Android on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Tue, 03 Nov 2026 18:00:00 +0100</lastBuildDate><atom:link href="https://eghuro.cz/tags/android/index.xml" rel="self" type="application/rss+xml"/><item><title>Offline login in an iOS or Android app: design it as offline unlock</title><link>https://eghuro.cz/posts/offline-login-is-offline-unlock/</link><pubDate>Tue, 03 Nov 2026 18:00:00 +0100</pubDate><guid>https://eghuro.cz/posts/offline-login-is-offline-unlock/</guid><description>&lt;p&gt;A product request for &amp;ldquo;offline login&amp;rdquo; in a mobile app with a cache of sensitive data (customer invoices, HR records, patient notes, credentials) sounds like one feature. On a phone with no network, nothing can check the password against the server, apply MFA, or learn that the device was revoked yesterday. What the app can do is prove a local factor to a device that was enrolled earlier and release keys for ciphertext already on it:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;offline login: authenticate the user, apply policy, authorize data (impossible offline)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;offline unlock: prove a local factor, release keys for cached data (what you can build)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I researched how to build the second one on iOS and Android. This post collects the parts that are easy to get wrong: where the password path needs a hardware key, why a biometric prompt without a &lt;code&gt;CryptoObject&lt;/code&gt; is only a boolean check, which clock keeps counting while the phone sleeps, and what changes for users who have no password at all. It is longer than a usual post here, because the traps sit in different layers.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-an-offline-unlock-can-and-cannot-prove"&gt;What an offline unlock can and cannot prove &lt;/h2&gt;&#10;&lt;p&gt;A connected sign-in lets the server authenticate the user and the device, require MFA, evaluate current policy and revocation, and send newly authorized data. An offline unlock does none of that. So it must never renew a server session, refresh authorization, or make cached data look current. Label the action &amp;ldquo;Unlock&amp;rdquo; in the UI and keep &amp;ldquo;Sign in&amp;rdquo; for the server. Show &amp;ldquo;Last checked&amp;rdquo; (policy) and &amp;ldquo;Last synchronized&amp;rdquo; (data) separately, because a recent local unlock is neither.&lt;/p&gt;&#10;&lt;p&gt;The trade-off between availability and revocation is unavoidable. While the device is disconnected, a revocation event cannot reach it, and plaintext it already showed cannot be recalled. A long offline window helps users during outages and travel. It also lets a lost or deauthorized device keep unlocking its cache until the window expires or the device reconnects. Pick a maximum offline age per threat model, make it visible, and apply revocation before any sync or key release at the next connection. Managed users should get an administrator-set finite limit. If you offer an unlimited consumer mode, tell the user that remote revocation and policy changes wait for the next reconnect.&lt;/p&gt;&#10;&lt;p&gt;First use of a device is always a connected sign-in. It enrolls the device, downloads the encrypted state, and creates the local unlock material. A device that was never provisioned online must never create offline capability, and a network error must never let you skip a required connected step.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-state-machine-where-a-stale-device-never-prompts"&gt;A state machine where a stale device never prompts &lt;/h2&gt;&#10;&lt;p&gt;Write the states down, so a missing transition shows up in review:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ connected sign-in + MFA + policy + initial sync ─▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness valid, then valid local factor ────────▶ UNLOCKED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful check-in ─────────────────────────────▶ LOCKED_CURRENT (freshness renewed)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness expires ───────────────────────────────▶ LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;UNLOCKED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ timeout/background/device lock/manual lock ──────▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful check-in ─────────────────────────────▶ UNLOCKED (freshness renewed)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ freshness expires ───────────────────────────────▶ LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;LOCKED_STALE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ successful connected reauthentication/check-in ──▶ LOCKED_CURRENT&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─ check-in reports device/user revocation ─────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─ logout/user removal ─────────────────────────────▶ UNENROLLED&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A check-in is any authenticated server call that returns current policy, revocation state, and server time. A background call with a refresh token is enough. If policy also wants full reauthentication with MFA after some maximum age, put that in the policy record, not in app logic.&lt;/p&gt;&#10;&lt;p&gt;Evaluate freshness before asking for a factor. A stale device shows the reconnect screen and never runs the password KDF, a PIN check, or a biometric prompt. When the window has expired, a biometric prompt that is bound to fail, followed by a generic &amp;ldquo;wrong password&amp;rdquo; error, tells the user nothing about the fix.&lt;/p&gt;&#10;&lt;p&gt;Losing one local factor does not change state. When a biometric wrapper is invalidated, or the PIN hits its threshold, delete that wrapper and continue with the others.&lt;/p&gt;&#10;&#10;&lt;h2 id="wrap-keys-never-encrypt-data-with-the-password"&gt;Wrap keys; never encrypt data with the password &lt;/h2&gt;&#10;&lt;p&gt;Use envelope encryption:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Generate a random 256-bit account data key (ADK).&lt;/li&gt;&#10;&lt;li&gt;Generate a random key per record and per file and wrap each under the ADK. Do not derive them from the ADK. A derived key can always be derived again, so you cannot destroy or rotate it for one object without rotating the ADK.&lt;/li&gt;&#10;&lt;li&gt;Encrypt record fields, search indexes, file metadata, thumbnails, and file content with an AEAD: AES-256-GCM or ChaCha20-Poly1305.&lt;/li&gt;&#10;&lt;li&gt;Wrap the ADK once per unlock method: password, biometric, optional PIN, recovery key.&lt;/li&gt;&#10;&lt;li&gt;Encrypt the password and PIN wrappers a second time under a non-exportable hardware key that needs no user authentication: Secure Enclave on iOS, Android Keystore on Android.&lt;/li&gt;&#10;&lt;li&gt;Put version, algorithm, KDF parameters, key ID, account, object ID, field ID, and purpose into the authenticated data.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The field ID in step 6 looks like overkill until a field moves. If an attacker can swap the bank-account field of supplier record A with that of record B and the AEAD still verifies, the app shows a payment destination nobody entered for that supplier. In an autofill provider, the same swap between URL fields offers entry A&amp;rsquo;s password to site B.&lt;/p&gt;&#10;&lt;p&gt;A whole-database encryption layer can hide metadata and WAL contents. It does not replace per-field AEAD, because it usually protects only while the database is closed.&lt;/p&gt;&#10;&#10;&lt;h3 id="why-the-password-path-needs-the-device-too"&gt;Why the password path needs the device too &lt;/h3&gt;&#10;&lt;p&gt;The threat model that changes the design is the attacker with a copied sandbox or backup. Assume they hold the database, salts, KDF parameters, wrapped keys, and preferences. If the password wrapper is only &lt;code&gt;KDF(password)&lt;/code&gt;, they can run the KDF on GPUs, away from the device, for as long as they like.&lt;/p&gt;&#10;&lt;p&gt;Step 5 fixes that. With the wrapper sealed by a device key that never leaves the Secure Enclave or Keystore, every guess has to run on the original phone, at its speed. The KDF becomes the second barrier, not the only one. A secret stored in plaintext next to the copied files is not a second factor and does not count as one.&lt;/p&gt;&#10;&lt;p&gt;For the KDF itself:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Prefer Argon2id with a random salt and versioned parameters, benchmarked on the weakest supported device. It is not part of CryptoKit, CommonCrypto, or the standard Android crypto APIs, so it is a third-party dependency with its own review, pinning, and update process.&lt;/li&gt;&#10;&lt;li&gt;If you have to use PBKDF2, both platforms ship it: &lt;code&gt;CCKeyDerivationPBKDF&lt;/code&gt; and &lt;code&gt;SecretKeyFactory.getInstance(&amp;quot;PBKDF2WithHmacSHA256&amp;quot;)&lt;/code&gt;. Calibrate the iteration count on real devices instead of copying one from another project.&lt;/li&gt;&#10;&lt;li&gt;Store the parameters with the envelope and migrate them after a successful unlock.&lt;/li&gt;&#10;&lt;li&gt;Derive separate keys for local wrapping and for any protocol authentication, with domain separation.&lt;/li&gt;&#10;&lt;li&gt;On iOS, hold keys in CryptoKit &lt;code&gt;SymmetricKey&lt;/code&gt;, which zeroes its storage on release, not in &lt;code&gt;Data&lt;/code&gt; that the runtime may copy. A password captured as a Swift &lt;code&gt;String&lt;/code&gt; cannot be wiped; turn it into bytes for the KDF right away.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;KDF cost does not stop a patient person typing guesses into the unmodified app, so add an increasing delay and a persisted failure counter, and require a connected sign-in after a threshold.&lt;/p&gt;&#10;&#10;&lt;h3 id="a-six-digit-pin-is-a-million-candidates"&gt;A six-digit PIN is a million candidates &lt;/h3&gt;&#10;&lt;p&gt;An app PIN is a convenience secret in front of the password. It unlocks its own envelope, and that envelope has to be bound to a non-exportable platform key. Six digits are 10^6 candidates. An unbound PIN envelope falls to offline search, whatever the KDF costs.&lt;/p&gt;&#10;&lt;p&gt;The rest of the PIN rules:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;At least six digits; off by default for high-assurance or managed profiles.&lt;/li&gt;&#10;&lt;li&gt;Never store a PIN verifier that is cheaper to attack than the envelope.&lt;/li&gt;&#10;&lt;li&gt;A failure counter scoped to the user that survives process death and reboot, with increasing delay and at most 10 consecutive failures, the upper bound NIST SP 800-63B sets for activation secrets.&lt;/li&gt;&#10;&lt;li&gt;At the threshold, delete the PIN wrapper and ask for the password. Wiping the local data would destroy the only copy of anything not yet synced.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The catch: neither iOS nor Android enforces a retry limit in hardware for an app-defined PIN. NIST&amp;rsquo;s limit assumes the authenticator enforces it, and here the app does. The counter stops the user and a thief using the unmodified app. On a rooted or jailbroken device, an attacker can query the platform key directly at hardware speed, and only PIN length helps against that.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-biometric-prompt-must-unlock-a-key"&gt;The biometric prompt must unlock a key &lt;/h2&gt;&#10;&lt;p&gt;Biometric success has to authorize the cryptographic operation that unwraps the ADK. If it only flips an &lt;code&gt;isAuthenticated&lt;/code&gt; flag, a hooked callback on a rooted device skips it.&lt;/p&gt;&#10;&#10;&lt;h3 id="android-pass-a-cryptoobject"&gt;Android: pass a &lt;code&gt;CryptoObject&lt;/code&gt; &lt;/h3&gt;&#10;&lt;p&gt;Generate the key inside Keystore and require authentication for every use:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;spec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KeyGenParameterSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;offline-data-wrap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURPOSE_ENCRYPT&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PURPOSE_DECRYPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setKeySize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setBlockModes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BLOCK_MODE_GCM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setEncryptionPaddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENCRYPTION_PADDING_NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUserAuthenticationRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUserAuthenticationParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AUTH_BIOMETRIC_STRONG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 30+: every use prompts&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setInvalidatedByBiometricEnrollment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setUnlockedDeviceRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 28+&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setIsStrongBoxBacked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// API 28+; on StrongBoxUnavailableException, retry in the TEE&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nc"&gt;KeyGenerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;KeyProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KEY_ALGORITHM_AES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AndroidKeyStore&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then hand the initialized cipher to &lt;code&gt;BiometricPrompt&lt;/code&gt;, and decrypt only with the cipher you get back:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-kotlin" data-lang="kotlin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cipher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;AES/GCM/NoPadding&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DECRYPT_MODE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wrappingKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GCMParameterSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KeyPermanentlyInvalidatedException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;deleteBiometricWrapper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// enrolled biometrics changed: fall back to the password&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="err"&gt;: &lt;/span&gt;&lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationCallback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onAuthenticationSucceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;unlocked&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cryptoObject&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updateAAD&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;val&lt;/span&gt; &lt;span class="py"&gt;adk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unlocked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doFinal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// ...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PromptInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Unlock offline data&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setAllowedAuthenticators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BiometricManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Authenticators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BIOMETRIC_STRONG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setNegativeButtonText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Use password&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nc"&gt;BiometricPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CryptoObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set the accepted authenticators explicitly. A default that includes &lt;code&gt;DEVICE_CREDENTIAL&lt;/code&gt; means the device PIN opens the offline data, and so does anyone who watched the owner type it. If you want that fallback, make it a written product decision. Since this is a symmetric key with authentication required, creating the wrapper at enrollment also needs a prompt; ask for the password first, as with any new factor.&lt;/p&gt;&#10;&#10;&lt;h3 id="ios-a-secure-enclave-key-and-ecies"&gt;iOS: a Secure Enclave key and ECIES &lt;/h3&gt;&#10;&lt;p&gt;Generate the wrapping key in the Secure Enclave with biometric access control, and wrap the ADK with ECIES:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Unmanaged&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CFError&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;?&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;access&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecAccessControlCreateWithFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the OS deletes it if the passcode is removed&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[.&lt;/span&gt;&lt;span class="n"&gt;privateKeyUsage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;biometryCurrentSet&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// no passcode fallback&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrKeyType&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecAttrKeyTypeECSECPrimeRandom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrKeySizeInBits&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrTokenID&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kSecAttrTokenIDSecureEnclave&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecPrivateKeyAttrs&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrIsPermanent&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrApplicationTag&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;offline-data-wrap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;kSecAttrAccessControl&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;privateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecKeyCreateRandomKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;CFDictionary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;wrapped&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SecKeyCreateEncryptedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;SecKeyCopyPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eciesEncryptionCofactorX963SHA256AESGCM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;adk&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;CFData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;To unwrap, fetch the key with a fresh &lt;code&gt;LAContext&lt;/code&gt; passed as &lt;code&gt;kSecUseAuthenticationContext&lt;/code&gt;, with &lt;code&gt;touchIDAuthenticationAllowableReuseDuration&lt;/code&gt; left at 0, and call &lt;code&gt;SecKeyCreateDecryptedData&lt;/code&gt;. The prompt the user sees then authorizes this key use. That is the iOS counterpart of Android&amp;rsquo;s per-use authentication.&lt;/p&gt;&#10;&lt;p&gt;Two details to get right:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The accessibility class goes into &lt;code&gt;SecAccessControlCreateWithFlags&lt;/code&gt;. It cannot be combined with a separate &lt;code&gt;kSecAttrAccessible&lt;/code&gt; attribute on the same item.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;.biometryCurrentSet&lt;/code&gt; invalidates the key when Face ID or Touch ID enrollment changes. Handle that by deleting the wrapper and requiring the password. A &lt;code&gt;.devicePasscode&lt;/code&gt; or &lt;code&gt;.userPresence&lt;/code&gt; fallback lets anyone who knows the device passcode into the offline data.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="data-protection-classes-that-fail-after-lock"&gt;Data Protection classes that fail after lock &lt;/h2&gt;&#10;&lt;p&gt;Set a Data Protection class per store instead of relying on container defaults. The &lt;code&gt;complete&lt;/code&gt; class key is discarded about 10 seconds after the device locks, which bites in three places:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Plaintext temporaries should use &lt;code&gt;complete&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Background downloads of offline files need &lt;code&gt;completeUnlessOpen&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;A SQLite database in WAL mode under &lt;code&gt;complete&lt;/code&gt; fails when anything touches it after lock. Either stop database access on &lt;code&gt;protectedDataWillBecomeUnavailable&lt;/code&gt;, or use &lt;code&gt;completeUntilFirstUserAuthentication&lt;/code&gt; for the database and rely on field-level AEAD.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Files in &lt;code&gt;tmp/&lt;/code&gt; and elsewhere in the container default to &lt;code&gt;completeUntilFirstUserAuthentication&lt;/code&gt; unless you set otherwise.&lt;/p&gt;&#10;&#10;&lt;h2 id="offline-expiry-needs-a-clock-that-counts-sleep"&gt;Offline expiry needs a clock that counts sleep &lt;/h2&gt;&#10;&lt;p&gt;Enforcing maximum offline age with wall-clock time alone fails the moment someone sets the clock back. I covered that half in &lt;a&#10; href="https://eghuro.cz/posts/offline-cache-clock-rollback"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;An offline cache TTL needs a clock-rollback policy&lt;/a&gt;&#10;. The research added three things.&lt;/p&gt;&#10;&lt;p&gt;The freshness record has two parts. A server-signed part (check-in time, policy version, expiry duration), verified with the server&amp;rsquo;s public key. A local part (wall time at check-in, monotonic time at check-in, boot reference, accumulated elapsed time), authenticated with a keystore device key. Do not protect it with the ADK: freshness is evaluated before accepting any factor, so the record must verify while the app is locked.&lt;/p&gt;&#10;&lt;p&gt;The monotonic clock must keep counting while the phone sleeps, because a phone is asleep most of the time:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Platform&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Use&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Stops during sleep&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;iOS&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;mach_continuous_time()&lt;/code&gt;, &lt;code&gt;clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;mach_absolute_time()&lt;/code&gt;, &lt;code&gt;ProcessInfo.systemUptime&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Android&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SystemClock.elapsedRealtime()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;SystemClock.uptimeMillis()&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Verify the chosen clock on a device by letting it sleep.&lt;/p&gt;&#10;&lt;p&gt;Reboots reset the monotonic clock. Android has &lt;code&gt;Settings.Global.BOOT_COUNT&lt;/code&gt;. iOS has no documented boot identifier: &lt;code&gt;kern.boottime&lt;/code&gt; moves when the user changes the wall clock, so it detects clock changes, not boots. On iOS, detect a reboot by a monotonic reading lower than the stored one. Then:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;update accumulated elapsed time on every foreground, unlock, and lock&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;same boot: elapsed from the monotonic clock, cross-checked against wall time&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;after reboot: elapsed = max(accumulated + monotonic_since_boot,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; wall_now - wall_at_checkin)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wall_now &amp;lt; last stored wall time → rollback → stale&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A rollback across a reboot can then hide at most the time between the last update and the reboot.&lt;/p&gt;&#10;&lt;p&gt;Fail closed on a missing, rolled-back, or inconsistent record, and exclude it from backup. A restore, to another device or to the same one, then leaves the record missing and forces a check-in. Otherwise, a same-device restore brings back an older valid record together with the &lt;code&gt;ThisDeviceOnly&lt;/code&gt; Keychain items it belongs to.&lt;/p&gt;&#10;&lt;p&gt;These controls resist clock rollback, not state rollback. Nothing stops someone from replacing the current record with an older valid copy, and the expiry is application logic. Like the PIN counter, it binds the unmodified app, not an attacker running their own code against the device keys.&lt;/p&gt;&#10;&#10;&lt;h2 id="documents-chunked-aead-and-plaintext-that-outlives-the-app"&gt;Documents: chunked AEAD and plaintext that outlives the app &lt;/h2&gt;&#10;&lt;p&gt;Offline documents, such as invoice PDFs or scanned contracts, need more than per-record encryption. CryptoKit&amp;rsquo;s &lt;code&gt;AES.GCM&lt;/code&gt; and &lt;code&gt;ChaChaPoly&lt;/code&gt; seal a whole message in memory. Streaming playback and seeking viewers need chunks, and naive chunking lets an attacker truncate, reorder, or drop chunks without failing authentication. Use a format in the style of the STREAM construction:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;fixed-size chunks (say 64 KiB), each sealed separately;&lt;/li&gt;&#10;&lt;li&gt;a nonce built from a random per-file prefix and the chunk index;&lt;/li&gt;&#10;&lt;li&gt;file ID, format version, chunk index, and a final-chunk flag in each chunk&amp;rsquo;s authenticated data;&lt;/li&gt;&#10;&lt;li&gt;an authenticated header with the prefix, chunk size and version;&lt;/li&gt;&#10;&lt;li&gt;a new per-file key or nonce prefix whenever the file changes, so no key and nonce pair is ever reused.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Prefer decrypting into memory or a pipe for an in-app viewer. &lt;code&gt;QLPreviewController&lt;/code&gt; wants a file URL, so some formats force a plaintext file. When one is unavoidable, put it in a dedicated private directory under &lt;code&gt;complete&lt;/code&gt;, with an unpredictable name, excluded from backup and indexing, shared only through a narrow, non-exported &lt;code&gt;FileProvider&lt;/code&gt; grant on Android.&lt;/p&gt;&#10;&lt;p&gt;The trap is cleanup. A suspended iOS app runs no code, so no timer deletes the file on schedule, and cancellation, render errors, crashes, process death, and reboot all skip view-level cleanup. Make the launch path enumerate and delete expired temporaries before it allows an unlock.&lt;/p&gt;&#10;&#10;&lt;h2 id="lock-logout-and-keychain-items-that-survive-uninstall"&gt;Lock, logout, and Keychain items that survive uninstall &lt;/h2&gt;&#10;&lt;p&gt;Lock keeps the encrypted state and destroys the usable keys: release the ADK and derived keys, close plaintext streams, blank the app-switcher snapshot, invalidate decrypted caches, and stop background jobs from quietly recreating an unlocked session. A suspended app cannot clear the clipboard later, so set the expiry when you copy:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;UIPasteboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;general&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;UTType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8PlainText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[.&lt;/span&gt;&lt;span class="n"&gt;localOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expirationDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;.localOnly&lt;/code&gt; keeps it off Universal Clipboard. On Android, mark the clip with &lt;code&gt;ClipDescription.EXTRA_IS_SENSITIVE&lt;/code&gt; (API 33+) and clear it with &lt;code&gt;ClipboardManager.clearPrimaryClip()&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Logout makes the state permanently unusable. Physical overwrite on flash is unreliable, so the primary boundary is cryptographic erasure: destroy every usable wrapping key, then delete tokens, databases with their WAL and journal files, indexes, cached files, temporaries, pending operations, and shared-container state.&lt;/p&gt;&#10;&lt;p&gt;On iOS, uninstalling the app deletes its container but leaves its Keychain items, and no app code runs during uninstall. Keep a marker file in the container. When it is missing at launch, delete every Keychain item the app owns, shared access groups included, before doing anything else. Otherwise, a reinstall can find the previous install&amp;rsquo;s wrappers.&lt;/p&gt;&#10;&#10;&lt;h2 id="autofill-is-offline-unlock-under-another-name"&gt;Autofill is offline unlock under another name &lt;/h2&gt;&#10;&lt;p&gt;If the cached data includes credentials and the app offers an autofill provider, that provider releases secrets from the local cache with no server call, so every rule above applies to it. The platforms constrain it differently.&lt;/p&gt;&#10;&lt;p&gt;On iOS, the Credential Provider extension is a separate process with its own unlock session. It sees the app&amp;rsquo;s state only through a shared Keychain access group and an App Group container, so every key and wrapper it needs, including the Secure Enclave device key and the freshness record, must live in the shared group. It runs under a memory limit Apple describes as only slightly lower than an app&amp;rsquo;s. That limit caps the Argon2id memory parameter, so benchmark inside the extension on the weakest device. Do not give the extension a cheaper copy of the password envelope: the weakest wrapper sets the strength of all the data it protects. If parameters that fit are too weak, let the extension offer biometrics only and send password users to the app.&lt;/p&gt;&#10;&lt;p&gt;An &lt;code&gt;LAContext&lt;/code&gt; evaluated in the app does not carry over to the extension. Within one request, the extension can pass one evaluated context to several Keychain reads, so a single prompt covers a list. The system may reuse the extension process for the next request, so nothing decrypted may stay in a static.&lt;/p&gt;&#10;&lt;p&gt;On Android, the &lt;code&gt;AutofillService&lt;/code&gt; runs in the app&amp;rsquo;s own process unless the manifest says otherwise. Its authentication activity still needs a &lt;code&gt;CryptoObject&lt;/code&gt;, and it should accept the same authenticators as the app.&lt;/p&gt;&#10;&lt;p&gt;Rules for both:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Give the provider its own key that wraps only what a fill needs (username, password, URL), so a faulty extension cannot decrypt documents, notes, or indexes.&lt;/li&gt;&#10;&lt;li&gt;Share the failure counter between app and provider. Otherwise, an attacker doubles the allowed attempts by alternating between them.&lt;/li&gt;&#10;&lt;li&gt;The provider reads freshness and never checks it itself. When stale, it fills nothing and says which app to open.&lt;/li&gt;&#10;&lt;li&gt;On iOS, the identity store shows registered usernames next to their domains in the QuickType bar to anyone holding the unlocked phone, with the app locked. Replace the whole identity set after each sync instead of removing and re-adding the current ones, so deleted entries disappear, and consider a managed policy that registers none.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h3 id="verify-who-is-asking"&gt;Verify who is asking &lt;/h3&gt;&#10;&lt;p&gt;The requester check is where autofill phishing happens (&lt;a&#10; href="https://doi.org/10.1145/3243734.3243778"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Aonzo et al., &lt;em&gt;Phishing Attacks on Modern Android&lt;/em&gt;, CCS 2018&lt;/a&gt;&#10;). iOS identifies websites and apps through associated domains. On Android, the provider has to do it:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Verify the requesting app&amp;rsquo;s signing certificate against the domain&amp;rsquo;s &lt;a&#10; href="https://developers.google.com/digital-asset-links"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Digital Asset Links&lt;/a&gt;&#10;, not its package name. An app named &lt;code&gt;com.examplebank.evil&lt;/code&gt; is not Example Bank.&lt;/li&gt;&#10;&lt;li&gt;Trust browsers by package name and certificate digest together. On a device without a listed browser, a sideloaded app can take that browser&amp;rsquo;s package name and report any web domain.&lt;/li&gt;&#10;&lt;li&gt;Derive registrable domains from the &lt;a&#10; href="https://publicsuffix.org"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Public Suffix List&lt;/a&gt;&#10;, not a hand-written list of suffixes. Otherwise, an entry saved for &lt;code&gt;victim.github.io&lt;/code&gt; collapses to &lt;code&gt;github.io&lt;/code&gt; and fills in &lt;code&gt;attacker.github.io&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="users-without-a-password"&gt;Users without a password &lt;/h2&gt;&#10;&lt;p&gt;Everything above assumes every user has a password. Users who sign in through enterprise SSO or a partner&amp;rsquo;s authenticator app do not. In this design, the password has five jobs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;the baseline wrapper of the ADK;&lt;/li&gt;&#10;&lt;li&gt;the fallback when a biometric wrapper is invalidated;&lt;/li&gt;&#10;&lt;li&gt;the authorization for a new biometric wrapper;&lt;/li&gt;&#10;&lt;li&gt;the replacement when the PIN hits its threshold;&lt;/li&gt;&#10;&lt;li&gt;the recent password entry that sensitive actions require.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each job needs a new owner. The options I found:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Connected sign-in takes every fallback role. This one always applies. A user who loses their last local factor while offline has no offline access until they reconnect.&lt;/li&gt;&#10;&lt;li&gt;A mandatory app passcode becomes the baseline factor. Set it during connected provisioning and wrap the ADK with it like the password (KDF, then device key). Three PIN rules change. Six digits stop being enough, because nothing stands behind the passcode: at 0.5 seconds per guess on a compromised device, 10^6 candidates take under six days. Allow alphanumeric passcodes with a real minimum length. The threshold sends the user to connected sign-in, and choosing a new passcode needs one too. The user gets a second secret to remember; that is the price of an offline knowledge factor.&lt;/li&gt;&#10;&lt;li&gt;The device passcode can back up the biometric wrapper. It avoids losing offline access at the first biometric change and reopens the &amp;ldquo;watched the owner type it&amp;rdquo; risk. Make it explicit, let administrators forbid it, and check on a device whether iOS still releases a &lt;code&gt;biometryCurrentSet&lt;/code&gt; or &lt;code&gt;devicePasscode&lt;/code&gt; item through the passcode after enrollment changes.&lt;/li&gt;&#10;&lt;li&gt;A recovery key can have enough entropy that a copied wrapper is safe even without device binding. Users lose these, and nobody can reset one, so offer it only where a lost factor can cost data.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;What does not work is anything the identity provider issued. A cached refresh token or ID token proves the device holds a token, not that the user is holding the device. Don&amp;rsquo;t wrap the ADK under it, and delete the identity provider&amp;rsquo;s token cache after the server exchange.&lt;/p&gt;&#10;&lt;p&gt;Check one thing in your own protocol. If the server opens a passwordless user&amp;rsquo;s data without any secret from the user, it can provision the ADK again at the next sign-in. A lost factor then costs offline access, not data, as long as offline records stay read-only. Unsynced offline edits are the exception, because they exist only on the device.&lt;/p&gt;&#10;&lt;p&gt;Decide the factor set at provisioning from the login method, and store it with the policy. If the same user later provisions through a password login, re-enroll; don&amp;rsquo;t bolt a password wrapper onto a passwordless enrollment.&lt;/p&gt;&#10;&#10;&lt;h2 id="caveats"&gt;Caveats &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The ADK&amp;rsquo;s scope is a protocol decision. If the ADK only protects this device&amp;rsquo;s cache, logout and revocation destroy it. If it is the user&amp;rsquo;s end-to-end key shared with the server and other devices, a device revoked while offline keeps a copy that decrypts future data, and revoking it means rotating the key.&lt;/li&gt;&#10;&lt;li&gt;Offline edits are a separate project. Ship read-only offline records first. Editable mode needs an encrypted operation log separate from server-confirmed state, conflict rules that keep both versions when a merge could lose data, and an authorization recheck for each operation on reconnect.&lt;/li&gt;&#10;&lt;li&gt;On rooted and jailbroken devices, hardware-backed keys and integrity signals raise the cost. Retry counters and expiry are app logic and don&amp;rsquo;t bind an attacker who runs their own code against the keys. Don&amp;rsquo;t claim otherwise.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="test-the-failure-paths"&gt;Test the failure paths &lt;/h2&gt;&#10;&lt;p&gt;Happy-path testing says nothing about whether keys and plaintext stay contained. The release gates I would not skip:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Area&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Test&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Provisioning&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A never-enrolled device fails offline; restoring a backup to another device, or an older backup to the same one, forces a check-in&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Crypto&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Copied password and PIN wrappers cannot be opened off the device; ciphertext swapped between records or between fields of one record fails authentication&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Biometrics&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add and remove enrolled biometrics, change and remove the passcode, reboot, process death&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Retries&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Counters survive relaunch and reboot; the PIN threshold removes only the PIN wrapper&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Freshness&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Clock forward and back, time zone change, reboot, a long sleep counted toward expiry, a replayed older record, the exact boundary; a stale device never prompts&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Documents&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Truncated, reordered, and dropped chunks fail; kill the app mid-preview and confirm the temporary is gone before the next unlock&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Deletion&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Uninstall and reinstall leave no usable Keychain state; removing one user leaves another intact&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Autofill origin&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;A look-alike package name gets no match; &lt;code&gt;victim.github.io&lt;/code&gt; is not offered on &lt;code&gt;attacker.github.io&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS&lt;/a&gt;&#10; and &lt;a&#10; href="https://mas.owasp.org/MASTG"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG&lt;/a&gt;&#10;, &lt;a&#10; href="https://pages.nist.gov/800-63-4/sp800-63b.html"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;NIST SP 800-63B&lt;/a&gt;&#10;, &lt;a&#10; href="https://csrc.nist.gov/pubs/sp/800/132/final"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;NIST SP 800-132&lt;/a&gt;&#10; for PBKDF2, and &lt;a&#10; href="https://www.rfc-editor.org/rfc/rfc9106"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RFC 9106&lt;/a&gt;&#10; for Argon2id set the baseline. They don&amp;rsquo;t decide where your keys live or what happens when a preview crashes.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Offline, the app can only answer &amp;ldquo;is this the enrolled user, on the enrolled device, within the allowed window?&amp;rdquo; Every unlock path, including the password, should require the device&amp;rsquo;s hardware key; every biometric prompt should gate a key operation; and the window should be measured by a clock that keeps counting while the phone sleeps.&lt;/p&gt;&#10;&lt;p&gt;If your app already has an offline mode, copy its sandbox to another machine and try to open the password wrapper there. If that attempt gets as far as a KDF, the password path is missing its device key.&lt;/p&gt;&#10;</description></item></channel></rss>