Files
DodoSSH/src/DodoSSH.Client.Android/Platform/AndroidDeviceKeyStore.cs
T
jaap-jan fe9d7fc289 Give DodoSSH a phone, and a shared shell for both heads to drive
The Android head from docs/android-port.md, taken as far as its step 6.

Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and
libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android
despite shipping no Android build, and the local cache opens. Two findings the audit
could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which
settles the open "which Android versions" question at targetSdk 36; and Android has
blocked cleartext HTTP since API 28, so the terminal renderer needs a network security
config scoped to 127.0.0.1 or the WebView loads nothing.

DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal
renderer files and the palette moved there so both heads drive one state machine and draw
from one set of tokens. The desktop head is otherwise untouched and its 144 tests still
pass.

The platform pieces behind interfaces that already existed: the profile directory from
filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and
a foreground service so a shell outliving a vault lock stays true on a platform that
stops backgrounded processes.

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
2026-07-31 20:58:48 +02:00

224 lines
9.6 KiB
C#

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;
/// <summary>
/// This phone's device key, wrapped by a hardware-held key that a fingerprint releases.
/// </summary>
/// <remarks>
/// <para>
/// The Android counterpart of the desktop head's <c>WindowsDeviceKeyStore</c>, 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.
/// </para>
/// <para>
/// <b>The X25519 scalar is not stored in the keystore</b>, 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.
/// </para>
/// <para>
/// <b>StrongBox is asked for and not required.</b> Where the phone has a separate security chip the key
/// lives there; where it does not, generation throws
/// <see cref="StrongBoxUnavailableException"/> 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.
/// </para>
/// <para>
/// <b>Every failure here is answered by returning null rather than throwing</b>, 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.
/// </para>
/// </remarks>
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";
/// <summary>
/// The nonce is stored ahead of the ciphertext rather than derived.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private const int NonceBytes = 12;
/// <inheritdoc />
/// <remarks>
/// 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.
/// </remarks>
public ValueTask<bool> 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);
}
}
/// <inheritdoc />
public async ValueTask SaveAsync(ReadOnlyMemory<byte> 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);
}
/// <inheritdoc />
public async ValueTask<byte[]?> 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;
}
}
/// <inheritdoc />
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;
}
/// <remarks>
/// <c>SetInvalidatedByBiometricEnrollment</c> 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.
/// </remarks>
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()!;
}
}
}