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()!;
}
}
}