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:
@@ -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>
|
||||
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>
|
||||
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);
|
||||
|
||||
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.
|
||||
|
||||
@@ -4793,6 +4793,64 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
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]
|
||||
public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user