Your Whole Memory System, in Your Pocket
Not a viewer into a server somewhere โ the actual governed daemon, running in production mode on the phone, with the encrypted vault unlocked by a key your fingerprint releases exactly once.
There are two very different things people call "an app on your phone." One is a window: your data lives on a company's server, and the app is a viewer that renders what the server decides to send. The other โ much rarer โ is the actual system running on the phone, holding your data on the device in your hand, enforcing its own rules locally with nothing to phone home to. NAOMS just reached the second kind on Android. This piece is about why that distinction is not marketing, and about the concrete engineering it took to make it literally true.
The temptation, when you put "an app" in an app store, is to ship the window and call it local-first. It demos identically. The difference only shows up at the exact moments that matter: when the network is gone, when the server is compromised, when someone asks who actually decided this was allowed. So the test we held ourselves to was not "does it render on a phone." It was: is the thing on the phone the same governed daemon we run on a laptop, in the same production mode, or is it a softer impostor wearing the same UI?
The flag that changes everything
Inside the daemon there is a single piece of state called production mode. It is not a cosmetic setting. When it is on, the governance layer enforces a real presence gate: a sensitive operation must be backed by a fresh, human authentication, and the gate fails closed if it isn't. When it is off, that same gate is satisfied by a non-production skip โ a deliberate convenience so that headless tests don't each need a fingerprint reader.
Here is the uncomfortable truth we found and fixed. On desktop, production mode
gets switched on explicitly at boot (the daemon is started with a production
flag). The mobile on-device runtime โ Hermes, the embedded daemon that runs
inside the app โ never set it. So isProductionMode() stayed false on the
phone, and the presence gate was being satisfied by the non-production skip. The
app looked governed. It was running with the safety catch off.
The fix is one line, and it is the most important line in the whole release:
// The mobile on-device daemon IS a production daemon โ enforce the REAL
// governance biometric presence gate. (Opt-out only for headless e2e.)
initProductionModeFlag(globalThis.__NAOMS_HERMES_NONPROD !== true);From that point on, the phone's daemon holds itself to the same bar as the desktop one. The only way to turn the gate off is an explicit test-only global โ not a default, not a silent fallback. This is the entire difference between "a viewer into a server" and "the real thing on the device": in the viewer, the governance decision happens somewhere you have to trust. Here it happens in your pocket, in production mode, and refuses to proceed without you.
What "unlocked by your fingerprint" actually means
flowchart TD
A[Vault on disk: encrypted with the DEK] -->|DEK is wrapped, never stored bare| B[Wrapped DEK blob]
B --> C{Unlock}
C -->|Fingerprint| D[BiometricPrompt bound to a Cipher
via CryptoObject โ BIOMETRIC_STRONG only]
D -->|StrongBox / TEE releases the AES-GCM key
for exactly one doFinal| E[Unwrap DEK]
C -->|Fallback| F[Password unlocks the
vault_app_password_blob]
F --> E
E --> G[DEK live in the shell]
G --> H[Cold daemon: dispatch vault.unlock
so the embedded daemon spawns its signer]
H --> I[Production-mode governance now operating on-device]
"Unlocked by your fingerprint" is easy to say and easy to fake โ plenty of apps show a fingerprint prompt that gates nothing but a boolean. We wanted the fingerprint to gate the data encryption key (DEK) that the on-device vault is actually encrypted with. So the DEK is never stored in the clear. It is wrapped under a key that lives in the phone's hardware keystore, and that hardware key is configured so it cannot be used at all until a strong biometric authenticates the specific operation.
Concretely: at enrollment we generate an AES-256-GCM key inside the AndroidKeyStore
with setUserAuthenticationRequired(true) and an authentication validity of zero
seconds โ meaning every single use of the key demands a fresh authentication. We
prefer StrongBox (the dedicated secure element) and fall back to the TEE if
StrongBox rejects the key. Unlock then runs a BiometricPrompt bound to a
Cipher through a CryptoObject. On success the prompt hands back a cipher that
the secure hardware has authorized for exactly one doFinal() โ one unwrap of
the DEK. There is no window where the DEK sits decryptable without a live
fingerprint behind it.
That is the categorical difference made physical. A stolen phone is a wrapped blob and a hardware key that will not turn without a finger that isn't the thief's.
The ECIES dead end
The first design was wrong, and the way it was wrong is worth telling because it
is exactly the kind of thing that never shows up in a tutorial. The original
vault key was an elliptic-curve key, using ECIES (encrypt-to-public-key) on
newer Android. It is a clean design on paper. On a real device โ a Fairphone 6
running Android 15 โ it threw No provider found for ECIES. ECIES is simply not
a registered AndroidKeyStore cipher transformation on real hardware.
So the whole key strategy was rebuilt around a symmetric AES/GCM keystore key, which is the canonical, hardware-supported pattern for a CryptoObject biometric unlock. This is the unglamorous reality of "runs on Android": the correct-looking cryptographic design loses to the one the silicon actually provides, and you find that out on a physical phone, not in a simulator.
The prompt that never appeared
Then a subtler wall. On relaunch, the unlock would auto-fire โ and sometimes the biometric prompt simply never showed up, and the unlock promise hung forever. No error, no crash. Just a system that stopped.
The cause is an Android lifecycle rule with sharp teeth: BiometricPrompt. authenticate() silently no-ops if it is called after the host activity's
onSaveInstanceState() โ i.e. before the activity is fully RESUMED. On relaunch
the unlock was firing mid-resume, so the prompt was discarded before it appeared,
and the suspending call that was waiting on a result never got one. The fix
defers the prompt until the activity lifecycle reaches RESUMED, then launches it,
guaranteeing the activity is foreground when authenticate() actually runs.
Paired with that is a discipline we now hold everywhere across this bridge: always resolve. Every authentication outcome โ success, user cancel, hardware error โ resumes the waiting call. A biometric callback that can fall through without resolving is a UI that can hang, and a hanging unlock is indistinguishable from a broken app. (There is one deliberate exception: a failed-but-retryable attempt does not resolve, because the system itself offers the retry.)
One more honest seam sits just past the unlock. The very first time a returning
phone user unlocks a cold daemon, the embedded daemon has to spawn its signer
before it can do anything governed โ and the original path looped on
UNAUTHENTICATED instead. The fix makes the cold unlock dispatch a real
vault.unlock so the daemon brings its signer up. Small bug, total
difference: without it, the on-device daemon could be unlocked and still unable to
sign a thing.
The approval the daemon couldn't ask for
The deepest issue was not about cryptography at all. It was about what doesn't run on a phone.
On desktop, when something requests an approval, an approval.requested entry is
written to the chain, and a packaged enricher projects it into an
approval_request node in the graph โ carrying the cryptographic challenge the
biometric must answer. The governance gate that finally checks your credential
reads that node's challenge property to know what it's asking you to sign.
On mobile, that enricher never runs. The on-device daemon cannot do dynamic
package loading โ the mobile bundler stubs it out โ so at boot the log reads
Package discovery complete {packages:0}. No package means no enricher, which
means no approval_request node, which means the gate reads challenge from a
node that does not exist, finds nothing, and fails closed with
challenge_property_missing. The result: the biometric prompt is never even
reached. The most secure-sounding failure mode โ fail closed โ was making the
on-device approvals unusable.
The fix is a Hermes-only materializer that writes the same approval_request
node directly, at request-creation time, mirroring the desktop enricher's node
shape field-for-field so the gate reads an identical node on both platforms. It
is an idempotent upsert keyed on the request id, gated to the mobile runtime, and
dead-code-stripped out of the desktop bundle โ so the desktop path, where the
enricher is the single canonical writer, is untouched. This is the same governed
approval ceremony two keys must sign,
arriving on a runtime that has to reconstruct, by hand, the projection a full
desktop never has to think about.
A message sent to a socket that wasn't there
A final phone-shaped gremlin, because it is the texture of this whole effort. During onboarding the WebView can reconnect on a brand-new connection id, leaving the daemon's outbound channel pointed at the old one. The broadcast machinery still counted one live socket and reported the message delivered โ but the bytes went to a dead connection and vanished. The honest count and the real delivery had quietly diverged.
The fix prunes the stale connection the instant a push fails to land, so the next broadcast falls through to the live connection instead of cheerfully reporting success into the void. We added matching diagnostics on both sides of the TypeScript-to-Rust boundary โ the daemon now logs the exact connection id and whether it reached a live channel โ because on a device you cannot attach a debugger to, an honest log line is the difference between a five-minute fix and a five-day one.
Honestly scoped: foundation, not polish
Here is the plain accounting, because the honesty axiom is the point.
What is real today: the real governed daemon runs on the phone in production mode, with the genuine presence gate enforced โ not a permissive sandbox, not a remote proxy. The on-device vault is encrypted, its DEK wrapped behind a hardware-backed AES/GCM keystore key that a strong biometric releases for a single unwrap, with a password fallback. Onboarding can enroll that fingerprint unlock, returning users are offered it, and the on-device approval path can finally materialize the node it needs and reach the biometric.
What this is not: a finished, app-store-polished release with every surface done. It is the foundation โ and the foundation is the part you cannot retrofit. You can add screens, settings, and shine on top of a daemon that genuinely runs your governance on your device behind your own hardware. You cannot bolt "the real thing actually runs here" onto a viewer after the fact; that decision is load-bearing from the first commit.
The phone is the most personal โ and the most surveilled โ computer most people own, which is exactly why your system should work for you, not on you, and why it matters that what's now in your pocket is the system itself and not a shadow of it. The window has become the room. The rest is finishing, and finishing is a far better problem to have than proving the thing can be real at all.
Written by AI agents from real project logs; owned and edited by Mujo.