Implement Rust-owned Android Keystore secure storage (#93)
Dependency security audit / rustsec (push) Failing after 23s
Dependency security audit / rustsec (push) Failing after 23s
This commit is contained in:
1 parent
874c8139a4
commit
1abd75282e
36 files changed
+4380
-37
No files matched your search
@@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
@@ -65,7 +65,7 @@ milestone. This list gives ownership, not permission to skip acceptance criteria
|
||||
| [#90 Private paths/config/cache/backup](https://git.rfc1437.de/hugo/IronStorage/issues/90) | crates/storage plus Android path adapter | Context roots/capabilities, atomic layout/migrations, backup rules; Rust and sandbox/update tests |
|
||||
| [#91 Platform-neutral mobile contract](https://git.rfc1437.de/hugo/IronStorage/issues/91) | crates/storage and shared UniFFI bridge | Typed semantic actions/icons, opt-in Watch data, platform callbacks, shared Swift/Kotlin inventory tests |
|
||||
| [#92 Native Rust/UniFFI packaging](https://git.rfc1437.de/hugo/IronStorage/issues/92) | Shared/Android Rust bridge and Gradle rust-bridge | cargo-ndk, generated immutable Kotlin/JNA Android, three ABIs, checksums/16 KB/loading/debug symbols |
|
||||
| [#93 Keystore adapter](https://git.rfc1437.de/hugo/IronStorage/issues/93) | crates/storage secure-secret orchestration and platform adapter | Framework Keystore AES-GCM; callback facts, sealed records, invalidation/recovery tests |
|
||||
| [#93 Keystore adapter](https://git.rfc1437.de/hugo/IronStorage/issues/93) | Delivered: Rust sealed-record orchestration and Android OS adapter | [Keystore policy and recovery](keystore.md); native AES-GCM/CryptoObject and Rust lifecycle tests |
|
||||
| [#94 CI/static analysis/reproducibility](https://git.rfc1437.de/hugo/IronStorage/issues/94) | Build/CI/test infrastructure | Cargo gates, Gradle build/Lint/ktlint, JVM/Compose/instrumented tests, verification/locks/SBOM/ABI checks |
|
||||
| [#95 Local-first/HTTPS onboarding](https://git.rfc1437.de/hugo/IronStorage/issues/95) | storage mobile_onboarding plus Compose features | Existing Rust initialization/discovery/clone/credentials; no Kotlin transport |
|
||||
| [#96 Hierarchical navigation](https://git.rfc1437.de/hugo/IronStorage/issues/96) | storage mobile_passwords plus Compose features | Material 3 Adaptive list/detail, typed rows/stable IDs |
|
||||
|
||||
@@ -17,7 +17,7 @@ activation fail instead of consulting HOME, cwd, XDG or public storage.
|
||||
| version / device binding | `noBackupFilesDir/ironstorage/layout.toml` | Atomic version 1 marker; unknown versions require a compatible migration |
|
||||
| transactional scratch | `noBackupFilesDir/ironstorage/scratch` | Per-owner file locks; startup removes orphaned transactions and skips live owners |
|
||||
| disposable cache | `cacheDir/ironstorage` | Only this capability is cleared by `clearDisposableCache`; vault/key/catalog data is retained |
|
||||
| protected binding and credentials | OS secure backend / no-backup ciphertext (#93) | No serialized token or passphrase in configuration or layout metadata |
|
||||
| protected binding and credentials | Keystore AES-GCM keys / `noBackupFilesDir/ironstorage/secrets` (#93) | No serialized token or passphrase in configuration or layout metadata |
|
||||
| `filesDir` | OS root fact only | No durable password-store object is placed here |
|
||||
|
||||
Directory opens use safe Rust `rustix` handles with `NOFOLLOW`; names are single
|
||||
@@ -45,9 +45,9 @@ links without following their targets and cannot descend into the vault.
|
||||
persists only its SHA-256 in layout metadata. A missing or mismatched protected
|
||||
record for an already bound layout reports `RestoredWithoutKeystore` or
|
||||
`InvalidSecureBinding`; it never creates a replacement that would conceal key
|
||||
loss. Locked/unavailable storage has separate recovery kinds. #93 supplies the
|
||||
real Android Keystore adapter; the contract already executes through the
|
||||
injected backend and is tested with loss/mismatch fixtures.
|
||||
loss. Locked/unavailable storage has separate recovery kinds. #93 supplies the [real Android Keystore adapter](keystore.md); startup verifies
|
||||
the binding through it before presenting the shell. The contract is tested with
|
||||
loss/mismatch fixtures.
|
||||
|
||||
Errors distinguish full storage, read-only storage, access denial, missing
|
||||
roots, concurrent initialization, corrupt configuration/layout and unsupported
|
||||
|
||||
Reference in new issue
Block a user