From 185790fb14d45273282753a630f4a4cf131a1681 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Thu, 6 Aug 2026 07:39:15 +0200 Subject: [PATCH] Let a key move to another vault, and ask whether it goes with the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keys sync and keys are shared: SshKey is in the sync registry on both sides, the material rides in the sealed payload, and every generation of the vault key is wrapped to a new member. What was missing was the way in. Hosts and groups could move between vaults and keychain items could not, so a key typed into a personal vault before the team existed stayed there for good — and moving a host into the team's vault left it authenticating with something nobody else in that vault can read. The code said so and could do nothing about it: "the answer is usually to put a copy of that key in the destination vault", which meant pasting the private half into a second item and deleting the first. A private key on a clipboard, and two items nobody can tell apart afterwards. MoveAsync already existed on the generic repository and is now exposed for keys and passwords as it is for hosts and groups. What had to be built around it is the re-aim. An item re-sealed under another vault's key lands with an id of that vault's making, so every host bound to the old one and every group lending it as a default is left naming a tombstone — and a host bound to something its vault no longer holds refuses to connect rather than falling back to a typed password. A move without the re-aim would look like a success and break every machine on that key. It runs over every vault this session can write to, because a binding resolves across all of them, and it counts what it could not rewrite: an item from a newer client, or one in a vault this account may only read. Those are said in the sentence afterwards rather than swallowed. The host's move asks the question rather than deciding it. A binding resolves across vaults, so the moved host goes on working for the person who moved it whichever way this is answered; it is the colleagues they have just joined who hold one vault's key and cannot connect with a host whose key stayed behind. Unticked, and it stays that way on purpose: moving a key into a team's vault hands it to everybody holding that key, and this design does not default anybody into a disclosure. Under the box is the count of everything else that authenticates with that key, because a key twenty machines use is a different decision from one nothing else touches, and neither number is visible from the panel otherwise. The question is answered against the vault in the picker, so choosing a different destination re-asks it and a key already in the destination offers nothing. One thing fixed on the way. A host that inherited its key from its group arrived in the destination naming nothing at all — the group belongs to the vault it left — so a machine that connected before the move refused after it, with no sentence anywhere saying why. The resolved binding is now written onto the host as it crosses, and the stranded-binding warning reads the resolved binding too, which is the case where somebody is least likely to know a key is involved. MOVE is on both heads, for keys and passwords only: a tag, a bucket and a pin are read from the active vault alone, so "another vault" is not a question any of them has. Four tests cover the move and its re-aim, the host's move with the key brought and without it, and the inherited binding. --- README.md | 24 +- docs/manual-checks.md | 25 + .../Views/HostsScreen.axaml | 19 + .../Views/KeychainScreen.axaml | 56 +- src/DodoSSH.Client.App/Views/HostDrawer.axaml | 20 + .../Views/KeychainScreen.axaml | 41 + .../ViewModels/VaultViewModel.cs | 772 +++++++++++++++++- .../CredentialRepository.cs | 10 + src/DodoSSH.Client.Sync/SshKeyRepository.cs | 23 + .../VaultSharingTests.cs | 265 ++++++ 10 files changed, 1223 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a4be75d..5477b6d 100644 --- a/README.md +++ b/README.md @@ -424,8 +424,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port. What a move cannot do is reach a machine that has already synced the host, which is the same limit -everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go -to" picker on the Keychain screen and cannot be moved yet. +everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the +Keychain screen and cannot be moved yet. + +**The move asks whether the key comes too**, because that is the half a host's move could not settle on its +own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either +way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your +personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that +way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure +is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that +key, which is what makes the answer decidable — a key twenty machines use is a different decision from one +nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the +destination. A key the host only *inherits* from its group counts too, and is written onto the host on the +way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing. + +**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen, +on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in +the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and +every group lending it as a default, across every vault you can write to. Without that the move would be a +deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect +rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer +client, or one in a vault you can only read — is left naming the old item and is counted in the sentence +afterwards. The panel says what points at the key before you press it, not after. **A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 590641b..9a16706 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -446,6 +446,31 @@ target lands. Anything still in the source vault is a partial move, which is sur not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves hosts behind and never a shelf with nothing on it. +### 3.3b Moving a key, and moving a host with its key · **needs a second vault** + +In your personal vault: add an SSH key, then two hosts that both authenticate with it. On the Keychain +screen select the key and press **MOVE**, and read the panel before choosing the shared vault. + +**Pass:** the panel says what uses the key — "Used by 2 hosts…" — before anything happens. Afterwards the key +carries the destination's badge under an id it did not have a moment ago, *and both hosts still say `key` +under their names* and still connect. The sentence names the vault and the two hosts that followed it. + +Then the other direction: with a key back in your personal vault and a host bound to it, choose **Move to +another vault…** on the host and pick the shared vault. The tick box under the picker offers to bring the +key, unticked, with the count of what else uses it underneath. Leave it and press MOVE; then move the host +back, tick it, and press MOVE again. + +**Pass:** unticked, the host lands in the shared vault and the status line says the key it authenticates with +is in another vault and will not resolve for anybody else there. Ticked, the key lands in the shared vault +too and the host still says `key`. Choosing a *different* vault in the picker re-asks the question, and the +box disappears when the destination is the vault the key is already in. + +**Failure means:** a host that says `password` after either move is the re-aim not having happened — the item +takes a new id in the destination, so every host bound to it and every group lending it has to be rewritten +as it lands, and a host bound to something its vault no longer holds refuses to connect rather than falling +back to a typed password. A tick box that is ticked when the panel opens is worse than a bug: it moves a +private key into a shared vault on the strength of a decision nobody made. + ### 3.4 A group deleted on another machine · **needs two machines** Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index 9113c64..bb7dc4b 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -712,6 +712,25 @@ + + + + + + +