Public Access
Give the phone a way to enrol the fingerprint it already unlocks with
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:
@@ -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() =>
|
||||
|
||||
Reference in New Issue
Block a user