Skip to content

android: treat a file that can no longer be decrypted as absent - #160

Merged
hpoul merged 3 commits into
mainfrom
android-heal-unreadable-file
Oct 19, 2026
Merged

hpoul merged 3 commits into
mainfrom
android-heal-unreadable-file

Conversation

@hpoul

@hpoul hpoul commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

A stored file whose keystore key is gone fails every read, permanently. Auto Backup and device-to-device transfer carry an app's files to a new phone but never its keystore keys. The decrypt path then minted a fresh key, and every later read failed the GCM tag check, with nothing to clear it on a store without authentication.

  • Reading never creates a key. A missing key is decided with KeyStore.getKey, which returns null only when the keystore positively reports no such key and throws for every other failure. containsAlias would not do: it answers false for every keystore failure, such as the daemon restarting.
  • An unreadable file is moved aside to <name>.v2.txt.unreadable, not deleted. That covers no key, a failed tag check, or a file too short to hold an IV and a tag. A wrong verdict costs a rename, not the data, and delete removes the moved-aside file.
  • Writes go to <name>.v2.txt.partial, are synced, then renamed over the file, so neither a killed process nor a power loss can leave a corrupt or empty file.

Bumps to 6.0.0-dev.7.

Verified on Android emulators:

  • Before the fix (API 34): the keyless and corrupt-file integration tests fail on main with Signature/MAC verification failed on every read.
  • After the fix (API 36): the suite is 10 of 10. That includes a too-short file, and the keyless test asserting the .unreadable file and its removal.

The review that found the containsAlias problem worked from keystore2's AndroidKeyStoreSpi and AndroidKeyStoreCipherSpiBase source.

🤖 Generated with Claude Code

hpoul and others added 3 commits October 19, 2026 14:23
A stored file whose keystore key is gone failed every read for good.
Auto Backup and device-to-device transfer carry the app's files to a new
phone but never keystore keys, and `getOrCreateSecretKey` then minted a
fresh key, so every later read failed the GCM tag check. With
`authenticationRequired: false` nothing ever clears that: no prompt, no
lockout, just an error on every read until the app's data is cleared.

`cipherForDecrypt` now checks the key exists before asking for a cipher;
a file without one is deleted and reads as absent. A file that fails
the tag check with its key present — a key minted over a restored file
by an earlier version, or a torn write — is deleted the same way on
read. Either way the next write starts clean.

Writes now go to `<name>.partial` and are renamed over the file, so a
process killed mid-write leaves the old file or the new one.

Verified on an API 34 emulator: two new integration tests (a file put
back after its key was removed; a file whose tag was flipped) fail on
main with "Signature/MAC verification failed" on every read and pass
with this change; the suite is 9 of 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aside

The previous commit deleted a file whenever KeyStore.containsAlias said
its key was missing. On Android 12+ containsAlias answers false for every
keystore failure, not only "not found" — the daemon restarting, a busy
backend, a locked user — so a hiccup on a read after boot would have
deleted a stored secret for good, on authenticated stores too, before
any prompt. KeyStore.getKey throws for those and returns null only when
the keystore positively reports no such key, so the decision is now made
on that, and reading never creates a key: a key minted on the decrypt
path was what made a restored file fail its tag check forever.

A file judged unreadable — no key, a failed tag check, or too short to
hold an IV and a tag — is now renamed to `.unreadable` instead of
deleted, so a wrong verdict costs a rename and not the data; `delete`
removes it. Below Android 12 nothing on the API surface can tell a lost
binder from a missing key, which is the strongest reason for the rename.

Writes are synced before the rename, so a power loss cannot leave an
empty file under the real name, and a failed write removes its partial
file. An empty or short file, what a power loss left before, now reads
as absent instead of throwing on every read. The IV read closes its
stream and throws IOException on a short read instead of asserting.

Found in review, by reading keystore2's AndroidKeyStoreSpi and
AndroidKeyStoreCipherSpiBase. Verified on an API 36 emulator: the
integration suite, now 10 tests with one for a short file and the
keyless test asserting the `.unreadable` file and its removal by
delete, passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A file moved aside as unreadable stayed behind when delete was called
on a store with no file, because the handler only deleted when the main
file existed. It now also deletes when a partial or set-aside file is
there; the answer is still deleteFile's, true only when a stored value
was deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hpoul
hpoul merged commit ca4cc1a into main Oct 19, 2026
15 checks passed
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