Let the recovery code be copied, and give the phone a clipboard to copy to
ci / build and test (push) Canceled after 0s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s

Both screens had made the code selectable and both said why: a person who cannot
get it out of the box photographs the screen, and a screenshot is a worse home
for it than a clipboard. This finishes that argument. Selecting 64 characters of
letter-spaced monospace with a thumb is the version of "possible" people give up
on halfway — and on the phone the screen blocks screenshots, so the honest
remaining options were retyping it or losing it.

It is the one secret this application deliberately offers to a clipboard, and the
contrast with the keychain's copy is the whole argument rather than an
inconsistency. There, copying the private half is refused outright, because
installing a key means pasting the public one and the private one has no business
leaving the vault. Here there is no better route: the code exists for one screen,
is stored nowhere, and has to reach a password manager. The clipboard is the
intended destination rather than a way round the design.

The sentence afterwards matters as much as the copy, and is asserted: a clipboard
is a staging post, this screen is the only place the code exists, and the next
thing copied replaces it. Somebody who copies and does nothing has not saved it.

The phone had no clipboard delegate at all — the desktop passed one and this head
passed null — so COPY PUBLIC KEY on the keychain answered "this machine has no
clipboard" on a device that plainly has one. Nothing about that was platform
shaped: Android has a clipboard and Avalonia surfaces it through the same
TopLevel. Wiring it fixes that copy too.

The test fixture built its shell without a clipboard, which modelled the bug
rather than the product, so it has one now and the public-key test asserts what
lands there instead of the refusal. The refusal keeps its own test, on a shell
built without one, because the view model reads the delegate's absence rather
than an empty result — and because a button that silently does nothing on this
screen is worse than one that refuses.
This commit is contained in:
2026-08-05 18:14:34 +02:00
parent 253c72d2b7
commit dc1ebf6afa
7 changed files with 234 additions and 14 deletions
@@ -27,6 +27,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
/// <summary>Everything this shell has copied, newest last.</summary>
private readonly List<string> clipboard = [];
/// <remarks>
/// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
/// state machine, not about how expensive the passphrase is to attack.
@@ -127,7 +130,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
TimeProvider.System,
ssh,
CheapProfile,
ResumeAsync);
ResumeAsync,
// ◆ A clipboard, where this fixture used to pass none. Both heads wire one now — the phone's
// was simply never passed, which made every copy on that head answer "this machine has no
// clipboard" on a device that plainly has one. A fixture without one modelled the bug rather
// than the product. The branch for a machine that really has none is still covered, by a shell
// built without one where it is the thing under test.
copyToClipboard: text =>
{
clipboard.Add(text);
return Task.CompletedTask;
});
return ValueTask.CompletedTask;
}
@@ -3397,11 +3411,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
/// <remarks>
/// A machine with no clipboard reports itself rather than appearing to have copied. This shell is built
/// without one, which is what makes the case reachable at all.
/// The public half and only the public half. There is deliberately no command for the other one — a
/// private key on a clipboard is a private key in every application on the machine — so what this pins
/// is that the one thing installing a key needs does reach the clipboard.
/// </remarks>
[Fact]
public async Task CopyingAPublicKey_WithNoClipboard_SaysSo()
public async Task CopyingAPublicKey_PutsThePublicHalfOnTheClipboard()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -3416,7 +3431,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.CopyPublicKeyCommand.ExecuteAsync(null);
vault.Status.ShouldContain("no clipboard", Case.Insensitive);
var copied = clipboard.ShouldHaveSingleItem();
copied.ShouldStartWith("ssh-");
copied.ShouldNotContain("PRIVATE KEY");
}
/// <remarks>
/// ◆ <b>The one secret this application deliberately offers to the clipboard.</b> The recovery code
/// exists for one screen, is stored nowhere and has to reach a password manager, so the clipboard is
/// where it is going whatever the interface does — the only question is whether the interface helps or
/// leaves somebody transcribing it, or photographing a screen that blocks screenshots.
/// </remarks>
[Fact]
public async Task CopyingTheRecoveryCode_PutsItOnTheClipboardAndSaysWhereToPutIt()
{
await EnrolledAsync();
shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
var code = shell.RecoveryCode.ShouldNotBeNull();
await shell.CopyRecoveryCodeCommand.ExecuteAsync(null);
clipboard.ShouldHaveSingleItem().ShouldBe(code);
// The sentence matters as much as the copy. A clipboard is a staging post rather than a home, and
// this screen is the only place the code exists — somebody who copies it and does nothing has not
// saved it.
shell.StatusMessage.ShouldContain("password manager");
shell.StatusMessage.ShouldContain("replaces it");
}
/// <remarks>
/// The other branch, and it needs a shell built without a clipboard because that is precisely the
/// condition — the view model reads the delegate's absence, not an empty result. It must say so rather
/// than leaving a button that appears to have worked: a recovery code somebody believes is on their
/// clipboard is a recovery code they will not write down.
/// </remarks>
[Fact]
public async Task CopyingTheRecoveryCode_WithNoClipboard_SaysSoRatherThanSeemingToWork()
{
var bare = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
SignInAsync,
TimeProvider.System,
ssh,
CheapProfile,
ResumeAsync);
await using (bare.ConfigureAwait(false))
{
bare.RecoveryCode = "correct horse battery staple";
await bare.CopyRecoveryCodeCommand.ExecuteAsync(null);
bare.StatusMessage.ShouldContain("no clipboard", Case.Insensitive);
clipboard.ShouldBeEmpty("the other shell's clipboard must not have been written to either");
}
}
// ---- Importing ssh_config ----