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
+14
View File
@@ -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 returns something like `localhost`, which would make every log entry from a phone indistinguishable. Needs a
real device name from the head. 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 ### 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. 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 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` 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. 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 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 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, two heads differ in *where the response arrives* and in nothing else — PKCE, the state check, discovery,
+68
View File
@@ -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 **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 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. 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.
+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. // makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken) .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 // 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. // view model shows its own progress and handles its own failures.
@@ -157,7 +157,8 @@
Width="22" VerticalAlignment="Center" /> Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Preferences" /> <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> </StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15" <TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
@@ -174,10 +174,12 @@
</Panel> </Panel>
<!-- <!--
Preferences keeps PendingScreen for its body — it is still a screen that states what is absent Preferences carries the header its siblings under MORE have, written here rather than inside the
rather than one with settings on it — and gains the header its siblings under MORE have, here screen: the screen is one of two that hang off this hub with a back arrow, and the arrow belongs
rather than inside PendingScreen: that control is shared with three states of getting in, none of to the shell that knows where back is.
which has anywhere to go back to.
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 IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
@@ -185,9 +187,7 @@
CommandParameter="{x:Static vm:ShellScreen.More}" /> CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" /> <TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid> </Grid>
<views:PendingScreen Grid.Row="1" <views:PreferencesScreen 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." />
</Grid> </Grid>
</Panel> </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 TimeProvider clock;
private readonly Argon2Profile? passphraseProfile; 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> /// <remarks>
/// Built here from the paths rather than taken as a dependency, because it holds preferences and not /// 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 /// 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 /// 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. /// and the tests that drive this state machine have no use for it.
/// </param> /// </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( internal MainWindowViewModel(
ClientPaths paths, ClientPaths paths,
ClientCacheFactory caches, ClientCacheFactory caches,
@@ -302,7 +317,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ISftpSessionFactory sftpSessions, ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null, Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null, ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null) Func<string, Task>? copyToClipboard = null,
string? deviceName = null)
{ {
this.paths = paths; this.paths = paths;
this.caches = caches; this.caches = caches;
@@ -315,12 +331,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.passphraseProfile = passphraseProfile; this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard; 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); 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 // 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 // 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. // 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; this.workspace.ConnectionLog = connectionLog;
// Both dependencies as functions rather than values: the connection arrives after sign-in and the // 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( () => provisioner.EnrollAsync(
ServerUrl, ServerUrl,
chosen, chosen,
Environment.MachineName, deviceName,
"Personal", "Personal",
cancellationToken), cancellationToken),
cancellationToken) cancellationToken)
@@ -1574,6 +1595,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}).ConfigureAwait(true); }).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> /// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks> /// <remarks>
/// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that /// 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) private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
{ {
await RunAsync( await RunAsync(
"Waiting for Windows…", GestureWait,
async () => async () =>
{ {
var outcome = await Opener() var outcome = await Opener()
@@ -1621,13 +1654,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
await RunAsync( await RunAsync(
"Waiting for Windows…", GestureWait,
async () => async () =>
{ {
var name = Environment.MachineName;
var registered = await vault.Session var registered = await vault.Session
.RegisterDeviceAsync(connection.Account, deviceKeys, name, cancellationToken) .RegisterDeviceAsync(connection.Account, deviceKeys, deviceName, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
if (!registered) if (!registered)
@@ -1638,7 +1669,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CanRegisterDevice = false; CanRegisterDevice = false;
CanForgetDevice = true; 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); }).ConfigureAwait(true);
} }
@@ -1665,7 +1699,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
await RunAsync( await RunAsync(
"Waiting for Windows…", GestureWait,
async () => async () =>
{ {
var revocation = await vault.Session var revocation = await vault.Session
@@ -2617,7 +2651,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private IReadOnlyList<LiveConnection> LiveConnections() => private IReadOnlyList<LiveConnection> LiveConnections() =>
[ [
.. connectionLog.Open().Select(open => new LiveConnection( .. connectionLog.Open().Select(open => new LiveConnection(
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)), open.HostLabel, open.Address, open.StartedAt, deviceName)),
]; ];
private InsertTarget CurrentInsertTarget() => private InsertTarget CurrentInsertTarget() =>
@@ -63,6 +63,14 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
/// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary> /// <summary>Device wraps registered after enrollment, keyed on the device public key.</summary>
internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal); internal Dictionary<string, byte[]> RegisteredDevices { get; } = new(StringComparer.Ordinal);
/// <summary>What each registered device called itself.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal List<string> RegisteredDeviceNames { get; } = [];
/// <summary>The id issued for each registered public key, so revocation has something to name.</summary> /// <summary>The id issued for each registered public key, so revocation has something to name.</summary>
private readonly Dictionary<string, Guid> deviceIds = new(StringComparer.Ordinal); private readonly Dictionary<string, Guid> deviceIds = new(StringComparer.Ordinal);
@@ -191,6 +199,7 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
var key = Convert.ToHexString(request.PublicKey); var key = Convert.ToHexString(request.PublicKey);
RegisteredDevices[key] = request.WrappedPrivateKey; 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 // 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. // actually registered rather than one this fake invented on the way past.
@@ -4793,6 +4793,64 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.CanUnlockWithDevice.ShouldBeFalse(); shell.CanUnlockWithDevice.ShouldBeFalse();
} }
/// <remarks>
/// The phone's case, tested here because the seam is the shell's. <c>Environment.MachineName</c> answers
/// <c>localhost</c> 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.
/// </remarks>
[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] [Fact]
public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase() public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase()
{ {