Offer to bring the keys an ssh_config points at
ci / build and test (push) Successful in 2m0s
ci / android head (push) Successful in 6m5s
ci / api image (push) Successful in 42s

An import that recorded a key path and left every host asking for a password was
an import whose result did not connect. The answer to that was a manual paste per
key, which is the sort of thing people do once and then stop importing.

So there is a tick, and it starts off. With it off nothing changes: an
IdentityFile becomes a note and the host asks for a password. With it on, IMPORT
reads each host's first IdentityFile out of ~/.ssh, stores it in the vault
encrypted like any other key, and binds the host to it.

Three things about how it is drawn are load-bearing rather than tidy. It is a
default nobody arrives at by accident. The sentence beside it names the directory
rather than saying "your keys", because that is what somebody is agreeing to. And
nothing is read during SCAN — tick it, read what it says, untick it, and no
private key has been opened. This is the only place the application opens key
material out of a directory the user did not point at file by file, and the whole
of what makes that acceptable is that it took a deliberate press.

One vault key per file, however many entries named it: an ssh_config pointing
twelve hosts at one id_ed25519 is the ordinary shape, and twelve copies would be
twelve things to rotate and eleven to forget. A file whose material is already in
the keychain is bound to rather than stored again, which is what makes running
the import twice harmless.

What cannot be read off a disk is a passphrase, so a protected key arrives
without one — and the report under the button names those files rather than
leaving a host to fail at connect time with a message about a malformed key.
Telling them apart means decoding for OpenSSH's own container, whose cipher name
is the first field inside the base64 rather than anything in the armour, and that
is the format ssh-keygen has written by default for years. The 88 base64
characters it decodes need 66 bytes, not 64: with the smaller span every
protected key came back unprotected, which the tests now pin.

A path that is not on this machine leaves its host imported and unbound, exactly
as it would have been with the tick off, and is named in the same report. A
config carried from another machine is the ordinary case, not an error.
This commit is contained in:
2026-08-05 08:58:28 +02:00
parent 746711da9d
commit ca07d63585
11 changed files with 1220 additions and 62 deletions
+58 -13
View File
@@ -218,12 +218,57 @@ Scan, then navigate away without importing.
### 2.9 Imported hosts are correct
Import a couple, then open one on the Hosts screen.
Import a couple with the key tick **off**, then open one on the Hosts screen.
**Pass:** the address, port and username match the config, and the notes record any `IdentityFile` path and
any `ProxyJump` — with `ProxyJump` clearly stated as not routing. Connecting should ask for a password even
where the config named a key, because **no key material is read**; binding it to a key in the keychain is a
separate act.
any `ProxyJump` — with `ProxyJump` clearly stated as not routing. Connecting asks for a password even where
the config named a key, because with the tick off **no key material is read**; binding it to a key in the
keychain is a separate act.
### 2.10 The key tick reads `~/.ssh`, and only when it is ticked · **the one worth the most care**
Scan a config that names at least one `IdentityFile`.
**Pass:** below the list there is a tick reading *Also import the private keys these hosts point at*, and it
is **off**. On a config that names no `IdentityFile` anywhere there is no tick at all.
Turn it on without pressing anything else.
**Pass:** a warning appears naming what IMPORT will read, and the rows whose config named a key change from
*a key on disk* to *a key, imported*. Nothing has been read yet — turn it off again and navigate away, and
the point is that no private key was ever opened.
Turn it on and press IMPORT.
**Pass:** the keychain holds one key per **file**, not per host — a config pointing twelve entries at one
`~/.ssh/id_ed25519` produces one key, and all twelve hosts show `key` as their authentication. The key's
notes name the file it came from. A report appears under the button with one line per file.
**Pass, and this is the half that is easiest to lose:** for a key protected by a passphrase, the report says
so and names the file. The key is still imported; what it does not have is the passphrase, which is nowhere
on disk to be read. Add it on the keychain screen and the host connects.
Now import the same config again.
**Pass:** the hosts duplicate — a second bookmark for one machine is allowed and takes a click — and the
**key does not**. The report should be identical and the keychain should still hold one.
**Failure means:** a tick that is on by default, or one that reads keys during SCAN, is the failure that
matters here — this is the only control in the application that opens private key material out of a
directory the user did not point at file by file, and the whole of what makes it acceptable is that it took
a deliberate press. A second copy of a key on a re-import means the material comparison in
`VaultViewModel.ImportHostsAsync` is not finding what is already there, which turns "import my config"
into a way to fill a keychain with duplicates of one file.
### 2.11 A key file that is not on this machine leaves the host alone
Point an `IdentityFile` at a path that does not exist, tick the box and import.
**Pass:** the host is imported, unbound, exactly as it would have been with the tick off — and the report
names the file and says there is no such file. One bad path must not stop the other entries.
**Failure means:** an import that stopped, or a host that vanished, on a config carried from another
machine. That is the ordinary case rather than an error.
---
@@ -237,27 +282,27 @@ covered by nothing at all.
Connect the SFTP page to a host first. All four of these should queue transfers.
### 2.10 Explorer → remote pane
### 2.12 Explorer → remote pane
Drag one file, then several, from Explorer onto the right-hand pane.
**Pass:** the pane outlines in accent colour while the pointer is over it, and the drop queues one transfer
per file into the directory showing.
### 2.11 Local pane → remote pane
### 2.13 Local pane → remote pane
**Pass:** as above. This uses the same platform file format as the Explorer drag, so a failure here with
2.10 passing points at the drag *source*, not the drop target.
2.12 passing points at the drag *source*, not the drop target.
### 2.12 Remote pane → local pane
### 2.14 Remote pane → local pane
**Pass:** the left pane outlines and the drop queues a download.
### 2.13 Local pane → Explorer
### 2.15 Local pane → Explorer
**Pass:** the file copies out.
### 2.14 The highlight clears · **the one most likely to be wrong**
### 2.16 The highlight clears · **the one most likely to be wrong**
Drag something over a pane and then out of it again without dropping.
@@ -268,13 +313,13 @@ why the layout test cannot catch it — but once visible it swallows the `DragOv
the pointer appears to leave immediately, the highlight sticks, and the drop lands nowhere. The fix is
`IsHitTestVisible="False"` on the highlight `Border` in `TransfersScreen.axaml`.
### 2.15 Dropping while disconnected
### 2.17 Dropping while disconnected
Disconnect, then drag a file over the remote pane.
**Pass:** the pane outlines in red and says "Connect to a host first." Nothing is queued on drop.
### 2.16 A click still selects a row
### 2.18 A click still selects a row
Click rows in both panes, and drag a row a few pixels without releasing.
@@ -794,7 +839,7 @@ around the cards.
space around the cards connects to nothing.
**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure
as 2.16 on the other screen, and here it would make the grid unusable. Empty space that connects means the
as 2.18 on the other screen, and here it would make the grid unusable. Empty space that connects means the
double-tap handler has lost its check that the pointer was over a card.
**And the same two gestures on the group cards above**, where they mean select and open rather than select