Files
IronStorage/docs/android/keystore.md
T
hugo 1abd75282e
Dependency security audit / rustsec (push) Failing after 23s
Implement Rust-owned Android Keystore secure storage (#93)
2026-10-08 16:22:57 +02:00

92 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Android protected secrets (#93)
`crates/storage::secret_store::android` implements the secure-secret backend.
`MobilePrivateRuntime.androidSecretBackend` connects it to a narrow UniFFI OS
callback. Core translates callback types; platform calls Android Keystore,
KeyguardManager and AndroidX BiometricPrompt. No frontend reads a password-store
repository, chooses a credential identity/protection, persists ciphertext, or
implements encryption itself. App startup verifies its existing private device
binding using this real backend before presenting the Rust shell.
The OS holds non-exportable AES-256-GCM keys. Rust stores versioned sealed
records under `noBackupFilesDir/ironstorage/secrets`, with 0700 directories and
0600 files. Service/account identities become SHA-256 names using
length-delimited namespace fields; OpenPGP fingerprints normalize to uppercase.
Each write creates a random generation-specific alias. GCM authenticates the
record version, identity, protection class and complete alias; its nonce is the
12-byte IV generated by the OS. Authentication happens before supplying AAD to
an authorization-bound cipher. No plaintext token, account record or passphrase
is serialized in ordinary settings, the layout marker or logs.
| Storage policy | Android key and operation |
| --- | --- |
| DeviceUnlocked (Git credentials/device binding) | No interactive authentication; Keyguard checks before and after each operation, plus `setUnlockedDeviceRequired` from API 28 |
| BiometryCurrentSet (optional enrolled GPG passphrase) | Per-operation `BIOMETRIC_STRONG` CryptoObject, enrollment invalidation enabled, secure lock required; no PIN fallback |
| RequireUserPresence | From API 30, per-operation `BIOMETRIC_STRONG \| DEVICE_CREDENTIAL` CryptoObject; the configured credential flow is explicit |
API 26–27 use the OS locked-state checks for DeviceUnlocked and the legacy
per-operation biometric key parameter. API 28–34 require a secure screen lock
because those releases have documented unlocked-key bugs. API 35+ support
unlocked keys without a secure lock. Unsupported protection returns a typed
error; it never downgrades an enrolled passphrase to device-only protection.
Rust restricts OpenPGP locators to the two interactive protection classes and
Git/device-binding locators to DeviceUnlocked. Factory failures become storage
recovery state in Rust; Kotlin only presents that state.
Android invalidates biometric-bound keys when a biometric is added or all
biometrics are removed; removing a secure lock also invalidates authentication
keys. Removing one of several original enrolled biometrics does not grant a new
biometric access. These are the [Android key-policy guarantees](https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder),
not a claim of extra OS enrollment APIs or physical-device validation.
StrongBox is requested only when the framework advertises it. A typed
StrongBox-unavailable generation failure retries the same specification using
ordinary Keystore; authentication and unlocked-device requirements stay exact.
Other generation/cipher failures never trigger a weaker key or a retry without
authentication. The persistent emulators exercise the non-StrongBox path; Rust
mocks exercise the advertised-but-unavailable fallback.
A private advisory lock serializes record transactions. Create rejects an
existing record; replace rejects an absent one. Replacement seals with a new
key, syncs a private atomic file, renames it and syncs the directory before
retiring the previous key. Cipher/authentication/write failure preserves the
previous record and removes the candidate key. If durability fails after rename,
the new key is retained because the new record may already be installed.
Delete unlinks the selected sealed record and deletes its key without requiring
biometric authentication, so disabling enrollment can remove protected data.
Startup removes only well-formed owned orphan aliases and unlocked owned atomic
files, retaining keys when any record cannot be parsed. Unrelated aliases are
left alone. Records are bounded to 16 KiB and startup enumeration is bounded.
Missing ciphertext, missing device keys, invalidated keys, corruption,
duplicate, cancellation, denial, device lock, lockout, unsupported policy and
unavailability have separate Rust-visible errors. Existing bound data with a
missing Keystore key reports restore recovery and never receives a replacement
binding. Tampering or changing the requested policy fails closed. Background
lock increments the Rust generation and cancels the pending OS prompt; a late
cipher result cannot return plaintext for an obsolete generation.
Kotlin uses the exact authorized Cipher returned in the CryptoObject callback,
one pending request, a bounded wait and cancellation. OS input arrays are
zeroed in `finally`; generated foreign callback return arrays are erased after
serialization. Rust immediately owns cleartext in `SecretBytes`, including
incoming create/replace buffers. Errors and generated diagnostics are redacted.
The JVM, OS cipher implementation and UniFFI serialization can still make
transient copies; this is not a guarantee of complete memory erasure. There is
no EncryptedSharedPreferences, MasterKey, alternative repository format or
application subprocess.
Tests use public fixture secrets and synthetic emulator fingers. Rust exercises
all record operations, policy mappings, failed replacement, tampering, restore,
orphan ownership, lock/generation races, shared credential encoding and actual
mobile biometric-disable deletion. Android instrumentation uses real Keystore
keys and real CryptoObject prompts; its host is confined to the test APK.
Enrollment/lock removal deliberately invalidate only disposable fixture keys.
Android 35 delivers the direct lockout callback, verified as `Lockout`. Android
37 instead offers an OS PIN screen to recover the sensor during biometric-only
authentication. The emulator test enters that PIN and verifies the operation
still requires a matching fingerprint; cancelling then returns `Cancelled` and
no cipher result. The PIN is never added to the biometric-only key's permitted
authenticators. Native callback tests also verify Rust-visible lockout, policy
rejection, factory recovery mapping and post-serialization array erasure.