Skip to content

Secure Mode

Yura Filatov edited this page Aug 8, 2026 · 4 revisions

Secure Mode

Secure Mode provides defense against coerced device access — physical seizure, compelled unlock, or anyone with the power to force you to hand over your phone. It is governed by a feature flag (secureMode in features.plist, default true) and adds no binary overhead when disabled.


PIN Lock

When a PIN is configured, PINEntry overlays the app on every cold launch and after five minutes in background. The lock screen is a neutral 6-digit keypad — identical in appearance regardless of whether a normal, duress, or no Secure Mode is active.

PIN verifiers are AES-GCM blobs derived via the Secure Enclave:

verifier = AES-GCM(
  key:  HKDF-SHA256(seKey, info: label ∥ pin),
  msg:  sentinel
)
SE key tag: "app.layer.key.occulta.v1"  (opaque)

The SE binding is the real rate-limiter — the verifier is useless without the physical device. A constant 500 ms timing gate equalizes response time across correct, incorrect, and duress entries so timing does not leak which branch was taken.

Wrong attempts trigger an incremental lockout: no delay for the first 5 attempts, then 1 min → 2 min → 5 min → … → 24 h from attempt 20 onward. The lockout counter survives app kills (persisted in AppLayerConfig, excluded from backup).

Grace period: Five minutes after a successful unlock, returning from background skips the PIN prompt. A UIKit cover is installed synchronously in sceneWillResignActive so iOS captures it — not live content — in the app-switcher snapshot.


Duress PIN & Decoy View

Configuring Secure Mode requires two PINs:

  • Normal PIN — unlocks the real view. All contacts and vault entries visible.
  • Duress PIN — unlocks a decoy view. Contacts and vault entries marked sensitive are hidden.

Entering either PIN triggers the same UI flow and produces the same haptic. The app is visually identical at both depths — same tabs, same settings, same vault layout — but Manager.Security.isRestricted filters based on currentDepth.

Contact visibility is tracked per-contact via visibleThroughDepth: Data (AES-GCM-encrypted Int) on Contact.Profile. The field is always encrypted and populated from the moment a contact is created — never left nil — so its presence or absence can't itself be inspected as a signal:

Value Meaning
Int.max Visible at every depth (safe)
0 Visible at depth 0 only (sensitive)
N Visible at depths 0 through N

At depth D, a contact is shown when decrypt(visibleThroughDepth) >= D — a ceiling: once visible at a given depth, also visible at every shallower one.

A second field, originDepth, governs contacts created while already at a duress depth — a contact a coercer force-pairs live during coercion, for example. These need the opposite rule from an ordinary contact: visible from the depth they were created at and every depth nested deeper than it, never at any depth shallower — including the real depth 0. originDepth is a floor, not a ceiling:

Value Meaning
0 Created at the real depth — no confinement, visibleThroughDepth's ceiling applies as normal
N > 0 Created at duress depth N — visible at N and every depth nested deeper, hidden at any depth shallower than N

A contact with originDepth > 0 bypasses the ceiling check entirely, at every depth, including sensitivity reclassification — there is nothing behind such a contact for a coercer to learn by keeping it fully visible from its origin depth onward, so there's no protective purpose in ever hiding it again.

Vault entries use a different rule: VaultEntry.visibleThroughDepth is an exact match, not a ceiling — an entry is visible only at the precise depth it was created at (nil = never classified, always visible). A ceiling would let an entry created at a duress depth leak into the real depth-0 vault; exact match keeps each depth's content strictly its own.

The share index — the list of contacts available in the iOS Share Extension — is filtered to match the current depth. Sensitive contacts never appear in the share sheet in the decoy view.


Inbound Message Handling

No inbound bundle is ever rejected because of restriction state, for any contact, at any depth. An earlier version gated inbound bundles with a check that threw a "not addressed to you" error for a known contact not marked safe at the current depth — correct in isolation, but real mode has no equivalent rejection path for a genuinely known contact, so that outcome was only ever reachable while restricted. A coercer who force-pairs a device live, or who uses an existing contact identity, could read that asymmetry directly as a duress-detection test, independent of anything shown or hidden on screen.

That check is removed entirely. Every bundle from a fingerprint-matched sender decrypts and processes identically, real or duress depth, known-safe or not — the only remaining rejection is a bundle from a sender with no fingerprint match at all, which fails the same way at every depth and carries no depth-dependent signal. A contact with originDepth > 0 is fully functional immediately, indistinguishable in protocol behavior from a real contact, since there is nothing behind it a coercer doesn't already know.

The one accepted trade: a real, previously-hidden contact's genuinely new message can render on screen the instant it arrives, if a duress depth happens to be active when it does — content a rejection-based gate would previously have kept encrypted. This is deliberate, not an oversight: an app that goes silent exactly when a known contact messages is itself a signal, and suppressing the reveal without suppressing decryption reopens the same detection asymmetry this change closes. See Security Properties for how this trade is scored.


Key Rotation on Activation

Activation triggers an 11-step atomic key rotation to cryptographically separate real and decoy data:

1.  Verify current PIN
2.  Create a staged local database key
3.  Derive the layer store encryption key
4.  Classify contacts: sensitive → blob; safe → stay in DB
5.  Migrate any untagged contacts
6.  Seal sensitive contacts into an encrypted slot in the layer store
7.  (reserved)
8.  Re-encrypt all contacts and vault depth fields under the staged key
9.  Commit the staged key  ← point of no return
10. WAL checkpoint — flush staged-key writes to the main SQLite file
11. Delete superseded key material

On failure before commit the staged key is rolled back and no state changes. On failure after commit, forceDeactivateForRecovery removes the duress verifier without key rotation as a last resort.


Layer Store

A 32-slot fixed-size file (<UUID>.occbak, ≈ 1 MB) holds the sealed sensitive contacts. Every slot is AES-GCM encrypted to exactly 32 KB of plaintext, zero-padded. All 32 slots are re-sealed with fresh nonces on every push and pop — slot layout reveals nothing.

layerKey = HKDF-SHA256(seKey, info: "layer-store-key")
slot     = AES-GCM(layerKey, zeroPadded(payload), randomNonce)

The file has no header, no magic bytes, and no version field. It exists on every install from first launch and is rewritten every 24 hours when Secure Mode is inactive, so its creation timestamp predates any security event and its modification timestamps correlate with normal app activity.

File properties: .completeFileProtection, isExcludedFromBackup = true.


Multi-Layer Support

The architecture supports up to 32 nested duress layers. A second activation from within the decoy view creates a deeper decoy, each with its own duress PIN and contact set. Entering any PIN from a cold start routes directly to the correct depth — cold-start routing aliases written at activation mean no intermediate layers need to be stepped through.


Coercion-Resistant Toggle

The "Enable PIN" toggle in Settings lowers the PIN gate without removing verifiers — the toggle appears off, the PIN prompt stops appearing, and the app opens directly to depth-N content on the next launch. Depth filtering remains active regardless. A coercer who forces the user to "disable their PIN" sees exactly the same result as a user who disabled PIN on an ordinary app.

Re-enabling the toggle presents a standard setup sheet. The entered PIN is matched against existing verifiers:

  • Normal PIN match → re-routes to depth 0 (real view)
  • Duress PIN match → re-routes to depth 1 (decoy view)
  • Unknown PIN at depth > 0 → silently creates a new layer for the coercer's PIN so the toggle always flips on

Forensic Deniability

  • AppLayerConfig is written on the very first launch — its presence never indicates PIN or Secure Mode usage.
  • Verifier arrays are always padded to exactly 32 entries. Filler entries are random bytes of identical size to real verifiers; only the SE key can distinguish them.
  • pinEnabledPerDepth encodes gate state as UInt8 (not Bool) so enabled and disabled entries produce equal-length ciphertexts — a size difference would reveal the disabled slot without decryption.
  • All metadata fields use constant-size encodings for the same reason.
  • The layer store file exists from first launch and is not excluded from the 24-hour rewrite cycle until Secure Mode is active, making the timestamp profile indistinguishable from any other install.
  • No inbound bundle is ever rejected based on restriction state — real and duress depths process every known sender identically, closing a live protocol-behavior test a coercer could otherwise use to detect duress mode without inspecting any content. See Inbound Message Handling above.

Clone this wiki locally