feat(android): migrate rootkey from expo-secure-store on first boot#107
Merged
Conversation
The previous Android app stored the 16-byte rootkey (device identity) in
expo-secure-store. This adds the one-shot, one-way migration so the new
native store reads it on first boot, re-wraps it under our AndroidKeyStore
wrapper key, and from then on is the source of truth.
- LegacyRootKeyDecoder reads SharedPreferences("SecureStore"), decodes the
expo-secure-store AES envelope (alias reconstructed from the pinned default
keychainService "key_v1" + the unauthenticated suffix), decrypts to the
stored UTF-8 hex string, and hex-decodes to 16 raw bytes. Length-checked:
anything other than exactly 16 bytes throws rather than returning a
wrong-but-plausible key (silent identity loss).
- RootKeyStore.loadOrInitialize gains the three-state flow: native hit ->
use; native miss + legacy hit -> migrate (re-wrap, commit(), read-back +
byte-compare); both miss -> generate. Recovery hatch: a present-but-corrupt
native blob falls back to legacy before surfacing an error. Never silently
regenerates on any decrypt failure.
- Legacy entry is never deleted or rewritten; it stays as the only on-device
recovery hatch.
- Logs only state transitions, never key material.
Legacy parameters verified against expo-secure-store@56.0.4 source as shipped
by comapeo-mobile. Note the envelope's keystoreAlias field holds only the
keychainService, not the full keystore alias (the plan doc was imprecise here);
the full alias is reconstructed in the decoder.
iOS is greenfield (no migration); existing apps/example/tests cover its
read-or-generate precedence.
Android tests are instrumented (AndroidKeyStore) and were not run here (no
emulator); they rely on CI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Implements Android root-key migration for existing installs by reading the legacy expo-secure-store rootkey on first boot and re-wrapping it into the native RootKeyStore, with a recovery path that attempts legacy migration when a native blob is present but unreadable.
Changes:
- Added
LegacyRootKeyDecoderto locate, parse, decrypt, and hex-decode the legacy SecureStore envelope into the 16-byte rootkey. - Updated
RootKeyStore.loadOrInitialize()to: prefer native store, migrate from legacy on native miss, and attempt legacy recovery on native decrypt failure. - Expanded Android instrumented tests to cover migration, fallback recovery, corruption cases, and idempotent steady-state behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| android/src/main/java/com/comapeo/core/RootKeyStore.kt | Adds native-read recovery hatch, legacy migration path, and shared persist+verify logic with plaintext zeroization handled by callers. |
| android/src/main/java/com/comapeo/core/LegacyRootKeyDecoder.kt | New decoder for expo-secure-store envelope → AES/GCM decrypt → UTF-8 hex → 16-byte rootkey with strict length checks. |
| android/src/androidTest/java/com/comapeo/core/RootKeyStoreTest.kt | Adds instrumented coverage for migration, corruption/no-regeneration guarantees, fallback recovery, and known-vector round trips. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch (e: Exception) { | ||
| throw RootKeyException("Legacy rootkey decrypt failed", e) | ||
| } | ||
| return String(plaintext, Charsets.UTF_8) |
Refines the expo-secure-store → native migration (71e1925): - Funnel keystore exceptions (loadWrapperKey, legacy decrypt) through RootKeyException so the all-failures-through-RootKeyException contract holds. - Stop createOrLoadWrapperKey re-wrapping loadWrapperKey's own RootKeyException as a generic "create" failure; broaden the StrongBox catch and log the non-StrongBox downgrade so it is observable rather than silent. - On the §2.1 legacy-fallback recovery path, capture the native-read failure (with stack) to Sentry as STATE=RECOVERED so hatch firings are monitorable. - Add rootkey.load{outcome=native|migrated|generated|recovered} and rootkey.wrapper_key.created{strongbox} counters via a new SentryFgsBridge.countMetric helper (metrics are unsampled, unlike spans). - Correct migration-plan §5.2/§5.3: the envelope keystoreAlias field holds only the keychainService, not the full alias (verified against expo-secure-store@56.0.4); mark §10 open questions resolved. - Fix stale e2e example/ → apps/example/ paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ervation - wipedWrapperKeyWithValidLegacyRecovers: exercises the §7 native-decrypt-fails path where the wrapper key alias is wiped (the real OEM credential-reset case), distinct from the existing parse-failure recovery test. - migrationLeavesLegacyEntryInPlace: locks in the §2.1 invariant that one-way migration never deletes the legacy recovery hatch. - Rename a confusing ByteArray local (`generated` → `key`) in loadOrInitialize. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gmaclennan
added a commit
that referenced
this pull request
Jun 23, 2026
## Optic Release Automation This **draft** PR is opened by Github action [optic-release-automation-action](https://github.com/nearform-actions/optic-release-automation-action). A new **draft** GitHub release [v1.0.0-pre.4](https://github.com/digidem/comapeo-core-react-native/releases/tag/untagged-884b8ff33e96eff7e06b) has been created. Release author: @gmaclennan #### If you want to go ahead with the release, please merge this PR. When you merge: - The GitHub release will be published - The npm package with tag pre will be published according to the publishing rules you have configured - No major or minor tags will be updated as configured #### If you close the PR - The new draft release will be deleted and nothing will change <!-- Release notes generated using configuration in .github/release.yml at 353e6e9 --> ## What's Changed ### 🚀 Features * feat(android): migrate rootkey from expo-secure-store on first boot by @gmaclennan in #107 ### 🐛 Bug Fixes * fix(backend): apply defaultOnlineStyleUrl to the standalone map server by @gmaclennan in #148 ### 🏗️ Maintenance * test(ios): de-flake shutdown-IPC assertions via deterministic wait by @gmaclennan in #147 **Full Changelog**: v1.0.0-pre.3...v1.0.0-pre.4 <!-- <release-meta>{"id":343614988,"version":"v1.0.0-pre.4","npmTag":"pre","opticUrl":"https://optic-zf3votdk5a-ew.a.run.app/api/generate/"}</release-meta> -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the Android root-key migration from the previous app's
expo-secure-storeinto the nativeRootKeyStore(issue #98, P0). The 16-byte rootkey is the device's identity across every CoMapeo project; a botched migration is silent identity loss, so the implementation is deliberately paranoid: validate every byte-level transform, never silently regenerate, never touch the legacy entry.What changed
LegacyRootKeyDecoder.kt— readsSharedPreferences("SecureStore"), decodes theexpo-secure-storeAES envelope, decrypts to the stored UTF-8 hex string, and hex-decodes to 16 raw bytes. Post-decode length is checked: anything other than exactly 16 bytes throws rather than returning a wrong-but-plausible key.RootKeyStore.loadOrInitialize()three-state flow — native hit → use; native miss + legacy hit → migrate (re-wrap under our wrapper key,commit(), read-back + byte-compare, zero plaintext); both miss → generate (existing path).rootkey).Legacy parameters (verified against shipped source)
Verified against
expo-secure-store@56.0.4(the versioncomapeo-mobileshipped):"SecureStore""<keychainService>-<keyName>", fallback bare"<keyName>""key_v1"(app sets none)"__RootKey""AES/GCM/NoPadding:key_v1:keystoreUnauthenticated"Correction to the plan doc: the envelope's own
keystoreAliasJSON field holds only the keychainService string (e.g."key_v1"), not the full keystore alias. The full alias is reconstructed from the keychainService plus theusesKeystoreSuffixflag. Noted in a code comment.Acceptance criteria
rootkey, never silent regenapps/example/tests/ios/RootKeyStoreTests.swiftplus inspectionChecks run
npm run lint(ESLint via expo-module): pass (no TS/JS touched, repo lints clean)Could not run locally
android/src/androidTest/.../RootKeyStoreTest.kt) — they exerciseAndroidKeyStore, which has no JVM-only equivalent, and require an emulator/device. Must be run by CI. They were written to compile cleanly but could not be compiled here (nogradlewin the library module, nokotlinc).Closes #98
🤖 Generated with Claude Code