Skip to content

harden(app): keep native token + backup-safe secure storage - #12053

Merged
undivisible merged 1 commit into
mainfrom
harden/native-auth-token-backup
Aug 22, 2026
Merged

undivisible merged 1 commit into
mainfrom
harden/native-auth-token-backup

Conversation

@undivisible

@undivisible undivisible commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #11917. That squash landed the Flutter secure-storage move before the Codex items were on the branch.

Ports from #11352 (now closed) plus the Codex review:

  • Mirror authToken to nativeAuthToken in prefs so Android BLE/WS can still read a token after the Flutter key is gone from SharedPreferences.
  • OmiBackgroundAudioStreamer reads nativeAuthToken, then falls back to authToken.
  • android:allowBackup="false" so encrypted prefs cannot restore without the keystore key.
  • iOS Keychain accessibility is first_unlock so a locked BLE restore can still get the token.
  • If secure-store migration fails, keep the leftover prefs token instead of logging the user out.

Test plan

  • Fresh login: Flutter session works; Android background streamer can auth.
  • Upgrade from a pre-harden(app): migrate authToken to flutter_secure_storage #11917 install: token migrates; native path still has nativeAuthToken.
  • iOS locked-device BLE restore after first unlock.
  • Confirm no backend/token-format change (app-only).

Review in cubic


Note

Medium Risk
Touches session-token storage, Android backup, and iOS Keychain accessibility. Native still mirrors the token in SharedPreferences for background BLE/WS.

Overview
Follow-up to the Flutter secure-storage migration so native Android can still auth background transcription after authToken leaves SharedPreferences.

Mirrors the live session into a dedicated nativeAuthToken prefs key on init, set, and delete. OmiBackgroundAudioStreamer reads that first, then falls back to authToken. If a secure write fails, init still uses a leftover prefs token instead of logging the user out.

Also sets android:allowBackup="false" so encrypted prefs cannot restore without the keystore key, and uses iOS Keychain first_unlock so a locked-device BLE restore can still read the token. Migration tests now assert the native mirror.

Reviewed by Cursor Bugbot for commit d493452. Configure here.

Port the useful #11352 bits onto the slimmer #11917 path: mirror the
session into nativeAuthToken for Android background streaming, turn
Android backup off so a restore cannot orphan the keystore key, and
read the Keychain after first unlock so BLE restore can authenticate
while locked.
@cursor

cursor Bot commented Aug 22, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_2a6bdcad-78b6-418a-a4ca-35cd76e56775)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d493452700

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +64 to +66
// Codex P2: a failed secure write leaves the legacy prefs token; still use it.
if (_authTokenCache.isEmpty) {
_authTokenCache = _preferences?.getString('authToken') ?? '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Catch the secure read before applying the legacy fallback

When secure storage is inaccessible rather than merely rejecting one write—for example, an Android keystore key is invalid after restore—migrateAuthTokenFromPrefs() catches its first read/write exception, but the immediately following _readSecureAuthToken() throws again before this fallback executes. Because main() awaits SharedPreferencesUtil.init(), affected users still cannot start the app or retain their existing session. Keep the post-migration read in the same failure boundary and add a secure-storage-throws regression test.

AGENTS.md reference: AGENTS.md:L31-L32

Useful? React with 👍 / 👎.


private fun buildRequest(url: String): Request? {
val token = stringPref("authToken")
val token = stringPref("nativeAuthToken").ifEmpty { stringPref("authToken") }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Migrate tokens before relying on the native mirror

When an installation has already run the preceding secure-storage migration, authToken has been removed and nativeAuthToken does not exist yet. Android's BleCompanionService can start the foreground BLE service while Flutter is not running, so after updating, this lookup finds neither key and background audio frames cannot be authenticated until the user opens the app and SharedPreferencesUtil.init() creates the mirror. Preserve existing background sessions by providing a native-accessible migration/read path that does not require a Flutter startup.

AGENTS.md reference: app/AGENTS.md:L111-L115

Useful? React with 👍 / 👎.

if (_authTokenCache.isEmpty) {
_authTokenCache = _preferences?.getString('authToken') ?? '';
}
await _syncNativeAuthToken(_authTokenCache);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restrict the plaintext native mirror to Android

On every iOS startup and token update, this unconditional call writes the Firebase bearer token through shared_preferences, which maps to NSUserDefaults; the supposedly Android-only mirror therefore puts the token back into ordinary, backupable preferences and defeats the new Keychain storage on iOS. Gate the mirror and its cleanup to Android so non-Android platforms retain the documented secure-storage-only lifecycle.

AGENTS.md reference: app/AGENTS.md:L111-L115

Useful? React with 👍 / 👎.

} else {
await _secureStorage?.delete(key: _authTokenSecureKey);
}
await _syncNativeAuthToken('');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent stale deletes from clearing a newer native token

If authToken is cleared and then repopulated before the asynchronous secure-storage delete completes—for example, an expired-token transient failure followed promptly by a successful refresh—the new setter writes nativeAuthToken, but the older _deleteSecureAuthToken() later resumes here and removes that newer mirror. Secure storage then contains the fresh token while the native background streamer has none, so background transcription loses authentication until another refresh or app restart; serialize or revision-guard this state transition.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

Comment on lines +124 to +126
await _secureStorage?.delete(key: _authTokenSecureKey);
}
await _syncNativeAuthToken('');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear the native token even when secure deletion fails

When Keychain or encrypted-storage deletion throws during logout or terminal session invalidation, execution never reaches the native-mirror cleanup, leaving nativeAuthToken available to the Android foreground service after Dart has cleared its in-memory token. The service can therefore continue authenticating and uploading audio under the signed-out account until that bearer expires; make native cleanup unconditional, such as through a guarded finally, while still surfacing the secure-delete failure.

AGENTS.md reference: AGENTS.md:L44-L48

Useful? React with 👍 / 👎.

<!-- Add android:stopWithTask option only when necessary. -->
<application
android:name=".MyApp"
android:allowBackup="false"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude preferences from Android device transfers

Because this app targets SDK 36, allowBackup="false" is not sufficient to reliably suppress Android 12+ OEM device-to-device migration. On affected devices the shared-preferences files—including the new plaintext native token and encrypted-storage metadata—can still be transferred without the source device's Keystore key, recreating the restore failure this hardening intends to prevent and potentially copying the bearer token. Add android:dataExtractionRules that explicitly excludes the relevant sharedpref data from both cloud backup and device transfer.

AGENTS.md reference: app/AGENTS.md:L111-L115

Useful? React with 👍 / 👎.

@undivisible
undivisible merged commit 96ca850 into main Aug 22, 2026
37 of 39 checks passed
@undivisible
undivisible deleted the harden/native-auth-token-backup branch August 22, 2026 12:25
mdmohsin7 added a commit that referenced this pull request Aug 30, 2026
## Summary

Crashlytics reports a fatal `PlatformException(Unexpected security
result code, Code: -25299, Message: The specified item already exists in
the keychain.)` from `SharedPreferencesUtil._writeSecureAuthToken`
(preferences.dart:116). Two defects meet there.

**The failure is fatal for no reason.** The `authToken` setter fires its
secure-storage write unawaited, so a keychain rejection escapes as an
unhandled async error and `main.dart` records it through
`recordError(..., fatal: true)`. The app keeps running; only the crash
report says otherwise.

**-25299 is reachable in normal use.** `flutter_secure_storage` 9.2.4
writes with a check-then-add
(`ios/Classes/FlutterSecureStorage.swift:172-222`): `containsKey` first,
then `SecItemAdd` when the probe reports absent. The probe misses an
entry that exists in two ways we hit:

- Entries written between #11917 and #12053 carry the plugin default
`kSecAttrAccessibleWhenUnlocked`; #12053 switched writes to
`first_unlock`. A `WhenUnlocked` entry cannot be matched while the
device is locked — and the app refreshes tokens in the background during
BLE streaming and CoreBluetooth restore — so the probe says absent and
the add collides. The plugin's own repair path (delete at every
accessibility, then add) only runs when the probe *found* the entry, so
it never fires here.
- The setter is fire-and-forget and the plugin answers each method call
on a background dispatch queue, so two overlapping writes both probe
"absent" and both add.

## What changed

- Keychain calls are serialized through one queue, so overlapping writes
cannot both take the add path.
- Failures are reported as a value and logged instead of thrown, so a
keychain that refuses the write never becomes a fatal crash. The
in-memory token keeps the session alive.
- On -25299 the entry is deleted with no accessibility in the query —
the only query that matches whatever accessibility it was stored under —
and re-added.
- Sign-out deletes use that same query. Previously they ran with
`first_unlock` in the query, so a legacy `unlocked` entry survived
logout in the keychain.
- The migration checks whether the write landed before dropping the
prefs copy, so a keychain that never accepts the entry still retries on
the next launch instead of losing the token.

Second commit, adjacent defect in the same function: #12053's plaintext
mirror (`nativeAuthToken`) exists for the Android background streaming
service, whose Kotlin cannot reach the Flutter keychain wrapper
(`OmiBackgroundAudioStreamer.kt:306`). It runs on every platform, so on
iOS and macOS #11917's move of the token into the Keychain was undone by
a plaintext `NSUserDefaults` copy that nothing reads. It is now written
only where a native reader exists, and cleared everywhere else so an
earlier build's copy is dropped on the next launch.

If the keychain is genuinely locked since boot, the recovery delete
cannot match either and the write still fails — now logged rather than
fatal, and it self-heals through the plugin's own update path on the
next write after unlock.

## Tests

`app/test/unit/auth_token_secure_storage_migration_test.dart`, via a
`FlutterSecureStorage` fake that can reject writes:

- a duplicate-item write recovers by deleting the stale entry and
rewriting;
- overlapping token writes never run concurrently in the keychain
(`maxConcurrent == 1`), the regression that produced the collision;
- a keychain that rejects the write leaves the prefs token and the
migration flag intact, so the next launch retries;
- the plaintext mirror is cleared where no native reader consumes it,
and a token rotation does not write it back.

## Verification

**I could not run `flutter test` for this change.** The Flutter SDK path
on this machine is unreadable from the agent sandbox (`Operation not
permitted` on every file under it), so the suite and `dart format` did
not run here. The deterministic manifest did: `make preflight` passes
all 13 checks, and the push used the pre-push gate's documented
`PRE_PUSH_SKIP_DART_FORMAT=1` hatch, so CI's `app-dart-format` is the
first machine to see the formatting. The four tests above are written
but unexecuted; please run:

```
cd app && flutter test test/unit/auth_token_secure_storage_migration_test.dart
```

Device check worth doing before merge: sign out and back in on iOS,
confirm the session survives a cold start, and confirm the crash stops
appearing in Crashlytics for builds carrying this change.

## Failure class

No existing class in `.github/failure-classes/` describes this contract
— a fire-and-forget platform write whose rejection escapes as a fatal
error, over a store whose existence probe cannot distinguish absent from
inaccessible. I did not mint one for a single instance; say the word if
you want it registered.

Failure-Class: none


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/BasedHardware/omi/pull/12420?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
pull Bot pushed a commit to Mu-L/omi that referenced this pull request Aug 30, 2026
The auth token setter fires its secure-storage write unawaited, so a
PlatformException from the keychain escapes as an unhandled async error
and main.dart records it as a fatal crash. iOS reported -25299
(errSecDuplicateItem): flutter_secure_storage writes with a
check-then-add, and its existence probe misses an entry it cannot match
- one stored under a different kSecAttrAccessible (entries written
between BasedHardware#11917 and BasedHardware#12053 carry the plugin default rather than
first_unlock) while the device is locked, or one added by a write racing
this one, since the setter is fire-and-forget and the plugin answers
each call on a background queue.

Serialize keychain calls through one queue, report failures as a value
instead of throwing, and on -25299 delete the entry with no accessibility
in the query (the only query that matches whatever it was stored under)
before re-adding. Sign-out deletes now use that same query, so a legacy
entry no longer survives logout. The migration checks the write result
before dropping the prefs copy, so a keychain that never accepts the
entry still retries next launch.

Failure-Class: none

Verification: could not run `flutter test` in this environment - the
Flutter SDK path is unreadable from the agent sandbox. Added three
regression tests in
app/test/unit/auth_token_secure_storage_migration_test.dart covering the
duplicate-item recovery, write serialization, and the migration retry.
pull Bot pushed a commit to Mu-L/omi that referenced this pull request Aug 30, 2026
BasedHardware#12053 mirrors the live auth token into plain SharedPreferences so the
Android background streaming service can authenticate, since Kotlin
cannot reach the Flutter keychain wrapper
(OmiBackgroundAudioStreamer.kt reads nativeAuthToken). The mirror runs on
every platform, so on iOS and macOS BasedHardware#11917's move of the token into the
Keychain is undone by a plaintext copy in NSUserDefaults that nothing
reads.

Mirror only where a native reader exists, and clear the key everywhere
else so a copy an earlier build wrote is dropped on the next launch. The
Android reader keeps its legacy authToken fallback, so a device that has
not relaunched since the update still authenticates.

The platform decision is injectable because the host running
`flutter test` is never the Android device the mirror serves.

Verification: could not run `flutter test` in this environment - the
Flutter SDK path is unreadable from the agent sandbox. The mirroring
tests now declare which side they exercise, and a new test covers the
non-Android side: the pre-existing key is cleared at init and a token
rotation does not write it back.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant