Give the phone a way to enrol the fingerprint it already unlocks with
ci / android head (push) Failing after 4s
ci / api image (push) Successful in 35s
ci / build and test (push) Successful in 1m36s

The Android device key store, the biometric gate and the lock screen's UNLOCK
WITH FINGERPRINT button have all shipped since this head was written, and none
of them could ever run: that button appears only when a device key exists, and
nothing on the phone could create one. `CanUnlockWithDevice` was false on every
launch of every phone. This is the missing half.

**The offer is on PREFERENCES**, which held a PendingScreen until it had a
setting on it. It is there rather than beside the button it turns on because
registering needs an unlocked keychain and a reachable server — the vault has to
be open to seal the bundle, and the wrap has to reach the account or a phone
somebody has lost could never be revoked. Neither is true on the lock screen.

One card, and exactly one of its three blocks is ever drawn: the offer, the
withdrawal, or the sentence saying this phone has nowhere to keep a key. That is
`CanRegisterDevice` / `CanForgetDevice` / `HasNoDeviceKeyOption`, which are two
flags and not one and its negation for the reason written where they are set —
a phone with no screen lock and a phone already registered are both "cannot
register", and only the second has anything to take back. The withdrawal has no
confirmation, deliberately, and the sentence above it carries what the desktop
puts in a tooltip this head has no room for. `StatusMessage` is on the screen
because it is the only feedback this head has once the system's own dialogue has
gone.

**Two things would have been wrong in the feature the moment it worked.**

`Environment.MachineName` answers `localhost` on Android, and registering names
the device — so every phone would have arrived in the account's device list as
another identical row, on the very screen a lost handset is revoked from.
`PhoneEnvironment.DeviceName` was already written and never called; the shell
now takes it as an optional constructor argument that the desktop does not pass,
and it reaches enrollment, registration and every connection log entry. That was
gap §7 of docs/android-port.md, and it is now closed.

And the status line said "Waiting for Windows…" over an Android biometric
prompt. `GestureWait` picks the sentence from the platform rather than from a
head, unlike the device name beside it: a device name is a fact about one
handset only the head can read, and which dialogue appears is a fact about the
operating system this assembly is running on.

Two tests cover the seam — the injected name reaching the account, and the
default still being this machine's own name — and `FakeVaultServer` records what
each device called itself, because the name is the only part of a registration a
person ever reads. The gesture itself is unreachable from any test process, so
Phase 13 of docs/manual-checks.md carries five checks, including that enrolling
a new fingerprint in Android's own Settings destroys the key. That one is the
property that makes this a fast path rather than a weakening of the passphrase.
This commit is contained in:
2026-08-03 16:12:50 +02:00
parent fd8497bb76
commit 38d8706784
10 changed files with 327 additions and 20 deletions
+7 -1
View File
@@ -135,7 +135,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
// makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false));
.ConfigureAwait(false),
// Difference 5: what this phone is called. The shell's default is Environment.MachineName, which
// answers localhost here — so without this the account's device list would show one localhost
// per phone, on the very screen a lost device is revoked from, and every log entry a phone wrote
// would name the same machine. See PhoneEnvironment.DeviceName.
deviceName: PhoneEnvironment.DeviceName);
// Started rather than awaited: framework initialisation must not block on a schema migration. The
// view model shows its own progress and handles its own failures.
@@ -157,7 +157,8 @@
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Preferences" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What this phone can and cannot do, and why." />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Unlocking with a fingerprint, and what this phone cannot do." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
@@ -174,10 +174,12 @@
</Panel>
<!--
Preferences keeps PendingScreen for its body — it is still a screen that states what is absent
rather than one with settings on it — and gains the header its siblings under MORE have, here
rather than inside PendingScreen: that control is shared with three states of getting in, none of
which has anywhere to go back to.
Preferences carries the header its siblings under MORE have, written here rather than inside the
screen: the screen is one of two that hang off this hub with a back arrow, and the arrow belongs
to the shell that knows where back is.
It held a PendingScreen until it had a setting on it. The one it gained is this phone's device
key — the fingerprint the lock screen offers and that nothing here could previously enrol.
-->
<Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
@@ -185,9 +187,7 @@
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid>
<views:PendingScreen Grid.Row="1"
Heading="PREFERENCES"
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
<views:PreferencesScreen Grid.Row="1" />
</Grid>
</Panel>
@@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.Android.Views.PreferencesScreen"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
Design v2 — PREFERENCES, and the phone's counterpart of the desktop head's screen of the same name.
It exists for one setting: whether this phone may open the keychain with a fingerprint. That is the
setting the whole Android device key store was built for — AndroidDeviceKeyStore and BiometricGate have
been in this head since it was written — and until this screen there was nowhere to turn it on. The
lock screen offers UNLOCK WITH FINGERPRINT only when a device key exists, and nothing on the phone could
make one, so on a phone that button could never appear. This is the missing half.
Registering has to happen from an unlocked keychain and cannot happen from the lock screen, which is why
it is here rather than beside the button it enables: sealing this phone's device key needs the vault
open, and the wrap has to reach the server or it could never be revoked from a phone that was lost. See
MainWindowViewModel.RegisterDeviceAsync.
Everything the desktop's screen carries and this one does not is stated at the foot rather than left
out, exactly as the desktop states its own gaps. A preferences screen that is silent about what it has
not got reads as a product with one preference.
-->
<ScrollViewer>
<StackPanel Margin="16,0,16,24" Spacing="0">
<TextBlock Classes="section" Text="THIS PHONE" Margin="6,4,0,0" />
<!--
The offer and the withdrawal share this card and never show together: one is "there is no device key
here", the other is "there is". Neither appears on a phone that cannot hold one at all — a handset
with no screen lock — and that third case is the paragraph under them, because a card with nothing
in it would read as a setting that failed to load.
-->
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="9">
<!-- The lock screen draws the same ring beside its own fingerprint button. -->
<Ellipse Width="16" Height="16" Stroke="{StaticResource Accent}" StrokeThickness="1.5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Unlock with a fingerprint" />
</StackPanel>
<TextBlock Classes="body"
Text="Registers this phone so a later launch can open the keychain with your fingerprint — or this phone's PIN — instead of your passphrase. The key is held by the phone's secure hardware and never leaves it, and your passphrase keeps working." />
<Button Classes="primary" Content="REGISTER THIS PHONE"
Command="{Binding RegisterDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}" />
<!--
No confirmation, deliberately, and the desktop's button says the same in a tooltip this head has
no room for — so the sentence above it carries it instead. Somebody pressing this has realised a
phone is in the wrong hands, and the cost of a wrong press is one passphrase.
-->
<StackPanel Spacing="8" IsVisible="{Binding CanForgetDevice}">
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="registered · this phone can open the keychain without your passphrase" />
<Button Classes="danger" Content="STOP UNLOCKING HERE"
Command="{Binding ForgetDeviceCommand}"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<!--
Neither flag is set on a phone with no screen lock, and that is worth saying: Android will
generate a key that requires authentication on such a phone and then no gesture can ever release
it, which is why AndroidDeviceKeyStore reports itself unavailable rather than offering one.
-->
<TextBlock Classes="body" IsVisible="{Binding HasNoDeviceKeyOption}"
Text="This phone has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a screen lock with a fingerprint or a PIN enrolled on the phone itself." />
</StackPanel>
</Border>
<!-- Where registering, withdrawing and a declined prompt all answer. -->
<TextBlock Classes="body" Margin="6,12,6,0" TextAlignment="Center"
Text="{Binding StatusMessage}" />
<TextBlock Classes="section" Text="NOT HERE" Margin="6,26,0,0" />
<Border Classes="card" Margin="0,10,0,0">
<StackPanel Spacing="9">
<TextBlock Classes="body"
Text="Text size is on the terminal itself, where the text is — the phone puts A and A+ in the shell's own bar rather than a step away from what they resize." />
<TextBlock Classes="body"
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
<TextBlock Classes="body"
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>
/// The one setting this phone has: whether a fingerprint may open the keychain.
/// </summary>
/// <remarks>
/// Takes the shell as its data context, like the hub it is reached from, because both commands on it are
/// the shell's — registering a device key needs the session, the server connection and the keystore, and
/// all three are the state machine's.
/// </remarks>
internal sealed partial class PreferencesScreen : UserControl
{
public PreferencesScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -203,6 +203,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
/// <summary>What this machine is called — on the account, and on every log entry it writes.</summary>
/// <remarks>
/// From the head rather than from <see cref="Environment.MachineName"/>, because that property answers
/// <c>localhost</c> on Android and would make every phone in an account indistinguishable from every
/// other one — in the device list a user revokes from, and in the log they read to find out which
/// machine opened a shell. The desktop passes nothing and keeps the machine name; a phone knows its own
/// model and nothing in this assembly can ask for it, because <c>Android.OS.Build</c> is not reachable
/// from a <c>net10.0</c> library. See docs/android-port.md §7.
/// </remarks>
private readonly string deviceName;
/// <remarks>
/// Built here from the paths rather than taken as a dependency, because it holds preferences and not
/// state: there is nothing for a head to substitute, and a constructor parameter every head would pass
@@ -291,6 +302,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// application — one type implements both — and a separate parameter because it is a separate capability
/// and the tests that drive this state machine have no use for it.
/// </param>
/// <param name="deviceName">
/// What to call this machine. Optional, and the default is right for every head that runs on a desktop
/// operating system — see the field it is kept in for the one that it is not right for.
/// </param>
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
@@ -302,7 +317,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null)
Func<string, Task>? copyToClipboard = null,
string? deviceName = null)
{
this.paths = paths;
this.caches = caches;
@@ -315,12 +331,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard;
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
// to register for a reason no message could explain.
this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName;
transfers = new TransfersViewModel(sftpSessions, clock);
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
// running after it, and the entry it eventually produces belongs to the vault it was made in.
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
connectionLog = new ConnectionRecorder(clock, this.deviceName);
this.workspace.ConnectionLog = connectionLog;
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
@@ -1505,7 +1526,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
() => provisioner.EnrollAsync(
ServerUrl,
chosen,
Environment.MachineName,
deviceName,
"Personal",
cancellationToken),
cancellationToken)
@@ -1574,6 +1595,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}).ConfigureAwait(true);
}
/// <summary>What the status line says while the platform's own consent dialogue is up.</summary>
/// <remarks>
/// A runtime check rather than a constructor parameter, unlike the device name beside it, and the
/// difference between the two is why: a device name is a fact about one handset that only the head can
/// read, whereas which dialogue appears is a fact about the platform this assembly is running on, and a
/// value every Android head would pass identically is a parameter that only makes the heads longer.
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
/// deciding whether the prompt in front of them is the one this application asked for.
/// </remarks>
private static string GestureWait =>
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks>
/// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that
@@ -1583,7 +1616,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
{
await RunAsync(
"Waiting for Windows…",
GestureWait,
async () =>
{
var outcome = await Opener()
@@ -1621,13 +1654,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
await RunAsync(
"Waiting for Windows…",
GestureWait,
async () =>
{
var name = Environment.MachineName;
var registered = await vault.Session
.RegisterDeviceAsync(connection.Account, deviceKeys, name, cancellationToken)
.RegisterDeviceAsync(connection.Account, deviceKeys, deviceName, cancellationToken)
.ConfigureAwait(true);
if (!registered)
@@ -1638,7 +1669,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CanRegisterDevice = false;
CanForgetDevice = true;
StatusMessage = $"'{name}' can now unlock without your passphrase.";
// Named rather than "this machine", because the account lists several and this is the
// sentence that says which one just gained the ability to open the vault.
StatusMessage = $"'{deviceName}' can now unlock without your passphrase.";
}).ConfigureAwait(true);
}
@@ -1665,7 +1699,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
await RunAsync(
"Waiting for Windows…",
GestureWait,
async () =>
{
var revocation = await vault.Session
@@ -2617,7 +2651,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private IReadOnlyList<LiveConnection> LiveConnections() =>
[
.. connectionLog.Open().Select(open => new LiveConnection(
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
open.HostLabel, open.Address, open.StartedAt, deviceName)),
];
private InsertTarget CurrentInsertTarget() =>