Skip to content

Keep pairing keys through transient Keystore errors - #135

Merged
dzid26 merged 4 commits into
mainfrom
work/key-store-robustness
Oct 7, 2026
Merged

dzid26 merged 4 commits into
mainfrom
work/key-store-robustness

Conversation

@dzid26

@dzid26 dzid26 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Part of #116.

A transient Keystore error could delete a car's pairing key. The app also decrypted private keys just to check that one exists or to show its ID: at startup on the main thread, inside Settings composition, and several times per connection.

PairingKeyStore

  • New hasKey(), publicKeyRaw() and keyId() read only the stored public key. The key ID is SHA-1(public)[:4], the same as TeslaKeyPair.keyId. Only load() touches the private key.
  • A key is deleted only on AEADBadTagException, UnrecoverableKeyException or KeyPermanentlyInvalidatedException. Any other failure returns null and keeps the key. The failure goes to Logcat, without IDs.
  • A decrypt no longer creates the Keystore AES key. Before, a lookup that failed transiently could look like a missing key, and the code then generated a new key under the same alias. That replaced the key wrapping every stored private key. Now a missing Keystore key on decrypt is reported as UnrecoverableKeyException, because the material came from another install. Restored device-only keys are still dropped, as before.
  • Keys are written with commit(). If the key can't be stored, generate() returns null and pairing stops with an error, so addKey is never sent for a key the app could lose.
  • setBackupEnabled now runs on a background thread, so private-key access is serialized across the two instances (controller and Settings).

Callers

  • TeslaBleController uses the public-key accessors everywhere except handleSessionInfo, which needs the private key for TeslaSession.import. The startup stale-slot check uses hasKey().
  • The startup check no longer decrypts, so a device-only key restored from another phone is now found dead at its first session handshake. At that point handleSessionInfo forgets the cached slot and any stale "paired" state. The car then shows as unpaired and offers pairing, as it did before.
  • Settings shows key IDs from the public keys, so nothing is decrypted during composition. It runs setBackupEnabled through rememberCoroutineScope + Dispatchers.IO, and the switch then shows the mode that was actually stored.

VehicleStore

  • If the stored list fails to parse, the raw text is copied to vehicles_unreadable with commit(), before any save() can replace the list, and loading starts from an empty list. The warning leaves out the list and the parser message, because that message quotes the input, VINs included.

Settings backup wording (second commit)

  • On Android 12+, data_extraction_rules.xml doesn't exclude pairing_key.xml, so a backup can contain device-only keys. They stay wrapped by a Keystore key that never leaves the phone, and PairingKeyStore drops them on any other install. Android 11 and below exclude the file (backup_rules.xml).
  • The device-only status line now says a backup can't restore the keys on another phone, so a new phone needs re-pairing with an NFC card tap.
  • The "Stop backup" dialog now says new backups can't restore the keys on another phone, while usable copies already in Android backup may remain until they are replaced.
  • (Third commit) The "Include in backup" dialog no longer says each key "never leaves" the device: it says only this phone can use a device-only key, and turning backup on lets Android backup restore the keys on a new phone.
  • (Fourth commit) Below Android 12, where backup_rules.xml always excludes pairing_key.xml, the switch shows off and disabled with a line saying key backup needs Android 12 or newer, and the enable dialog never opens; a setting already on there keeps its storage (no migration; reinstall to reset) and its status text no longer claims a restore.

Not in this PR (hence "Part of"):

  • Per-link caching of the public key and key ID. Each read is a prefs lookup plus SHA-1.
  • Unit tests with a fake Keystore.
  • The docs mismatch, which a separate PR handles.
  • Moving generate() off the main thread when pairing starts from the UI.

Type

  • Bug fix
  • Feature
  • Protocol / crypto change
  • Docs / CI / tooling
  • Refactor (no behavior change)

Testing

The app module can't be built where this was written (Google Maven is blocked). Verified there:

  • ktlint and detekt, using the repo config, on the four changed files: clean.

  • The changed PairingKeyStore, VehicleStore and TeslaBleController compile against the real core plus minimal Android stubs. A replica of the changed Settings logic compiles against kotlinx-coroutines. SettingsScreen.kt itself was not compiled, because it needs Compose.

  • A throwaway JVM harness (not committed) ran the real PairingKeyStore and VehicleStore against in-memory SharedPreferences and a fake AndroidKeyStore provider doing real AES-GCM. Its 17 cases cover:

    • round trips;
    • the public accessors never touching the Keystore;
    • a different, missing, unrecoverable or invalidated wrapping key (the key is dropped);
    • a transient error or corrupt material (the key is kept);
    • a failed generate or commit;
    • both backup directions;
    • the unreadable vehicle list (copy kept, no VIN logged).

    Flipping the definite-loss check in either direction makes the harness fail.

  • No new tests in the repo. The app module has no Robolectric, and these classes call Android framework APIs directly.

Needs a real-car check:

  • pairing a car with a fresh key (card tap, then a session);

  • the session after an app restart (no re-pairing, sessions come up);

  • Settings shows each car's key ID, and the backup toggle still converts keys both ways.

  • ./gradlew :core:test (core untouched)

  • ./gradlew :app:testDebugUnitTest (left to CI)

  • ./gradlew :app:lintDebug (left to CI)

  • Real car (see above)

Checklist

  • Protocol changes ship test vectors; expected.txt regenerated, not hand-edited (n/a: no protocol change)
  • No VINs, keys, or decrypted payloads in logs
  • Docs/master-plan updated where relevant (docs are left to the separate docs PR)
  • New source files carry SPDX-License-Identifier: AGPL-3.0-only (n/a: no new files)

Notes for review

  • On keystore2 (Android 12+), an UnrecoverableKeyException from the key lookup can also wrap a transient failure ("Failed to obtain information about key"). Following the issue, it still counts as definite. A follow-up could narrow it, for example by checking the cause's isTransientFailure() on API 33+.
  • If the car no longer has a restored device-only key, no handshake ever decrypts that key. The car then keeps its cached "Paired · slot N" until Settings → Clear pairing cache. Dropping the cached slot when a whitelist check doesn't list our key would close that gap.

🤖 Generated with Claude Code

https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX


Generated by Claude Code

claude added 4 commits October 7, 2026 21:13
PairingKeyStore deleted a car's key whenever a Keystore decrypt failed,
and the app decrypted private keys just to check that a key exists or to
show its ID: at startup on the main thread, in Settings composition, and
several times per connection. A transient Keystore or Binder error could
therefore cost a re-pairing and leave the old key enrolled in the car.

PairingKeyStore:
- Add hasKey(), publicKeyRaw() and keyId(), which read only the stored
  public key (key ID = SHA-1(public)[:4], as in TeslaKeyPair.keyId).
- Delete a key only on AEADBadTagException, UnrecoverableKeyException or
  KeyPermanentlyInvalidatedException; any other failure returns null and
  keeps the key. A decrypt no longer creates the Keystore AES key: a
  failed lookup used to mint a new key under the same alias, replacing
  the one that wraps every stored private key. A missing Keystore key is
  reported as UnrecoverableKeyException (material from another install).
- Write keys with commit(); generate() returns null when the key could
  not be stored, so addKey is never sent for a key the app may lose.
- Serialize private-key access across instances (controller, Settings).

Callers:
- TeslaBleController uses the public-key accessors everywhere except
  handleSessionInfo, which needs the private key for the ECDH step. The
  startup stale-slot check uses hasKey().
- Pairing stops with an error when a fresh key cannot be saved.
- When the store drops a key this phone can never decrypt (for example a
  device-only key restored from another phone), handleSessionInfo forgets
  its cached slot so the car shows unpaired and can pair again; the
  startup check no longer decrypts, so it cannot catch that case.
- Settings shows key IDs from the public keys and runs setBackupEnabled
  on Dispatchers.IO; the switch then shows the stored mode.

VehicleStore keeps a list it cannot parse under vehicles_unreadable
(committed before any save() can replace the list) and logs the failure
without the list contents, since the parser message quotes VINs.

Part of #116.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX
On Android 12+ data_extraction_rules.xml does not exclude
pairing_key.xml, so a backup can contain device-only keys. They stay
wrapped by a Keystore key that never leaves the phone, and
PairingKeyStore drops them on load anywhere else. Android 11 and below
exclude the file (backup_rules.xml).

Say that a backup can't restore device-only keys on another phone,
rather than that they stay out of backups, and that usable copies
already in Android backup may remain after backup is turned off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX
The "Include vehicle keys in Android backup?" dialog said each
device-only key "never leaves" the device. On Android 12+ a backup can
hold a wrapped copy, which no other phone can decrypt. Say instead that
only this phone can use the key, and that turning backup on lets Android
backup restore the keys on a new phone, matching the status line and the
"Stop backup" dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX
On Android 11 and below, backup_rules.xml always leaves pairing_key.xml
out of backups, so turning the switch on there only stored the keys
without Keystore encryption and made nothing restorable. Below Android
12, show the switch off and disabled with a line saying key backup needs
Android 12 or newer, and never open the enable dialog.

A setting already turned on there keeps its key storage (pre-1.0: no
migration; reinstall to reset), and its status text no longer claims a
restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129RzhbEE2cboMcYyZMuxqX
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Screenshots

App screenshots: light and dark

Captured from the demo build at 5fe0410.

@dzid26

dzid26 commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

On keystore2 (Android 12+), an UnrecoverableKeyException from the key lookup can also wrap a transient failure ("Failed to obtain information about key"). Following the issue, it still counts as definite. A follow-up could narrow it, for example by checking the cause's isTransientFailure() on API 33+.
If the car no longer has a restored device-only key, no handshake ever decrypts that key. The car then keeps its cached "Paired · slot N" until Settings → Clear pairing cache. Dropping the cached slot when a whitelist check doesn't list our key would close that gap.

Good suggestions

@dzid26
dzid26 merged commit 547c262 into main Oct 7, 2026
4 checks passed
@dzid26
dzid26 deleted the work/key-store-robustness branch October 7, 2026 21:52
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.

2 participants