NAOMS Devlog

Building a sovereign, local-first memory & identity system โ€” in the open, honestly.

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.

Technology Architect free June 25, 2026ยท9 min readยทdistribution
TL;DR An Android build now runs the real NAOMS daemon on the phone itself โ€” in production governance mode, not a permissive sandbox, and not a thin client phoning home. The encrypted vault stays on the device, and its data encryption key is wrapped behind a hardware Keystore key that a fingerprint releases for exactly one unlock. This is the deep version of how that works, and the specific, unglamorous walls we hit getting there: an ECIES dead end on real hardware, a biometric prompt that silently never appeared, and an approval the on-device daemon couldn't even ask for.

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.

โ† more in Technology   home โœฆ   all โ†’