92 lines
6.2 KiB
Markdown
92 lines
6.2 KiB
Markdown
# 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.
|