using global::Android.App; using global::Android.Security.Keystore; using global::Java.Security; using global::Javax.Crypto; using global::Javax.Crypto.Spec; using DodoSSH.Client.Session; namespace DodoSSH.Client.Android.Platform; /// /// This phone's device key, wrapped by a hardware-held key that a fingerprint releases. /// /// /// /// The Android counterpart of the desktop head's WindowsDeviceKeyStore, and a closer match to what /// the unlock screen wants than that one is: the Windows store leans on DPAPI plus a TPM-held key and a /// Hello gesture, whereas here the gesture is a property of the key itself and the platform will not /// release it without one. See ADR 0007 and docs/android-port.md §4. /// /// /// The X25519 scalar is not stored in the keystore, and cannot be: Android's keystore holds keys it /// generates and refuses to export them, while what the vault needs back is the raw 32 bytes. So the /// keystore holds an AES-GCM key that never leaves the secure hardware, and that key encrypts the scalar /// into an ordinary file beside the cache. The file is useless on its own — on this phone as much as on /// any other — which is the same shape the Windows store already has. /// /// /// StrongBox is asked for and not required. Where the phone has a separate security chip the key /// lives there; where it does not, generation throws /// and the key is made in the TEE instead. Refusing to fall /// back would mean a mid-range phone reporting no device key at all, which costs a real user a real /// feature to buy a distinction the threat model does not draw. /// /// /// Every failure here is answered by returning null rather than throwing, exactly as the interface /// asks. A user can cancel the prompt, a re-enrolled fingerprint invalidates the key permanently, and a /// phone can have no enrolled biometric at all — and the caller's answer to all three is the same one: /// ask for the passphrase. /// /// internal sealed class AndroidDeviceKeyStore(ClientPaths paths) : IDeviceKeyStore { private const string KeystoreName = "AndroidKeyStore"; private const string KeyAlias = "dodossh.device"; private const string Transformation = "AES/GCM/NoPadding"; /// /// The nonce is stored ahead of the ciphertext rather than derived. /// /// /// The cipher picks it: a GCM key that is asked to encrypt twice under a caller-chosen nonce is one /// misuse away from losing the key, and Android's keystore refuses a caller-supplied IV for exactly /// that reason. Twelve bytes is what it generates. /// private const int NonceBytes = 12; /// /// /// Three things have to be true, and the third is the one that is easy to forget: the platform has a /// keystore, the phone has a screen lock, and something is actually enrolled to satisfy it. A phone /// with no lock screen can still generate a key that requires authentication — and then no gesture can /// ever release it. /// public ValueTask IsAvailableAsync(CancellationToken cancellationToken) { try { var keyguard = PhoneEnvironment.Require() .GetSystemService(global::Android.Content.Context.KeyguardService) as KeyguardManager; return ValueTask.FromResult(keyguard?.IsDeviceSecure == true); } catch (Exception exception) when (exception is not OutOfMemoryException) { return ValueTask.FromResult(false); } } /// public async ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken) { var key = GenerateWrappingKey(); var cipher = Cipher.GetInstance(Transformation) ?? throw new InvalidOperationException("This phone has no AES/GCM/NoPadding cipher."); cipher.Init(CipherMode.EncryptMode, key); // Authenticated before the key is usable, exactly as loading is. Registering a device is itself a // decision worth a gesture — it is the moment this phone gains the ability to open the vault // without the passphrase. await BiometricGate .AuthenticateAsync(cipher, "Register this phone", cancellationToken) .ConfigureAwait(false); var sealed_ = cipher.DoFinal(devicePrivateKey.ToArray()) ?? throw new InvalidOperationException("The keystore cipher returned nothing."); var nonce = cipher.GetIV() ?? throw new InvalidOperationException("The keystore cipher chose no IV."); var blob = new byte[nonce.Length + sealed_.Length]; nonce.CopyTo(blob, 0); sealed_.CopyTo(blob, nonce.Length); paths.EnsureCreated(); await File.WriteAllBytesAsync(paths.DeviceKeyFile, blob, cancellationToken).ConfigureAwait(false); } /// public async ValueTask TryLoadAsync(CancellationToken cancellationToken) { try { if (!File.Exists(paths.DeviceKeyFile)) { return null; } var blob = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken).ConfigureAwait(false); if (blob.Length <= NonceBytes) { return null; } var store = KeyStore.GetInstance(KeystoreName) ?? throw new InvalidOperationException("This phone has no AndroidKeyStore."); store.Load(null); // Null when the key was invalidated — a re-enrolled fingerprint or a reset screen lock does // this, and it is permanent by design. The wrapped file is unopenable from here on, so the // honest answer is the same as having no key: ask for the passphrase. if (store.GetKey(KeyAlias, null) is not IKey key) { return null; } var cipher = Cipher.GetInstance(Transformation) ?? throw new InvalidOperationException("This phone has no AES/GCM/NoPadding cipher."); cipher.Init(CipherMode.DecryptMode, key, new GCMParameterSpec(128, blob, 0, NonceBytes)); await BiometricGate .AuthenticateAsync(cipher, "Unlock DodoSSH", cancellationToken) .ConfigureAwait(false); return cipher.DoFinal(blob, NonceBytes, blob.Length - NonceBytes); } catch (Exception exception) when (exception is not OutOfMemoryException) { // Deliberately flat. KeyPermanentlyInvalidatedException, UserNotAuthenticatedException, a // cancelled prompt and a truncated file are four different stories with one ending, and the // interface says so: distinguishing them would describe the keystore rather than tell the user // anything they can act on. return null; } } /// public ValueTask ForgetAsync(CancellationToken cancellationToken) { try { var store = KeyStore.GetInstance(KeystoreName); store?.Load(null); store?.DeleteEntry(KeyAlias); } catch (Exception exception) when (exception is not OutOfMemoryException) { // Best effort: the file going is what actually withdraws this phone's ability to unlock, and // an orphaned keystore entry opens nothing. } // After the keystore entry, not before. A file left behind with its key already deleted is merely // unopenable; a key left behind with its file already gone is the same. Order costs nothing here, // but the file is the one that matters, so it goes last and unconditionally. if (File.Exists(paths.DeviceKeyFile)) { File.Delete(paths.DeviceKeyFile); } return ValueTask.CompletedTask; } /// /// SetInvalidatedByBiometricEnrollment is on, which is the setting that makes this worth having: /// without it, somebody who can add their own fingerprint to an unlocked phone inherits the ability to /// unlock the vault. With it, enrolling a new fingerprint destroys the key and the phone falls back to /// the passphrase — which is the correct outcome and the reason the load path treats invalidation as /// ordinary rather than exceptional. /// private static IKey GenerateWrappingKey() { static KeyGenParameterSpec.Builder Spec() => new KeyGenParameterSpec.Builder(KeyAlias, KeyStorePurpose.Encrypt | KeyStorePurpose.Decrypt) .SetBlockModes(KeyProperties.BlockModeGcm)! .SetEncryptionPaddings(KeyProperties.EncryptionPaddingNone)! .SetKeySize(256)! .SetUserAuthenticationRequired(true)! .SetInvalidatedByBiometricEnrollment(true)!; var generator = KeyGenerator.GetInstance(KeyProperties.KeyAlgorithmAes, KeystoreName) ?? throw new InvalidOperationException("This phone has no AES key generator in its keystore."); try { generator.Init(Spec().SetIsStrongBoxBacked(true)!.Build()); return generator.GenerateKey()!; } catch (StrongBoxUnavailableException) { // No separate security chip. The TEE-backed key is still hardware-held and still gated by the // same gesture; see the class remarks for why this is a fallback rather than a refusal. generator.Init(Spec().Build()); return generator.GenerateKey()!; } } }