When people talk about Android Keystore, the conversation often becomes vague very quickly.
Keys are “secure.” The keystore is “hardware-backed.” The device vendor “supports it.”
None of those statements are very useful if you are the person who actually has to prove what the device does, wire it into a service boundary, and decide whether the resulting design is safe enough to ship.
That was the situation I was in.
I was building a proof of concept for a custom Android device supplied by a vendor. On paper, the requirement sounded straightforward: store a key securely, encrypt and decrypt data, and expose those operations through a service so a trusted Linux-side component could use them later.
In practice, that immediately turned into a more useful set of questions.
The device eventually reported what I wanted to see:
- `securityVerdict=HARDWARE_BACKED_TEE`
- `securityLevel=Trusted Environment (TEE)`
- `insideSecureHardware=true`
That was a good result. More importantly, getting there clarified a few things that are easy to blur together:
- Android Keystore is not the same thing as hardware backing
- hardware-backed does not necessarily mean StrongBox
- a Binder service can preserve key isolation while still becoming a decryption oracle if you design it badly
The project is now open source, but the more useful part of the exercise was understanding exactly what was being proven, what was not, and where the real security boundary actually sits.
The First Problem Was Not Crypto. It Was Ambiguity
The starting question was not “how do I call AES-GCM on Android?”
That part is comparatively easy.
The real question was this: on this custom device, can I prove that the key lives in secure hardware rather than in a software-only keystore path?
That distinction matters because an app can use `AndroidKeyStore` without getting hardware backing. The API surface alone does not guarantee TEE or StrongBox. It only gives you the route into whatever the device vendor actually implemented underneath.
So the PoC had to do more than round-trip some ciphertext. It needed to:
- generate the key inside `AndroidKeyStore`
- use it for real encryption and decryption
- inspect the resulting key metadata
- classify the device report into something easier to reason about than raw platform constants
That last part ended up mattering more than I expected, because it turned platform detail into something I could actually reason about.
That gave me a much cleaner way to discuss the outcome with other engineers and with the vendor. Instead of a wall of Android terminology, I could reduce the result to one line:
`HARDWARE_BACKED_TEE`
That is not StrongBox, but it is still a meaningful positive result.
StrongBox Was Not The Goal. Hardware Backing Was
There is a habit in Android security discussions where StrongBox gets treated as if it is the only interesting secure hardware outcome.
It is not.
StrongBox is a stricter and usually better form of hardware isolation, but many devices do not have it. A device can still be meaningfully hardware-backed through the TEE, and for this PoC that was the main thing I needed to establish first.
The test device did not report StrongBox support. It reported TEE-backed keystore instead.
That still means the key material is meant to stay outside ordinary Android userspace, which was the core property I cared about.
The more useful hierarchy here is:
- software-backed
- hardware-backed TEE
- hardware-backed StrongBox
If you are evaluating a custom device, the first important win is proving you are not in the software-only category.
The First Failure Was A Good Reminder That “Supported By Android” And “Supported By This Device” Are Not The Same Thing
One of the more useful moments in this PoC came from a failure.
My initial key generation request included `setUnlockedDeviceRequired(true)`. That is a legitimate Android capability, but on this device it caused key generation to fail. Once I removed it, the flow worked.
That was a useful reminder because it shifted the conversation away from abstract platform support and back to the only thing that really matters in this kind of work: what this build on this device actually does.
There are two reasons I think that detour was valuable.
The first is practical: vendor implementations can be incomplete, buggy, or selectively compatible in ways that the Android API surface does not make obvious.
The second is more methodological: a crypto PoC should not try to look clever. It should try to be diagnostic. When something fails, you want the result to teach you what the platform will and will not accept.
The Binder Service Made The Architecture More Real
Once the keystore result was clear, the next step was to stop thinking about it as an app-only demo.
The real target architecture involved a service boundary. The app needed to be able to call into a service, and a future trusted Linux-side component would need to do the same on-device.
That pushed the PoC toward an AIDL contract instead of a direct in-app helper.
This was the point where the project stopped being just a keystore experiment and started looking like an actual system integration problem.
That matters because service design changes the threat model. It is one thing to say “my app can use a hardware-backed key.” It is another to say “I now have a Binder service that will perform crypto on behalf of other callers.”
Those are related statements, but they are not equivalent from a security point of view.
The Most Important Security Lesson Was Not About The Key. It Was About The Caller
It is easy to say “the key never leaves secure hardware” and feel as if the hard part is done.
It is not done.
If your Binder service exposes a generic `decrypt()` operation, then every caller you authorize effectively gains the ability to ask the service for plaintext. The key may remain protected in the TEE, but your design can still become a decryption oracle.
That was the most important architectural lesson in the whole PoC.
Hardware-backed key storage protects the key material. It does not automatically protect the plaintext you hand back to a trusted caller.
That is why I tightened the service boundary in two layers:
- a signature-level permission in the manifest
- a runtime caller check that allows only same-signature apps, `shell`, `system`, or `root`
That is a much better starting point than an exported service with no meaningful access control. But it is still only a starting point.
The long-term hardening move is not “more permission checks.” It is reshaping the API so trusted callers can request only the narrow operation they actually need.
I do not have the final decrypt or post-decrypt use case yet, so I deliberately stopped short of pretending I knew the perfect contract. That would have been dishonest. But the direction is clear already: the final production service should do less, not more.
The Linux Machine Was Never Really Talking To Binder
Another thing this PoC clarified was the difference between an on-device trusted component and a host machine.
The future Linux service is expected to be trusted or system-level later, but a workstation is not an Android Binder peer. Binder is local to Android. So the host-side script in this project is really a debug transport, not part of the production trust model.
That is why the testing path uses `adb` and a shell-only bridge instead of pretending the workstation is a first-class Binder client.
That split ended up being helpful because it kept the security story honest.
The workstation path is there to prove the theory and automate the checks. It is not the thing you should model your production trust boundary around.
So What Does A Future Trusted Linux Or System Service Actually Need
At this point the answer is fairly concrete.
If the future caller is another app or app-like component, it needs:
- the same signing certificate, or an explicit trust relationship introduced later
- the signature permission `dev.jamescullimore.trustkeyservice.permission.ACCESS_KEYSTORE_SERVICE`
- the Binder contract `IKeystoreCommandService`
If the future caller is a native on-device system component, the important thing is not Android manifest permission syntax in the usual app sense. The important thing is that it runs under a trusted identity, such as `system` or `root`, and that the final deployment model reflects that trust relationship intentionally.
In other words, the permission story depends on what the caller actually is:
- app caller: signature and manifest permission matter
- native system caller: Binder identity, deployment, and eventually SELinux matter more
That is the sort of detail I wanted this PoC to flush out early rather than late, because these trust assumptions get much harder to clean up once the integration is already underway.
The Result Was Good, But It Was Not Magical
By the end of the PoC, the result on the test device was exactly the kind of outcome I was hoping to get:
- the key generated successfully
- encryption and decryption worked
- the platform reported `Trusted Environment (TEE)`
- the platform reported `insideSecureHardware=true`
That is enough to say the device is exposing a hardware-backed keystore path through the TEE.
What I would not say is that the problem is finished.
The keystore question is largely answered. The bigger remaining design question is what the service should actually do once the final trusted caller is known. That is where the difference between a good PoC and a production security boundary usually lives.
Closing Thoughts
The part I like most about this exercise is that it forced the fuzzy words to become specific.
Not “secure.”Not “hardware-backed.”Not “vendor-supported.”
Specific.
TEE, not StrongBox.Hardware-backed, not software-backed.Binder-protected, but still not safe enough to expose arbitrary decrypt forever.
That is a much better place to be. It means the next conversation can focus on the real thing that still matters: what the trusted caller actually needs, and how to design the narrowest possible service contract around it.