Skip to content

feat(android): migrate rootkey from expo-secure-store on first boot#107

Merged
gmaclennan merged 4 commits into
mainfrom
feat/rootkey-migration
Jun 23, 2026
Merged

feat(android): migrate rootkey from expo-secure-store on first boot#107
gmaclennan merged 4 commits into
mainfrom
feat/rootkey-migration

Conversation

@gmaclennan

Copy link
Copy Markdown
Member

Summary

Implements the Android root-key migration from the previous app's expo-secure-store into the native RootKeyStore (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

  • New LegacyRootKeyDecoder.kt — reads SharedPreferences("SecureStore"), decodes the expo-secure-store AES 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).
  • Recovery hatch (§7) — a present-but-corrupt native blob falls back to the legacy store before surfacing an error. Never silently regenerates on any decrypt failure; all failures surface through the existing error-frame path (phase rootkey).
  • Legacy entry is never deleted or rewritten (§2.1) — it remains the only on-device recovery hatch.
  • Logging hygiene (§8) — only state transitions logged, never key material.

Legacy parameters (verified against shipped source)

Verified against expo-secure-store@56.0.4 (the version comapeo-mobile shipped):

  • SharedPreferences file: "SecureStore"
  • Pref key: "<keychainService>-<keyName>", fallback bare "<keyName>"
  • keychainService: default "key_v1" (app sets none)
  • keyName: "__RootKey"
  • Keystore alias (AES, unauthenticated): "AES/GCM/NoPadding:key_v1:keystoreUnauthenticated"
  • Encoding: the rootkey is stored as a 32-char hex string; decrypt yields the hex string, which is hex-decoded to 16 bytes.

Correction to the plan doc: the envelope's own keystoreAlias JSON field holds only the keychainService string (e.g. "key_v1"), not the full keystore alias. The full alias is reconstructed from the keychainService plus the usesKeystoreSuffix flag. Noted in a code comment.

Acceptance criteria

  • Legacy read + re-wrap when native store is empty
  • One-shot, one-way: native store becomes the source of truth; legacy left untouched
  • Failed migration → clean error frame phase rootkey, never silent regen
  • iOS greenfield read precedence confirmed (no migration step) — covered by existing apps/example/tests/ios/RootKeyStoreTests.swift plus inspection
  • Tests: native hit / legacy → migrate / both empty → first install / legacy corrupt → error / native-corrupt + valid legacy → fallback recovers / steady-state second call doesn't touch SecureStore / §6 known-vector encoding round-trip / hybrid-scheme error / wrong-length error

Checks run

  • npm run lint (ESLint via expo-module): pass (no TS/JS touched, repo lints clean)

Could not run locally

  • Android instrumented tests (android/src/androidTest/.../RootKeyStoreTest.kt) — they exercise AndroidKeyStore, 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 (no gradlew in the library module, no kotlinc).
  • Kotlin compile of the main module — same reason (built by the consuming app's Gradle). Reviewed by inspection.

Closes #98

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 LegacyRootKeyDecoder to 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)
@gmaclennan gmaclennan marked this pull request as ready for review June 22, 2026 12:25
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>
@github-actions github-actions Bot added the feature New feature (changelog) label Jun 23, 2026
…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 gmaclennan added the run-e2e Run the full BrowserStack e2e on this PR (otherwise it runs only in the merge queue) label Jun 23, 2026
@gmaclennan gmaclennan added this pull request to the merge queue Jun 23, 2026
Merged via the queue into main with commit 1024989 Jun 23, 2026
39 of 40 checks passed
@gmaclennan gmaclennan deleted the feat/rootkey-migration branch June 23, 2026 14:10
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>
-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature (changelog) run-e2e Run the full BrowserStack e2e on this PR (otherwise it runs only in the merge queue)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Root-key migration from existing (expo-secure-store) installs

2 participants