Engineering · 20 May 2026

Zero-knowledge on mobile, without compromising.

By the founder, Lyfos · ~7 min read

Lyfos is wire-compatible between web and mobile. A key holder who accepts their invite on a Mac in Chrome can release their share six months later from their iPhone in the airport lounge, and the cryptography just works. Getting that right meant making a few unusual engineering choices.

The constraint

The release engine relies on three primitives: Argon2id for key derivation, Curve25519 NaCl box for sealing each share to a holder, and Shamir's Secret Sharing for splitting the vault key into five. All three must produce byte-identical output across web (browser, libsodium / hash-wasm) and mobile (React Native, no native modules).

The web stack

On web we use hash-wasm for Argon2id, libsodium-wrappers for crypto_box, and secrets.js-grempe for Shamir's. Each lazy-loaded via dynamic import() so they don't bloat the critical path — shaving about 150 KB gzip off our initial bundle.

The mobile stack

On mobile we deliberately avoided native modules. Native crypto modules on Expo require config plugins, prebuild rebuilds, and a worse OTA story. So we picked the pure-JS sibling of each web library:

Pure JS means our app is OTA-updateable via EAS Update without binary rebuilds, and the same crypto code paths run on every platform.

The trap we almost fell into

Argon2id has parameters: memory cost, iterations, parallelism. If web runs at 64 MiB / 3 / 1 and mobile runs at 64 MiB / 3 / 4, the derived keys will differ. We pinned the parallelism to 1 everywhere and store the parameter set inside the envelope so future upgrades stay backward compatible.

The vault key on mobile

On web, the derived key lives in a non-extractable WebCrypto handle — the raw bytes never enter JavaScript. On mobile, React Native doesn't expose WebCrypto, so the key is a Uint8Array. To prevent it leaking into React state (and therefore into the Redux DevTools, the crash reporter, etc.), we hold it in a useRef on a context object, and expose it only via an accessor function used at the precise moment of finalize.

iOS Keychain quirks

SecureStore on iOS has a per-key 2 KB limit. Our encrypted vault is much larger than that. The fix: shard the blob across 1800-byte chunks, store a chunk-count under the main key, and read/write a small ceremony around it. On Android, AsyncStorage with encryption-at-rest covers it.

What we won't compromise on

We could have shipped a faster mobile launch with a native crypto module and called it good. We didn't, because the day Expo deprecates a native API or Apple changes a crypto framework, we'd be stuck shipping binary updates to thousands of users to fix it. Pure-JS crypto means we can update via OTA the same day a finding lands. For a vault product, that agility is worth the small performance cost.

Open the app →   Read the security model