<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Owasp on Lessons learned</title><link>https://eghuro.cz/tags/owasp/</link><description>Recent content in Owasp 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/owasp/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><item><title>iOS jailbreak protection in 2026: detection is a risk signal, not a security boundary</title><link>https://eghuro.cz/posts/ios-jailbreak-protection-2026/</link><pubDate>Mon, 26 Oct 2026 23:20:00 +0100</pubDate><guid>https://eghuro.cz/posts/ios-jailbreak-protection-2026/</guid><description>&lt;p&gt;An iOS app had jailbreak detection, passed an external pentest, and still spent more than a year accepting every jailbroken device because an asynchronous result was discarded. After that bug was fixed, the checks were still a 2025 list of Cydia-era paths that missed current rootless and hidden jailbreaks.&lt;/p&gt;&#10;&lt;p&gt;If the requirement says “the app must not run on a jailbroken phone,” the useful answer in 2026 is not a longer path list. Treat jailbreak detection as a bypassable risk signal, decide what sensitive operation the signal protects, enforce that decision at the server where possible, and test the whole policy against current jailbreak and bypass tools.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-short-version"&gt;The short version &lt;/h2&gt;&#10;&lt;p&gt;A defensible implementation today has five parts:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Write a threat model that explains why a compromised device materially changes this app&amp;rsquo;s risk.&lt;/li&gt;&#10;&lt;li&gt;Combine independent local signals and run them near sensitive operations, not only at launch.&lt;/li&gt;&#10;&lt;li&gt;Apply a proportional policy: observe, step up, restrict sensitive data, or refuse service according to confidence and impact.&lt;/li&gt;&#10;&lt;li&gt;Put the final authorization decision on a server and use App Attest to protect app-to-server requests. Do not call App Attest to prove that a device is not jailbroken.&lt;/li&gt;&#10;&lt;li&gt;Test the release binary statically, dynamically, and by trying to bypass it on representative clean and jailbroken devices.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This is also the difference between “the code contains a jailbreak check” and an engineering conclusion that can survive review.&lt;/p&gt;&#10;&#10;&lt;h2 id="how-a-working-check-silently-stopped-working"&gt;How a working check silently stopped working &lt;/h2&gt;&#10;&lt;p&gt;The first implementation was conventional:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;sysctl&lt;/code&gt; checked &lt;code&gt;P_TRACED&lt;/code&gt; and terminated a release build under a debugger;&lt;/li&gt;&#10;&lt;li&gt;known paths such as &lt;code&gt;/Applications/Cydia.app&lt;/code&gt;, &lt;code&gt;/bin/bash&lt;/code&gt;, &lt;code&gt;/usr/sbin/sshd&lt;/code&gt;, and &lt;code&gt;/etc/apt&lt;/code&gt; were checked;&lt;/li&gt;&#10;&lt;li&gt;the app tried to write &lt;code&gt;/private/jailbreak.txt&lt;/code&gt; outside its sandbox;&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;cydia://&lt;/code&gt; was passed to &lt;code&gt;canOpenURL&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;loaded Mach-O images were searched for &lt;code&gt;MobileSubstrate&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It ran only in non-Debug builds on physical devices. That was sensible, and the pentest team verified it.&lt;/p&gt;&#10;&lt;p&gt;One probe had never worked in the affected app targets: &lt;code&gt;canOpenURL&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; for schemes that are not declared under &lt;code&gt;LSApplicationQueriesSchemes&lt;/code&gt;, and &lt;code&gt;cydia&lt;/code&gt; was not declared. An app extension could not use &lt;code&gt;UIApplication.shared&lt;/code&gt; at all. Current IOSSecuritySuite also &lt;a&#10; href="https://github.com/securing/IOSSecuritySuite/blob/master/IOSSecuritySuite/JailbreakChecker.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;removed the &lt;code&gt;cydia://&lt;/code&gt; check&lt;/a&gt;&#10; because an App Store app registered the scheme and caused false positives. URL-scheme probes are weak evidence even when configured correctly.&lt;/p&gt;&#10;&lt;p&gt;Then the check moved behind a function that started a &lt;code&gt;Task&lt;/code&gt;, returned from the function immediately, and ignored the task&amp;rsquo;s &lt;code&gt;Bool&lt;/code&gt;. The user interface always received &lt;code&gt;true&lt;/code&gt;. The debugger check still appeared to work because &lt;code&gt;fatalError&lt;/code&gt; happened inside the task, masking the control-flow bug. Later refactors moved the broken call without changing its behavior.&lt;/p&gt;&#10;&lt;p&gt;The eventual fix was not another jailbreak signature. It was to make validation genuinely asynchronous, await it, and test the production call site as well as the checker.&lt;/p&gt;&#10;&lt;p&gt;That history produced three reusable lessons:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Test enforcement, not just detection. A perfect signal whose result is ignored protects nothing.&lt;/li&gt;&#10;&lt;li&gt;Test every app entry point and extension. The main app, credential provider, widget, and background path do not necessarily share a process or API surface.&lt;/li&gt;&#10;&lt;li&gt;Test the release configuration on hardware. A Debug simulator test proves code structure, not production behavior when the checks are compiled out there.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="when-owasp-says-to-implement-jailbreak-detection"&gt;When OWASP says to implement jailbreak detection &lt;/h2&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/news/2026/07/04/mastg-v200-release"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG 2.0, released in July 2026&lt;/a&gt;&#10;, makes the trade-off unusually explicit. &lt;a&#10; href="https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0030"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-BEST-0030&lt;/a&gt;&#10; is currently tagged Android and says “root detection,” but its risk principle applies directly to the equivalent iOS control: it is a bypassable, cost-raising environment signal. It should be used only when compromised-device risk materially affects the app. The selection and response should follow the threat model and risk tolerance.&lt;/p&gt;&#10;&lt;p&gt;That points toward jailbreak protection when a compromised platform makes a concrete abuse case easier, for example:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;extracting high-value secrets or regulated data from the app process or local cache;&lt;/li&gt;&#10;&lt;li&gt;instrumenting transactions, credential filling, cryptographic operations, or fraud controls;&lt;/li&gt;&#10;&lt;li&gt;changing a game, licensing, payment, or content-access decision made locally;&lt;/li&gt;&#10;&lt;li&gt;automating a protected API through a modified client;&lt;/li&gt;&#10;&lt;li&gt;defeating controls that depend on the sandbox, Keychain access policy, or trusted user interaction.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It does not make jailbreak detection a universal mobile-app baseline. A low-risk public-content app may gain little while inheriting false positives, support work, accessibility problems, and an exclusion policy it cannot reliably enforce.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/controls/MASVS-RESILIENCE-1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS-RESILIENCE-1&lt;/a&gt;&#10; states the security concern: many other controls assume that the platform&amp;rsquo;s sandboxing, secure storage, and biometrics can be trusted. But the wider &lt;a&#10; href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS resilience guidance&lt;/a&gt;&#10; treats anti-tampering and runtime self-protection as defense in depth, never a replacement for the baseline security architecture.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-current-techniques-are-rootless-and-designed-to-hide"&gt;The current techniques are rootless and designed to hide &lt;/h2&gt;&#10;&lt;p&gt;The old signature list described rootful Cydia and MobileSubstrate installations. Current jailbreaks changed the filesystem layout and then added explicit concealment.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine&lt;/a&gt;&#10; is a rootless jailbreak. Its bootstrap commonly appears below &lt;code&gt;/var/jb&lt;/code&gt;, with ElleKit providing tweak injection. Dopamine 1.1 &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/1.1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;introduced &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;&lt;/a&gt;&#10; in the environment of processes with tweaks enabled, while noting that &lt;code&gt;/var/jb&lt;/code&gt; might become optional to impede detection. Palera1n has its own loader and helper artifacts. Frida may appear as &lt;code&gt;frida-server&lt;/code&gt;, Frida Gadget, a loaded image, or merely runtime behavior after its obvious names have been changed.&lt;/p&gt;&#10;&lt;p&gt;At the time of this review, Dopamine&amp;rsquo;s current upstream release was &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/3.0.12"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;3.0.12, published October 20, 2026&lt;/a&gt;&#10;. A signature inventory that stops at Cydia or even Dopamine 2 is already historical.&lt;/p&gt;&#10;&lt;p&gt;Dopamine 2.4&amp;rsquo;s &lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/2.4"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;release notes&lt;/a&gt;&#10; are the more important evidence: “Hide Jailbreak” was changed specifically to hide the jailbreak to the point where it should be undetectable. &lt;a&#10; href="https://github.com/roothide/Dopamine-roothide"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RootHide&amp;rsquo;s Dopamine integration&lt;/a&gt;&#10; randomizes the jailbreak root and selectively keeps jailbreak changes away from blacklisted apps. Its purpose is jailbreak-detection bypass.&lt;/p&gt;&#10;&lt;p&gt;A 2026 local detector should therefore look across different classes of evidence, for example:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;rootful and rootless files, directories, symlinks, package-manager state, and randomized &lt;code&gt;.jbroot-*&lt;/code&gt; roots;&lt;/li&gt;&#10;&lt;li&gt;whether the sandbox permits a collision-safe write to a protected path;&lt;/li&gt;&#10;&lt;li&gt;debugger state and failure of the debugger-status query itself;&lt;/li&gt;&#10;&lt;li&gt;suspicious environment keys such as &lt;code&gt;DYLD_INSERT_LIBRARIES&lt;/code&gt; and &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;;&lt;/li&gt;&#10;&lt;li&gt;loaded images associated with Frida, ElleKit, Substrate, Substitute, libhooker, RootHide, or Dopamine&amp;rsquo;s system hook;&lt;/li&gt;&#10;&lt;li&gt;behavior that should be prohibited by the sandbox, where the API is appropriate for App Store software;&lt;/li&gt;&#10;&lt;li&gt;server-side anomalies and app-integrity assertions.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Use more than one API for important filesystem evidence. A &lt;code&gt;FileManager.fileExists&lt;/code&gt; check, a POSIX &lt;code&gt;lstat&lt;/code&gt;, and directory enumeration do not create certainty, but an attacker now has more hooks to maintain consistently.&lt;/p&gt;&#10;&lt;p&gt;Do not equate more signatures with “state of the art.” Public signature lists are useful coverage for unsophisticated and accidentally exposed jailbreaks. RootHide, renamed Frida components, or a patch to the function that aggregates all checks can make every local answer say “clean.” OWASP&amp;rsquo;s &lt;a&#10; href="https://mas.owasp.org/MASTG/knowledge/ios/MASVS-RESILIENCE/MASTG-KNOW-0084"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;jailbreak detection knowledge article&lt;/a&gt;&#10; says the same thing: these defenses are not effective by themselves, although distributing them through the app increases the cost of bypassing them.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-response-matters-more-than-the-boolean"&gt;The response matters more than the Boolean &lt;/h2&gt;&#10;&lt;p&gt;“Jailbroken: yes or no” is too weak a model for a fallible set of signals. Record an internal risk result with the evidence and confidence, then map it to a product policy.&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;Situation&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Reasonable response&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;One weak or ambiguous signal&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Telemetry, retry, or step-up 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;Several independent local signals&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Block sensitive operations; avoid disclosing the exact failed probe&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;Debugger or injected runtime tool in a high-risk flow&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Refuse the operation and invalidate relevant server state&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;Unsupported App Attest on an otherwise supported device&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Treat as a risk signal according to the documented policy&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;Enterprise-managed fleet with a strict posture requirement&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Evaluate Managed Device Attestation at the relying service&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;OWASP recommends proportional responses and warns that aggressive lockout can affect legitimate users. For an app that stores documents and credentials, a useful soft block can allow login and folder metadata while refusing document bodies, downloads, offline queues, and credential release.&lt;/p&gt;&#10;&lt;p&gt;That restriction must be in application logic, not just a screen with a Close button. Better still, the server should not return the sensitive payload. Otherwise, a hook can dismiss the screen or call the same API directly.&lt;/p&gt;&#10;&lt;p&gt;A hard process exit is appropriate only if the threat model justifies denial of all functionality and the product accepts false positives. Crashing also gives poor observability and can turn an operational failure into a permanent lockout. A generic refusal with server-side telemetry is usually easier to operate.&lt;/p&gt;&#10;&#10;&lt;h2 id="app-attest-authenticates-app-instances-not-clean-devices"&gt;App Attest authenticates app instances, not clean devices &lt;/h2&gt;&#10;&lt;p&gt;Apple&amp;rsquo;s &lt;a&#10; href="https://developer.apple.com/videos/play/wwdc2026/201"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;WWDC26 App Attest session&lt;/a&gt;&#10; describes the current protocol:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;the server initiates enrollment and issues a challenge;&lt;/li&gt;&#10;&lt;li&gt;the app creates a Secure Enclave-bound key and attests it;&lt;/li&gt;&#10;&lt;li&gt;the server validates and stores the attestation and public key;&lt;/li&gt;&#10;&lt;li&gt;sensitive requests include a fresh server challenge in an assertion over the payload;&lt;/li&gt;&#10;&lt;li&gt;the server validates the signature and requires a strictly increasing counter to reject replays.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This is valuable. It lets the server distinguish a valid published app instance, detect unexpected signing or launch categories, and bind authorization to a payload that has not been altered in transit.&lt;/p&gt;&#10;&lt;p&gt;It is not a jailbreak oracle. The same 2026 session says that a compromised device can still pass attestations and broker valid attestations for modified clients. Apple provides the fraud metric to help detect that pattern. Use App Attest as another server-verified signal, with retry and onboarding policy for legitimate failures.&lt;/p&gt;&#10;&lt;p&gt;For managed organizational devices, &lt;a&#10; href="https://support.apple.com/guide/security/managed-device-attestation-sec8a37b4cb2/web"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Managed Device Attestation&lt;/a&gt;&#10; provides Secure Enclave-backed evidence about device identity and security posture. That is stronger input for a managed-fleet trust decision, but it is not a general capability that a consumer app can require from every unmanaged phone.&lt;/p&gt;&#10;&#10;&lt;h2 id="test-implementation-behavior-and-bypass-resistance"&gt;Test implementation, behavior, and bypass resistance &lt;/h2&gt;&#10;&lt;p&gt;OWASP MASTG 2 separates two questions that teams often collapse into one.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0240"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-TEST-0240&lt;/a&gt;&#10; is static: extract the shipped app package and inspect the binaries for jailbreak-detection mechanisms. This catches missing code, build flags that compiled it away, or a check that never reached a target. OWASP warns that automated static analysis is not exhaustive; manual reverse engineering may be necessary.&lt;/p&gt;&#10;&lt;p&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0241"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASTG-TEST-0241&lt;/a&gt;&#10; is dynamic: run the app, identify the checks, and try to bypass them. A test fails when detection is absent, but passing a single predefined bypass tool doesn&amp;rsquo;t prove resilience because its scripts may be incomplete or outdated.&lt;/p&gt;&#10;&lt;p&gt;For a real release, I would keep this matrix with the evidence from each run:&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;Test&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Expected evidence&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;Clean supported iPhones and iOS versions&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;No false refusal; all sensitive flows work&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;Release build on physical hardware&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Checks execute; Debug/simulator exclusions do not leak into the archive&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;Rootful and current rootless jailbreaks&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Each applicable signal fires; policy is enforced&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;Dopamine Hide Jailbreak and RootHide blacklist&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Document what remains visible and what becomes undetectable&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;Frida server and embedded/renamed Gadget&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Known artifacts are detected; renamed forms challenge bypass resistance; outcomes are recorded&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;Hook the aggregate result and individual APIs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sensitive server operation still requires valid authorization and assertions&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;Main app, extensions, background entry points&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Each entry point validates before releasing its protected capability&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;Async and error paths&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Results are awaited; query failures follow the documented fail-open or fail-closed policy&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;Pentest bypass configuration&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;It cannot enter a production archive; CI tests that invariant&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Unit-test injected signals, cleanup failures, query failures, and clean-device results. Then add integration tests around the call site: the protected operation must not run after validation fails. Finally, let a tester attack the binary rather than merely demonstrating a known file on a jailbroken test phone.&lt;/p&gt;&#10;&#10;&lt;h2 id="a-reviewable-2026-lege-artis-baseline"&gt;A reviewable 2026 “lege artis” baseline &lt;/h2&gt;&#10;&lt;p&gt;“State of the art” is not established by citing one library or by counting checks. For an engineering review, the conclusion should be reproducible, dated, scoped, and explicit about uncertainty. This is the practical baseline I would use when assessing whether an iOS app&amp;rsquo;s jailbreak protection was implemented with professional care in October 2026:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Define the question and date.&lt;/strong&gt; State which app version, release configuration, iOS range, device classes, sensitive operations, and attacker capabilities are in scope. Practice changes as jailbreaks and iOS change.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Identify the risk before prescribing the control.&lt;/strong&gt; Connect compromised-platform capabilities to actual assets and abuse cases. Explain why jailbreak detection materially affects this app.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Select authoritative and current sources.&lt;/strong&gt; Record versions and access dates for OWASP MASVS/MASTG, Apple platform documentation, relevant jailbreak release notes, and the implementation under review. Treat public detection libraries as examples, not normative standards.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Preserve and identify the evidence.&lt;/strong&gt; Record the source commit, archive hash, build settings, entitlements, tool versions, device/iOS versions, jailbreak version, and test logs. Work from a reproducible copy.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Separate facts from interpretation.&lt;/strong&gt; “The production binary contains &lt;code&gt;/var/jb&lt;/code&gt; and &lt;code&gt;JB_ROOT_PATH&lt;/code&gt; checks” is a fact. “Those checks make bypass harder but cannot establish a clean device” is the interpretation supported by OWASP and current bypass projects.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Analyze the whole control path.&lt;/strong&gt; Review signal collection, aggregation, async propagation, every entry point, the product response, server authorization, telemetry, and release-lane exceptions. Include negative findings and dead code.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Test both presence and effectiveness.&lt;/strong&gt; Perform static inspection, clean-device compatibility tests, dynamic execution on relevant jailbreaks, and bypass attempts. A unit test or a pentest on one jailbreak version is not enough.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Consider alternatives and false positives.&lt;/strong&gt; Explain why a detected artifact could be benign, why a missing artifact does not establish cleanliness, and why the chosen response is proportional.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;State limitations.&lt;/strong&gt; Local checks can be hooked or patched; hidden jailbreaks may expose no artifact; App Attest can succeed on a compromised device; the tested device set is finite.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Make a bounded conclusion.&lt;/strong&gt; Say whether the examined implementation follows the documented risk policy and raises attacker cost for the tested scenarios. Do not conclude that it guarantees a non-jailbroken device.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This structure mirrors a sound software expert analysis: sources and methods are identified, observations remain distinguishable from interpretation, contrary evidence is not hidden, limitations reduce the stated certainty, and the conclusion answers only the technical question examined. It is guidance for an engineering assessment, not a legal conclusion about liability, contractual compliance, or negligence.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;The old implementation failed twice: first because application control flow discarded a valid result, then because its threat model had aged past its Cydia-era signatures. The durable fix is a process—current layered signals, proportional server-enforced policy, and repeated adversarial testing—not a permanent &lt;code&gt;isJailbroken&lt;/code&gt; function.&lt;/p&gt;&#10;&lt;p&gt;Take one sensitive operation in your app and trace it today from device signal to server authorization. If any step can ignore, hide, or locally override the result, that is the next protection to fix.&lt;/p&gt;&#10;&#10;&lt;h2 id="references"&gt;References &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS: Resilience against reverse engineering and tampering&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/news/2026/07/04/mastg-v200-release"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG 2.0.0 release announcement&lt;/a&gt;&#10;, July 4, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASVS/controls/MASVS-RESILIENCE-1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASVS-RESILIENCE-1: Platform integrity&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0030"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-BEST-0030: Implementing Root Detection&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/knowledge/ios/MASVS-RESILIENCE/MASTG-KNOW-0084"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-KNOW-0084: Jailbreak Detection&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0240"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-TEST-0240: Jailbreak Detection in Code&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://mas.owasp.org/MASTG/tests/ios/MASVS-RESILIENCE/MASTG-TEST-0241"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP MASTG-TEST-0241: Runtime Use of Jailbreak Detection Techniques&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://developer.apple.com/videos/play/wwdc2026/201"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple: Secure your apps with App Attest, WWDC26&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://support.apple.com/guide/security/managed-device-attestation-sec8a37b4cb2/web"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Apple Platform Security: Managed Device Attestation&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/2.4"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 2.4 release notes: Hide Jailbreak&lt;/a&gt;&#10;, February 16, 2025.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/1.1"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 1.1 release notes: &lt;code&gt;JB_ROOT_PATH&lt;/code&gt;&lt;/a&gt;&#10;, June 16, 2023.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine releases&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/opa334/Dopamine/releases/tag/3.0.12"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;Dopamine 3.0.12 release&lt;/a&gt;&#10;, October 20, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/roothide/Dopamine-roothide"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;RootHide integration for Dopamine 3&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;li&gt;&lt;a&#10; href="https://github.com/securing/IOSSecuritySuite/blob/master/IOSSecuritySuite/JailbreakChecker.swift"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;IOSSecuritySuite jailbreak checker&lt;/a&gt;&#10;, accessed October 26, 2026.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Adapt security skills to your project's risks: from Go to OWASP LLM and iOS</title><link>https://eghuro.cz/posts/adapt-security-skills-to-project-risks/</link><pubDate>Sun, 25 Oct 2026 21:50:00 +0100</pubDate><guid>https://eghuro.cz/posts/adapt-security-skills-to-project-risks/</guid><description>&lt;p&gt;A security skill written for Go covers different questions than one written for an LLM application or an iOS app. I started with &lt;code&gt;golang-security&lt;/code&gt; from &lt;a&#10; href="https://github.com/samber/cc-skills-golang"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;samber/cc-skills-golang&lt;/a&gt;&#10;, asked Claude to adapt it using the OWASP Top 10 for LLM Applications, and later carried that approach into production iOS development.&lt;/p&gt;&#10;&lt;p&gt;The most useful part of the adaptation was aligning the review questions with the project&amp;rsquo;s risks and the company&amp;rsquo;s values.&lt;/p&gt;&#10;&#10;&lt;h2 id="start-with-a-review-method"&gt;Start with a review method &lt;/h2&gt;&#10;&lt;p&gt;I was working in Go, a language I was unfamiliar with. I used my security skills alongside other Go skills to keep the implementation in check.&lt;/p&gt;&#10;&lt;p&gt;The security skill provided a repeatable method: separate modes for coding, reviewing changes, and auditing a codebase; questions about trust boundaries and attacker-controlled input; and instructions to trace data through surrounding code before reporting a finding. Its references and checklist gave the agent somewhere to look beyond the main instructions.&lt;/p&gt;&#10;&#10;&lt;h2 id="adapt-owasp-categories-to-the-application"&gt;Adapt OWASP categories to the application &lt;/h2&gt;&#10;&lt;p&gt;I asked Claude to create a new skill using the &lt;a&#10; href="https://genai.owasp.org/llm-top-10"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP Top 10 for LLM Applications (2025)&lt;/a&gt;&#10;, adapted to the project&amp;rsquo;s LLM and Model Context Protocol (MCP) surfaces.&lt;/p&gt;&#10;&lt;p&gt;The resulting &lt;code&gt;llm-security&lt;/code&gt; skill kept the review modes and evidence requirements. It mapped the categories to tools, resources, prompts, and content returned to the model. The Go checklist stayed active for the code underneath.&lt;/p&gt;&#10;&lt;p&gt;For &lt;a&#10; href="https://genai.owasp.org/llmrisk/llm01-prompt-injection"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;prompt injection&lt;/a&gt;&#10;, the reviewer needs to identify whose text reaches the model and what an attacker can control. A returned document body or filename can carry instructions. For &lt;a&#10; href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;excessive agency&lt;/a&gt;&#10;, the questions concern tool permissions, reachable side effects, and authorization enforced outside the model.&lt;/p&gt;&#10;&lt;p&gt;Those questions give the agent a path through the repository. It can follow a tool result back to its data source, then follow a tool call forward to the operation it performs. The skill also asks it to account for existing protections when assessing impact.&lt;/p&gt;&#10;&#10;&lt;h2 id="repeat-the-adaptation-for-ios"&gt;Repeat the adaptation for iOS &lt;/h2&gt;&#10;&lt;p&gt;Later, I applied the same approach to an iOS app using the &lt;a&#10; href="https://owasp.github.io/www-project-mobile-top-10"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;OWASP Mobile Top 10 (2024)&lt;/a&gt;&#10;. That mobile security skill became part of production development.&lt;/p&gt;&#10;&lt;p&gt;The review method carried over. The mapping changed to platform APIs and the code responsible for them:&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;Mobile risk&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Concrete review question&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;Improper credential usage&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Where are tokens stored, and which Keychain access settings apply?&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;Insufficient input/output validation&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;What can a deep link or share-extension input cause the app to do?&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;Insecure data storage&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Which protection classes apply to cached and temporary user files?&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;Inadequate privacy controls&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Where can sensitive data appear outside its intended screen, including logs and notifications?&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Repository context matters here. If a shared SDK owns authentication or networking, the skill needs to direct the reviewer into that SDK. Stopping at the changed Swift view would leave the relevant behavior unexamined.&lt;/p&gt;&#10;&lt;p&gt;For a mobile adaptation, &lt;a&#10; href="https://mas.owasp.org/MASVS"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;MASVS&lt;/a&gt;&#10; supplies verification controls, 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; provides testing guidance for checking them.&lt;/p&gt;&#10;&#10;&lt;h2 id="turn-company-values-into-review-rules"&gt;Turn company values into review rules &lt;/h2&gt;&#10;&lt;p&gt;Company values need concrete instructions too. For example, a company that prioritizes privacy can require reviewers to trace user data into logging, analytics, and crash reporting. That turns a broad preference into something an agent can inspect.&lt;/p&gt;&#10;&lt;p&gt;The mobile skill also records settled product decisions. An intentional tradeoff needs a rationale and the protections that still apply so that each review can assess the implementation against that decision. New evidence or changed assumptions can justify reopening it.&lt;/p&gt;&#10;&lt;p&gt;Severity needs the same local reasoning. OWASP provides risk categories; the product&amp;rsquo;s data, attacker access, and existing controls determine a finding&amp;rsquo;s impact.&lt;/p&gt;&#10;&lt;p&gt;A fresh adaptation request could be:&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;Use the existing security skill as a template. Keep its review workflow&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;and evidence requirements. Adapt the relevant OWASP categories to this&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;repository&amp;#39;s entry points, data flows, and platform APIs. Link each check&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;to the responsible code and a primary reference. Record product decisions&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;with their rationale. Preserve the language-level checks alongside it.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Tailoring a security skill gives the agent concrete questions about the product it is building. Take one OWASP category in your current skill and map it to the code, data, and product decision the reviewer should examine.&lt;/p&gt;&#10;</description></item></channel></rss>