diff --git a/docs/android-port.md b/docs/android-port.md
index 027e28b..f7f12fc 100644
--- a/docs/android-port.md
+++ b/docs/android-port.md
@@ -198,6 +198,13 @@ Used as the device name on connection and activity log entries, and when registe
returns something like `localhost`, which would make every log entry from a phone indistinguishable. Needs a
real device name from the head.
+**Done** — `PhoneEnvironment.DeviceName` reads what the user typed into Android's own Settings, falling back
+to the marketing model, and `MainWindowViewModel` takes it as an optional constructor argument that the
+desktop head does not pass. It reaches all three places the machine name was: enrollment, device
+registration, and every connection log entry. The registration is the one that had to be fixed before the
+device key could be offered on a phone at all — the account's device list is what a lost handset is revoked
+from, and a list of identical `localhost` rows is a revocation nobody dares press.
+
### 8. The Windows-only bits of the desktop head
Listed for completeness; none of these is ported, they are simply absent from an Android head.
@@ -452,6 +459,13 @@ go at 360dp:
fallback, and a prompt that came back after being dismissed would be a modal you cannot get out of to
type into it. It watches three properties rather than one because startup sets the state to `Locked`
before it has asked the keystore whether there is a key to offer, and does both inside the busy wrapper.
+
+ **Enrolling one is on PREFERENCES**, and until it was, none of the above could ever happen on a phone:
+ the store, the gate and the lock screen's button all shipped, and nothing in this head could create the
+ key they are about — so `CanUnlockWithDevice` was false on every launch of every phone. 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 lost phone could never be revoked), which is why the offer is on a screen behind
+ SETTINGS rather than beside the button it turns on.
5. ~~**Android sign-in.**~~ **Done**, and the seam it needed turned out to be worth more than the
implementation. `IAuthorizationCallback` now sits between `OidcClient` and the loopback listener, so the
two heads differ in *where the response arrives* and in nothing else — PKCE, the state check, discovery,
diff --git a/docs/manual-checks.md b/docs/manual-checks.md
index 59dac04..16183d3 100644
--- a/docs/manual-checks.md
+++ b/docs/manual-checks.md
@@ -1166,3 +1166,71 @@ unchanged and there is nowhere to change it. Nothing claims to know *when* it wa
**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that
archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented
data — `team` has no updated-at column, so there is nothing behind it.
+
+---
+
+## Phase 13 — Unlocking the phone with a fingerprint
+
+Every check here needs a real Android device or emulator with a screen lock and a fingerprint enrolled on
+the phone itself, and none has a headless equivalent: the whole feature is a keystore key the platform will
+not release without a gesture, and there is no gesture in a test process. What *is* covered automatically is
+the shell's half — `ShellFlowTests` registers, relaunches, unlocks and withdraws against a fake keystore, so
+what is left here is the platform half plus the one thing only a person can see, which is which dialogue
+comes up.
+
+### 13.1 The offer is on PREFERENCES, and only when there is something to offer
+
+Unlock the keychain, go to SETTINGS → Preferences on a phone with a screen lock.
+
+**Pass:** REGISTER THIS PHONE is there under THIS PHONE. On a phone with **no** screen lock at all, neither
+button is drawn and the paragraph saying this phone has nowhere to keep a device key is.
+
+**Failure means:** if the button is drawn on a phone with no screen lock, `AndroidDeviceKeyStore`
+`IsAvailableAsync` is no longer asking the keyguard — and registering there would put a wrap on the account
+that nothing can ever open, on a phone whose key no gesture can release.
+
+### 13.2 Registering asks for the fingerprint, and says which phone it registered
+
+Press REGISTER THIS PHONE while signed in.
+
+**Pass:** the system's own biometric prompt appears, titled "Register this phone". Confirm it, and the status
+line names **this phone** — the name from Android's Settings, or the model — rather than `localhost`. The
+button is replaced by STOP UNLOCKING HERE. Cancel the prompt instead and nothing changes but the message.
+
+**Failure means:** a status line reading `localhost` means the head is no longer passing
+`PhoneEnvironment.DeviceName`, and the account's device list is about to fill with rows nobody can tell
+apart. No prompt at all means the cipher is not being bound to it — see `BiometricGate`, where binding is
+the entire point.
+
+### 13.3 The lock screen then opens without the passphrase
+
+Lock the keychain, close the app, and launch it again.
+
+**Pass:** the prompt is raised on arrival, and confirming it opens the keychain with nothing typed. UNLOCK
+WITH FINGERPRINT is on the screen behind it. Lock from inside the running app instead and **no** prompt is
+raised — that rule is deliberate; see `PhoneShell.TryOfferDeviceUnlock`.
+
+**Failure means:** a button that is absent after a successful registration is the wrap not reaching the local
+cache. A prompt raised after an in-app lock trains the reflex of authenticating at a prompt nobody asked for.
+
+### 13.4 Enrolling a new fingerprint on the phone destroys the key · **the security property**
+
+With DodoSSH registered, add another fingerprint in Android's own Settings. Then launch DodoSSH.
+
+**Pass:** no fingerprint button, and the passphrase opens the vault as it always did. Registering again from
+PREFERENCES restores it.
+
+**Failure means:** `setInvalidatedByBiometricEnrollment` has been dropped, and anybody who can add their own
+fingerprint to an unlocked phone has inherited the vault. This is the check that says the phone's fast path
+is not a downgrade of the passphrase.
+
+### 13.5 Withdrawing stops this phone, and clears the account
+
+Press STOP UNLOCKING HERE.
+
+**Pass:** it goes back to offering REGISTER, a relaunch asks for the passphrase, and the device is gone from
+the account — check from the desktop head, or by registering the same phone again and seeing one device
+rather than two. There is no confirmation prompt, deliberately.
+
+**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
+half that matters when the handset is the thing that was lost.
diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs
index f0f54fa..25ca427 100644
--- a/src/DodoSSH.Client.Android/App.axaml.cs
+++ b/src/DodoSSH.Client.Android/App.axaml.cs
@@ -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.
diff --git a/src/DodoSSH.Client.Android/Views/MoreScreen.axaml b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml
index 48b1461..db4fe4c 100644
--- a/src/DodoSSH.Client.Android/Views/MoreScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml
@@ -157,7 +157,8 @@
Width="22" VerticalAlignment="Center" />
-
+
diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
index 825e1cb..c5d896b 100644
--- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
+++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
@@ -174,10 +174,12 @@
@@ -185,9 +187,7 @@
CommandParameter="{x:Static vm:ShellScreen.More}" />
-
+
diff --git a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml
new file mode 100644
index 0000000..74c6b00
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml.cs b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml.cs
new file mode 100644
index 0000000..02e5162
--- /dev/null
+++ b/src/DodoSSH.Client.Android/Views/PreferencesScreen.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace DodoSSH.Client.Android.Views;
+
+///
+/// The one setting this phone has: whether a fingerprint may open the keychain.
+///
+///
+/// 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.
+///
+internal sealed partial class PreferencesScreen : UserControl
+{
+ public PreferencesScreen() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 4e2709e..15d646f 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -203,6 +203,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
+ /// What this machine is called — on the account, and on every log entry it writes.
+ ///
+ /// From the head rather than from , because that property answers
+ /// localhost 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 Android.OS.Build is not reachable
+ /// from a net10.0 library. See docs/android-port.md §7.
+ ///
+ private readonly string deviceName;
+
///
/// 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.
///
+ ///
+ /// 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.
+ ///
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? copyToClipboard = null)
+ Func? 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);
}
+ /// What the status line says while the platform's own consent dialogue is up.
+ ///
+ /// 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.
+ ///
+ private static string GestureWait =>
+ OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
+
/// Opens the vault with this machine's device key instead of the passphrase.
///
/// No Task.Run, 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 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() =>
diff --git a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
index 4e57adc..932c4fe 100644
--- a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
+++ b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs
@@ -63,6 +63,14 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
/// Device wraps registered after enrollment, keyed on the device public key.
internal Dictionary RegisteredDevices { get; } = new(StringComparer.Ordinal);
+ /// What each registered device called itself.
+ ///
+ /// Kept because the name is the only part of a registration a person ever reads: it is what the account's
+ /// device list shows beside the button that revokes a phone somebody has lost. A head that registered
+ /// every device under the same name would be indistinguishable from a working one everywhere else.
+ ///
+ internal List RegisteredDeviceNames { get; } = [];
+
/// The id issued for each registered public key, so revocation has something to name.
private readonly Dictionary deviceIds = new(StringComparer.Ordinal);
@@ -191,6 +199,7 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
var key = Convert.ToHexString(request.PublicKey);
RegisteredDevices[key] = request.WrappedPrivateKey;
+ RegisteredDeviceNames.Add(request.Name);
// One id per public key, as the real service issues, so a revocation can name the device that was
// actually registered rather than one this fake invented on the way past.
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index 0e2b22c..afb55ec 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -4793,6 +4793,64 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.CanUnlockWithDevice.ShouldBeFalse();
}
+ ///
+ /// The phone's case, tested here because the seam is the shell's. Environment.MachineName answers
+ /// localhost on Android, so a head with no way to say what it is called would put one
+ /// indistinguishable row per phone into the account's device list — which is the list somebody revokes a
+ /// lost handset from, and a row nobody can identify is a revocation nobody dares press. See
+ /// docs/android-port.md §7.
+ ///
+ [Fact]
+ public async Task AHeadThatKnowsWhatThisDeviceIsCalled_RegistersItUnderThatName()
+ {
+ // An enrolled account with a keychain on it, exactly as a phone signing in to an existing account
+ // finds. The registering shell below is a second one over the same profile, which is what every
+ // other "another launch" test in this file does.
+ await UnlockedAsync();
+ await shell.LockCommand.ExecuteAsync(null);
+
+ var phone = new MainWindowViewModel(
+ paths,
+ caches,
+ workspace,
+ new VaultKnownHostStore(),
+ deviceKeys,
+ SignInAsync,
+ TimeProvider.System,
+ ssh,
+ CheapProfile,
+ ResumeAsync,
+ deviceName: "Jaap's Pixel");
+
+ await using var _ = phone.ConfigureAwait(false);
+
+ await phone.StartAsync(Token);
+ await phone.SignInCommand.ExecuteAsync(null);
+
+ phone.Passphrase = Passphrase;
+ await phone.UnlockCommand.ExecuteAsync(null);
+ phone.State.ShouldBe(ShellState.Unlocked, phone.StatusMessage);
+
+ await phone.RegisterDeviceCommand.ExecuteAsync(null);
+
+ server.RegisteredDeviceNames.ShouldBe(["Jaap's Pixel"]);
+
+ // And it is said back, because "this phone can now unlock without your passphrase" is a sentence
+ // about one device out of several.
+ phone.StatusMessage.ShouldContain("Jaap's Pixel");
+ }
+
+ [Fact]
+ public async Task AHeadThatSaysNothing_RegistersUnderThisMachinesOwnName()
+ {
+ // The desktop, and the reason the parameter is optional: a head running on an operating system whose
+ // machine name is real passes nothing and gets it.
+ await UnlockedAsync();
+ await shell.RegisterDeviceCommand.ExecuteAsync(null);
+
+ server.RegisteredDeviceNames.ShouldBe([Environment.MachineName]);
+ }
+
[Fact]
public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase()
{