Zero-knowledge on mobile, without compromising.
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:
- @noble/hashes for SHA, HMAC, and the underlying Argon2id helpers.
- tweetnacl for Curve25519 NaCl box — the canonical JS port of djb's NaCl.
- secrets.js-grempe for Shamir's (same library as web).
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.