Merge branch 'main' into the Android head

Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
+16
View File
@@ -100,6 +100,22 @@
ProxyJump both go through a loopback TCP bridge. See docs/adr/. ProxyJump both go through a loopback TCP bridge. See docs/adr/.
--> -->
<PackageVersion Include="SSH.NET" Version="2025.1.0" /> <PackageVersion Include="SSH.NET" Version="2025.1.0" />
<!--
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
managed only — no native assets — which is the bar this file sets for anything that gets
pinned. Taken rather than hand-rolled because the alternative here is implementing SigV4
request signing, and unlike the openssh-key-v1 container (which had no library at all) a
maintained implementation of this exists and is the one every S3-compatible service tests
against.
AWSSDK.Core is declared and pinned forward. What AWSSDK.S3 4.0.101.6 resolves on its own is
4.0.1, which is covered by GHSA-9cvc-h2w8-phrp — low severity, and this repository builds
with NuGet audit as errors, so "low" is not a reason to carry it. 4.0.100.9 is past it and
inside the same major. Same treatment as the OpenApi and SQLitePCLRaw entries above, and the
same standing obligation: this is now ours to keep current.
-->
<PackageVersion Include="AWSSDK.S3" Version="4.0.101.6" />
<PackageVersion Include="AWSSDK.Core" Version="4.0.100.9" />
<!-- <!--
Avalonia 12.1.0, with the WebView control on 12.0.1 — the latest it has shipped. Its Avalonia 12.1.0, with the WebView control on 12.0.1 — the latest it has shipped. Its
dependency is Avalonia >= 12.0.0 with no upper bound and it targets net10.0, so the skew dependency is Avalonia >= 12.0.0 with no upper bound and it targets net10.0, so the skew
+4
View File
@@ -20,12 +20,14 @@
<Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" /> <Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" /> <Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
<Project Path="src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" /> <Project Path="src/DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
<Project Path="src/DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
<Project Path="src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" /> <Project Path="src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<Project Path="src/DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" /> <Project Path="src/DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" />
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" /> <Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" /> <Project Path="src/DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
<Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" /> <Project Path="src/DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
<Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" /> <Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<Project Path="src/DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
<Project Path="src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" /> <Project Path="src/DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
</Folder> </Folder>
@@ -36,11 +38,13 @@
<Project Path="tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Auth.Tests/DodoSSH.Client.Auth.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Domain.Tests/DodoSSH.Client.Domain.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Import.Tests/DodoSSH.Client.Import.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Storage.Tests/DodoSSH.Client.Storage.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Sync.Tests/DodoSSH.Client.Sync.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Terminal.Tests/DodoSSH.Client.Terminal.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.ObjectStore.Tests/DodoSSH.Client.ObjectStore.Tests.csproj" />
<Project Path="tests/DodoSSH.Client.Transfer.Tests/DodoSSH.Client.Transfer.Tests.csproj" /> <Project Path="tests/DodoSSH.Client.Transfer.Tests/DodoSSH.Client.Transfer.Tests.csproj" />
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" /> <Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" /> <Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
+95 -1
View File
@@ -68,10 +68,15 @@ src/
DodoSSH.Client.Ssh connections, PTY shells, SFTP, host key trust DodoSSH.Client.Ssh connections, PTY shells, SFTP, host key trust
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, with no I/O of its own
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
tests/ one test project per source project tests/ one test project per source project
docs/adr/ architecture decision records docs/adr/ architecture decision records
docs/design-import-gaps.md what the client's design asked for and this build has not got docs/design-import-gaps.md what the client's design asked for and this build has not got
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
docs/android-port.md what an Android client would take, and what has been decided about it
``` ```
Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the
@@ -209,6 +214,42 @@ What is not here: transferring a directory, dragging between the panes, and rout
bastion — the last needs jump hosts the connection layer has not got. All three are in bastion — the last needs jump hosts the connection layer has not got. All three are in
[`docs/design-import-gaps.md`](docs/design-import-gaps.md). [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
### Working as a team
**TEAMS** in the nav rail creates a team, adds members and shares vaults. One distinction runs through the
whole screen and is worth having before you use it.
**Adding somebody to a team and giving them a key are two different acts, and only the first is something
the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in
their list immediately. It cannot make those vaults readable, because a vault key is sealed to each member's
public key and this server never holds one — so until somebody presses **SHARE KEY** from a machine that has
the key, their vault sits in the list saying it is waiting for one. That is not a rough edge to be smoothed
over later; it is what "the operator cannot read the credentials it stores" costs, and the screen says so
rather than implying the server handed anything out.
Sharing verifies before it wraps. The client reads the server's append-only key log, checks its hash chain
from the first entry, and refuses unless the key the directory just offered appears in that log unchanged.
That converts a key substitution by the server from invisible into visible — a substituted key has to be
published in a log every other client also reads. **It does not prove the key is the right person's.**
Compare the fingerprint with them over something this server does not carry; that is the only step that
closes it, and the success message says so every time.
Three limits, stated rather than discovered:
- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey,
and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials
that matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
- **The rekey is flagged, never performed.** See the milestone note above.
- **Host key trust stays in your personal vault.** A pin approved for a team's host is recorded and used
from your own vault, not the team's, so a teammate cannot pre-approve a fingerprint that your client will
then trust silently for a host you defined. The cost is that each member approves a team host's key once
on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what
has been trusted.
Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
vault is visible to everybody in that team and moving it back means deleting and retyping.
### End-to-end verification ### End-to-end verification
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
@@ -239,6 +280,11 @@ Linux and macOS is tracked in [`docs/platform-flags.md`](docs/platform-flags.md)
deployment gotchas that have already cost time once. Read it before assuming something works deployment gotchas that have already cost time once. Read it before assuming something works
off-Windows. off-Windows.
Android has been audited and scoped, but not started:
[`docs/android-port.md`](docs/android-port.md) records what ports as it stands (most of the core), what
does not (most of the interface), the decisions taken about what an Android client would be — phone-first,
keychain plus a terminal — and the spike that gates all of it.
### Conventions the build enforces ### Conventions the build enforces
- Warnings are errors. `dotnet format --verify-no-changes` gates CI. - Warnings are errors. `dotnet format --verify-no-changes` gates CI.
@@ -294,7 +340,55 @@ off-Windows.
and a queue that moves one file at a time with progress, throughput and resume. See and a queue that moves one file at a time with progress, throughput and resume. See
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which [Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
are consequences rather than choices. are consequences rather than choices.
- **M3 — teams**, sharing, ACLs.
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
Group membership is a field on the *host* rather than a member list on the group, so filing two machines at
once on two laptops is two independent writes instead of one contested one; and groups are flat, because a
parent pointer merged field by field lets two offline clients build a cycle that nothing can repair.
Inserting a snippet types it at the prompt and stops. Pressing Enter is a per-snippet decision, off by
default, and the reason is worth stating: a terminal is one input stream with no notion of being at a
prompt — the remote may be in an editor, or at a password prompt with the echo off — so this client cannot
honestly say "run this command", only "type this into whatever is there".
*Logs done:* what has been connected to, and what has been changed in the keychain. Both are synced,
encrypted items rather than local files, because the point of them is auditing a shared vault — a log only
one machine can read is a diagnostic, not an audit trail. Two consequences are stated rather than implied.
The connection log records the host's name, the address dialled, when, for how long and by which
account on which machine — but **not** the SSH username, which is a detail of the host and is in the host's
own logs. The keychain log records the **names** of the fields an edit touched and never their contents.
What that costs is in [ADR 0001](docs/adr/0001-e2ee-trust-model.md): the server still cannot read a single
field, but one row per connection with a server-side timestamp tells it your connection rate and the hours
you work. Retention bounds it — ninety days or five thousand entries per kind, whichever bites first.
*Buckets done:* S3-compatible object storage is a second kind of remote on the Files screen, beside a host.
Prefixes are directories, objects are files, and transfers go the same way through the same queue. The
bucket, its endpoint and its keys are a keychain item like any other, encrypted end to end — which matters
more than usual here, because for anybody self-hosting MinIO or Ceph the endpoint is an address on their
own network.
Three things a bucket cannot do are refused with a reason rather than approximated: there are no
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
- **M3 — teams**, sharing, ACLs. *Done, except rekey.* Teams with roles, a public-key directory, the
append-only key log served for clients to verify against, team-owned vaults, and vault key grants
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs
every vault it holds a key for, and a real TEAMS screen replaces the placeholder. See
[Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth
knowing before you rely on it; the reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md).
**What is deliberately not here: the rekey itself.** Removing a member revokes their grants and flags
every team vault `RekeyRequired`, and nothing acts on that flag. A rekey re-wraps every item's data key
under a fresh vault key and can only be performed by a client that holds the current one; that is M5's
key rotation. Until it lands the flag is what the interface reads to say a rotation is owed, which is
more honest than a button that only appears to do it. Ownership transfer is absent for the same kind of
reason — the owner cannot be removed or demoted, because nothing can appoint a replacement.
- **M4 — hardening and ops**, packaging, self-hosting guide. - **M4 — hardening and ops**, packaging, self-hosting guide.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys. - **M5 — multi-provider OIDC**, key rotation, per-item content keys.
+20
View File
@@ -82,6 +82,26 @@ operator all see ciphertext only. OIDC account takeover alone yields nothing rea
complete sharing graph regardless of settings. Host addresses are plaintext when relay is complete sharing graph regardless of settings. Host addresses are plaintext when relay is
enabled for that host; see [ADR 0004](0004-relay-authorization.md) for why that is a enabled for that host; see [ADR 0004](0004-relay-authorization.md) for why that is a
security requirement rather than a convenience. security requirement rather than a convenience.
- **The connection and activity logs widen that leak, deliberately.** They are ordinary vault
items — every field sealed, no plaintext column of any kind, not even a timestamp — but
there is one row per connection and one per keychain edit, and rows have `updated_at`. So
the operator can read a user's connection *rate and timing* off the change log without
decrypting anything: how many machines somebody touched this morning, and at what hour they
stopped. That is a real increase over what item counts alone gave away.
It is the price of the logs being auditable at all. Kept on the machine that produced them
they cannot be read by an administrator, cannot survive a reinstall, and cannot be checked
against anything — which makes them a diagnostic rather than an audit trail, and the point
of them is the audit trail once shared vaults land. Retention bounds the exposure rather
than removing it: ninety days or five thousand entries per kind, whichever bites first.
Two things keep it as narrow as it can be. The payload records the host's *label* and the
address as dialled but **not** the SSH username — "who in this organisation opened a shell"
is the audit question, and "which account they logged in as" is a detail of the host, whose
own logs already have it. And the activity log records the **names** of the fields that
changed and never their values — the same rule
[ADR 0006](0006-observability-stack.md) imposes on the server's own `audit_event.detail`,
arrived at independently on the other side of the encryption boundary.
- **Supply chain becomes the largest practical hole.** An operator who wants the secrets - **Supply chain becomes the largest practical hole.** An operator who wants the secrets
attacks the client, not the crypto. Release signing with a key not held by the server, and attacks the client, not the crypto. Release signing with a key not held by the server, and
eventually reproducible builds, matter more here than in a conventional product. eventually reproducible builds, matter more here than in a conventional product.
+89
View File
@@ -0,0 +1,89 @@
# ADR 0009 — Team access: membership authorises, a grant unlocks
- Status: accepted
- Date: 2026-07-31
- Builds on: [ADR 0001](0001-e2ee-trust-model.md)
## Context
M3 makes vaults shareable. The obvious way to model that is one concept — "access" — with a role
attached, and to let the server hand it out. Every hosted competitor works that way, and it is what
the imported design drew: a members table with a role column, and a share button beside each item.
This architecture cannot implement that concept, and the interesting part of M3 was working out
what it can implement instead.
The server holds ciphertext and no keys. A vault key is 32 random bytes sealed to each member's
X25519 public key (`docs/crypto.md` §3), and only a client holding the plaintext key can produce a
seal for somebody else. So "give Bob access" decomposes into two operations that live on different
machines and cannot be performed by the same actor:
- deciding that the server will **serve** Bob this vault's rows, which is a database write; and
- **wrapping** the vault key to Bob's public key, which needs a client that already holds it.
The schema anticipated this — `team`, `team_membership`, `vault.team_id` and `vault_key_grant` have
existed since the first migration — but nothing had had to name the split.
## Decision
**Membership is authorisation. A grant is access. The product says so out loud.**
`VaultAccessService` resolves a team vault through `team_membership`, mapping the role to
`PermissionFlags` by a union with no Deny rules. That decides what the server serves and nothing
else. Whether the caller can read what it serves is decided by whether they hold a grant, which the
server records, cannot produce and cannot verify.
Four consequences, each of which is a place where a more reassuring design was rejected:
- **A member with no grant is a normal state, not an error.** `VaultSummary.WrappedVaultKey` is null
and the vault appears in their list saying it is waiting for a key. Hiding it until a grant existed
would have been tidier and would have implied the server was the thing granting access.
- **The roles are only the ones that are enforceable.** There is no `ConnectOnly`, despite the design
asking for one and `TeamRole` having room. SSH terminates on the client, so opening a session needs
the credential's plaintext on that machine; "may connect but may not read the key" cannot be
enforced here, and shipping it as a role would have been a lie in a dropdown. `Connect` rides along
with `Read` and is documented as an interface hint.
- **Sharing verifies the recipient's key against the append-only key log, or refuses.** A directory
lookup is a claim by the server about a third party's public key; wrapping to an unverified claim
hands the vault to whoever made it. `KeyLogAudit` reads the whole log, checks its hash chain from
genesis, and refuses unless the offered key appears in it unchanged. There is no override flag,
because a flag that exists gets used on the day the log is briefly unreachable.
- **Removal is named for what it does.** It revokes grants and flags the vault for rekey. It does not
claim to reach anything already downloaded, and the interface says the remediation is rotating the
credential — the same non-retroactive limit ADR 0001 records.
Two things were deliberately **not** built, and both are refusals rather than omissions:
- **The rekey itself.** Only a client holding the current vault key can re-wrap every item's data key
under a new one. The server records that a rotation is owed and the interface reports it. M5.
- **Ownership transfer.** The owner cannot be demoted or removed, with its own problem code. Allowing
it without a transfer would leave a team nobody can administer, recoverable only by an operator
editing the database.
Two smaller choices, recorded because the alternative was written down first and rejected:
- **No `v_user_vault_permission` view.** ADR-adjacent notes and the old `VaultAccessService` remark
both anticipated one. The rules turned out to be about sixteen lines of C# shared by the two
methods that need them; a view would have moved the authorisation model into migrations, where a
test cannot reach it without a container.
- **Host key trust stays vault-scoped to the personal vault.** Pins in a team vault are listed but
not consulted at connect time. Consulting them would let any member with Write pre-approve a
fingerprint that another member's client then trusts silently for a host in their *own* vault,
which is a cross-boundary trust escalation. Scoping trust properly needs a scope on the SSH connect
path (`IKnownHostStore.FindAsync` takes host, port and algorithm and knows nothing about vaults);
until that exists, the safe direction is the narrow one, and the cost — approving a team host's key
once per member per machine — is stated in the README rather than hidden.
## Consequences
The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds
a grant are all plaintext rows. That was already true of metadata generally (`docs/crypto.md` §10)
and is not made worse here, but it is now a graph rather than a list.
A malicious granter can seal garbage. The recipient detects it as a tag failure and the grant's
Ed25519 signature names who issued it — detectable and attributable, which is the most that is
achievable without the server holding a key.
The two-step model costs a step in the interface and buys the property the whole product is for. It
also makes a class of bug impossible: there is no code path on the server that could accidentally
grant read access to plaintext, because there is no plaintext on the server to grant.
+452
View File
@@ -0,0 +1,452 @@
# An Android client: what it would take
**Status: audited, scoped, not started.** No Android code exists. The four decisions that shape the work
have been taken and are recorded in [Decisions](#decisions-taken); everything else here is the audit they
were taken against.
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
is **one remote pane** with Android's document picker for moving files in and out.
**What was actually checked**, so the rest can be read with the right amount of trust:
- Every project's target framework, read from `Directory.Build.props` and the `.csproj` files.
- The target frameworks each pinned package ships, read out of the local NuGet cache — so these are the
assemblies this solution would actually resolve, not what a package's README claims.
- Every site in `src/` that names a Windows API, a Windows path convention, or a desktop lifetime.
**What was not**: nothing was compiled for Android, nothing was run on a device or emulator, and no Android
SDK is installed here. Every statement below about *runtime* behaviour is reasoning from the code and the
platform's documented rules, and is marked where it matters.
---
## The headline
The port is smaller than it looks in one dimension and much larger in another.
**The core is already portable.** Every project targets plain `net10.0`, with no `net10.0-windows` anywhere
and no conditional compilation. All the Windows-specific code now sits in one project — `DodoSSH.Client.App`,
the desktop head. The cryptography, the sync engine, the local cache, the SSH layer, the item kinds and every
view model are platform-neutral today, and that is not luck: it is what the project structure has been
enforcing all along.
(One file was out of place when this was written — `WindowsDeviceKeyStore`, in `DodoSSH.Client.Session`. It
has since been moved, which is the only code change this audit produced; see §4.)
**The product is not.** DodoSSH is a two-pane file browser, a tab strip, a nav rail and a terminal, laid out
at a minimum of 880×560, driven by hover, right-click, middle-click and drag-and-drop. A phone is about
360dp wide and has none of those inputs. Roughly none of the *interface* ports; the question an Android
client really asks is not "will this compile" but "what is the Android product".
Two platform rules make that sharper, and they are the things most likely to be underestimated:
- **Scoped storage.** Android has no arbitrary local filesystem for an app to browse. The left-hand pane of
the Files screen — this machine's drives and directories — has no Android equivalent at all.
- **Background execution.** Android stops a backgrounded process. A terminal client whose whole premise is
that a shell survives locking the vault, and a transfer queue that runs for minutes, both assume a process
that keeps running. On Android that needs a foreground service with a persistent notification, or the
feature changes shape.
Neither is a porting problem; both were product decisions, and both have now been taken — a foreground
service, and a single remote pane. See [Decisions](#decisions-taken).
---
## What ports as it stands
Verified from the resolved package assemblies.
| Dependency | Ships for Android | Note |
| --- | --- | --- |
| `libsodium` 1.0.22 | ✅ `android-arm64`, `android-arm`, `android-x64`, `android-x86` | The native half of all the cryptography |
| `NSec.Cryptography` 26.4.0 | ⚠️ no Android-specific build | Ships `net9.0` plus iOS/tvOS/MacCatalyst. The plain `net9.0` assembly should load, since the platform-specific part is libsodium — but this is the one dependency worth proving with a build before anything else |
| `SSH.NET` 2025.1.0 | ✅ `netstandard2.0`, `net8.0` | Sockets only; needs the `INTERNET` permission |
| `SQLitePCLRaw.bundle_e_sqlite3` 2.1.12 | ✅ `net6.0-android31.0` | The local cache |
| `AWSSDK.S3` 4.0 | ✅ `netstandard2.0`, `net8.0` | |
| `CommunityToolkit.Mvvm` 8.4.2 | ✅ `netstandard2.0` | Every view model |
| `Avalonia.Controls.WebView` 12.0.1 | ✅ `net10.0-android36.0` | The surprise — see below |
| `Avalonia.Desktop` 12.1.1 | ❌ `net10.0` only | Replaced by `Avalonia.Android`, not ported |
So: **`DodoSSH.Client.Domain`, `.Storage`, `.Sync`, `.Api`, `.Auth`, `.Ssh`, `.Terminal`, `.Transfer`,
`.ObjectStore`, `.Import` and `.Crypto` should all target `net10.0-android` unchanged.** That is the great
majority of the code, including all of the cryptography and all of the sync protocol.
`DodoSSH.Client.Session` needed one file moved and no longer does — see §4. `DodoSSH.Client.App` is the
desktop head and does not port; an Android head would be a sibling project sharing its view models.
---
## What does not port, in order of how much it costs
### 1. The interface — the largest item by far, and it is not a port
880×560 minimum, a 54-pixel nav rail, a 268-pixel host sidebar, a two-pane file browser with six columns per
pane, a tab strip, and a layout suite (`DodoSSH.Client.App.Layout.Tests`, 64 tests) whose entire premise is
that everything fits at that minimum.
None of it survives a phone. What an Android client would be is a different product with the same core:
probably a host list, a terminal, and a single-pane file browser, with the keychain, snippets, logs and pins
as screens rather than as a rail. The desktop screens are not a starting point for that — they are a
different answer to a different question.
**This is where the real effort is**, and it is design effort before it is engineering effort. Everything
else on this list is a week or two of work; this is the product.
**Decided: phone first.** See [Decisions](#phone-first) — which means a redesign rather than a reflow, and
rules out the cheaper tablet route deliberately.
### 2. The Files screen's left pane has no Android equivalent
Scoped storage means an app sees its own directory and whatever the user hands it through the system
picker. There is no browsable `C:\` or `/home`. So the two-pane layout — the thing the whole screen is built
around — does not exist on Android.
The honest shapes are: a one-pane remote browser with **download to** and **upload from** going through the
system document picker, or a remote-to-remote transfer tool with no local side at all. Both are fine; both
mean the transfer queue's local half (`LocalDirectory`, the drive list, the breadcrumb trail) is desktop-only
code.
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
seam holds two very different remotes, and a `Uri`-backed Android document would be a third.
**Decided: one remote pane and the document picker**, and out of the first scope. See
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
### 3. Background execution
`TerminalWorkspace` keeps shells running across a vault lock, deliberately and documented. `FileTransferQueue`
runs one transfer at a time for as long as it takes. `VaultViewModel` runs an auto-sync pass every minute.
All three assume a process Android will stop.
The options are a foreground service with a notification for as long as a session or a transfer is live
(which is what every serious SSH client on Android does), or accepting that backgrounding the app drops the
connection. The first is not hard; it is a decision about what the app is allowed to do to the user's
battery and notification shade, and it wants taking deliberately.
**Decided: a foreground service** while a shell or a transfer is live. See
[Decisions](#sessions-survive-backgrounding-via-a-foreground-service).
### 4. The device key store — the cheap one, because the seam exists
`WindowsDeviceKeyStore` is DPAPI over a TPM-held key. `IDeviceKeyStore` is already the interface everything
else uses, with three methods and an `IsAvailableAsync` that exists precisely so a platform without a
keystore can say no.
Android's equivalent is the Android Keystore, with StrongBox where the hardware has it, and it is a closer
match than the Windows one: it can require biometric or device-credential authentication to release the key,
which is what the unlock screen would want anyway. **This is a straightforward implementation of an existing
interface**, and it is the piece of Android integration most clearly worth doing well.
**Done, ahead of any decision:** `WindowsDeviceKeyStore` used to sit in `DodoSSH.Client.Session`, which was
the one thing keeping that project from being portable. It now lives in `DodoSSH.Client.App/Platform/`, and
its factory is `DesktopDeviceKeyStores` — named for the head it belongs to. `IDeviceKeyStore` and
`UnavailableDeviceKeyStore` stayed behind, because they are the seam rather than an implementation.
The move cost nothing but a namespace, which is the useful part of the finding: the session layer takes a
store and has never known which one, so an Android implementation drops into the same hole. Verified by the
build and the suite, with the two Windows-only tests moving to `DodoSSH.Client.App.Tests` alongside it.
### 5. Sign-in
`BrowserLauncher` uses `Process.Start(UseShellExecute: true)`; `LoopbackCallbackListener` implements RFC 8252
§7.3 loopback redirect with a raw `TcpListener`.
Neither is right on Android. `Process.Start` does not exist; the platform way is an `Intent`, and the
platform way to receive the redirect is a Custom Tab plus an app link or a custom scheme. Loopback redirect
*might* work, and should not be used: on a shared device any other app can bind a loopback port, which is
exactly the attack RFC 8252 §8.3 warns about and the reason app links exist.
So this is a second implementation of an existing shape rather than a port. The PKCE flow, the discovery, the
key binding and the token handling above it are all unchanged.
### 6. `ClientPaths`
Branches Windows / macOS / XDG, with an explicit comment about wanting a *local, non-roaming* directory
because two machines sharing one cache file corrupts the outbox. On Android the right answer is the app's own
`filesDir`, which is per-app, non-roaming and not user-visible — it satisfies the requirement more cleanly
than any desktop platform does. One more branch, or better, the value injected by the head. `ClientPaths`
already takes an explicit directory for exactly this reason.
### 7. `Environment.MachineName`
Used as the device name on connection and activity log entries, and when registering a device. On Android it
returns something like `localhost`, which would make every log entry from a phone indistinguishable. Needs a
real device name from the head.
### 8. The Windows-only bits of the desktop head
Listed for completeness; none of these is ported, they are simply absent from an Android head.
- `NativeKeyboardFocus``user32.dll SetFocus`, and the whole documented asymmetry about focus not
returning from the WebView. Android's focus model is different and this problem may simply not exist there.
- `Program.Main``[STAThread]` (required by WebView2 specifically) and `StartWithClassicDesktopLifetime`.
An Android head is an `AvaloniaMainActivity` instead.
- Middle-click tab close, right-click, hover states, drag-and-drop between panes.
### 9. The terminal — better news than expected, with one unknown
`Avalonia.Controls.WebView` ships a `net10.0-android36.0` target, which was the single fact most likely to
sink this. And the transport underneath is more portable than it looks: `TerminalDataPlane` serves the page
and the binary protocol over a **loopback WebSocket**, and an Android WebView can load `http://127.0.0.1:port`
just as WebView2 does. The xterm.js bundles are embedded resources and are platform-neutral.
**Unverified, and it is the thing to check first if this goes ahead:** whether Avalonia's Android WebView
composites the same way — a native view above everything Avalonia draws. If it does, the occlusion rule in
`docs/platform-flags.md` applies unchanged and `IsTerminalShowing` keeps doing its job. If it does not, the
rule is unnecessary rather than wrong, which is the harmless direction.
The parts that are definitely different are the on-screen keyboard, and the fact that a terminal on a phone
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
accessory key row for this. That is UI work, not porting.
---
## Decisions taken
Four, each recorded with the reasoning that was actually weighed rather than only the outcome.
### Scope: the keychain and a terminal
Not a companion, and not the file browser. Everything that is already a list or a form — hosts, groups,
keys, passwords, snippets, pins, logs — plus opening a shell.
The terminal is the expensive half and it is the half that makes it an SSH client rather than a viewer. It
depends on the WebView spike coming back clean; if it does not, the companion subset is what is left and is
still worth shipping, so the work is ordered to find that out early.
### File transfer, when it comes: one pane and the document picker
Out of the first scope, decided now so the seams are not built the wrong way. A single remote pane, with
Android's document picker for moving files in and out.
This is the shape scoped storage allows, and the interesting part is how little it costs: `FileTransferQueue`
and `IRemoteFileStore` both carry over unchanged. Phase 6 already put a bucket behind that interface beside
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
### Sessions survive backgrounding, via a foreground service
A persistent notification for as long as a shell or a transfer is live.
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
notification is to look at.
### Phone first
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
and much of the current layout could have been reused — and the phone is the device people have with them,
which for an SSH client is most of the point.
So the interface is a redesign rather than a reflow, and that is the largest single item of work here. The
nav rail, the 268-pixel sidebar and the two-pane browser do not survive. What does survive is everything
behind them: every view model, every command, every piece of state.
---
## What the interface actually has to carry
Written for designing the phone client. It is an inventory of what exists today and what each part is
*for* — not a layout, and not a claim that any of it should look the same.
**Read it as a checklist of things that need somewhere to go.** The desktop has room to put a warning, a
confirmation and a form on screen at once; a phone does not, and the states most easily lost are the ones
that appear rarely and matter most. Those are marked **◆**.
### Getting in: six states before the app is usable
`ShellState`, and every one of them is a screen.
1. **Starting** — reading the local cache to find out whether this machine is enrolled.
2. **Needs a server** — nothing cached: name a server, sign in through the browser. The only state that
requires a network.
3. **Needs enrollment** — signed in, but the account has no vault key yet. Choose a passphrase.
4. **◆ Showing the recovery code** — *the user must not be able to click past this.* It is the only moment
the code exists; losing it along with the passphrase means the vault is unrecoverable, and there is no
server-side reset by design. On desktop it is a whole screen with a confirmation. It needs to stay one.
5. **Locked** — the unlock screen. Passphrase box, optional device unlock (biometric on Android), a status
line, and the line saying this works with no network. **◆** Also carries two disclosures: how many shells
are still connected behind the lock screen, and the paragraph explaining that *locked* describes the
keychain and not this machine's access to the hosts. Plus **reset this machine** for a forgotten
passphrase.
6. **Unlocked** — everything below.
### Chrome that is present on every screen
- **Titlebar** — vault name, account name, a search affordance (Ctrl+K on desktop), and a sync dot with a
label: synced, pending count, offline, unreachable.
- **Status bar** — the selected terminal's live dot and address, the vault's last status sentence, the sync
label again, and the search hint.
- **Nav rail** — eight destinations: `HOSTS FILES KEYS PINS SNIPS LOGS TEAM PREFS`. Five characters is a
desktop constraint, not a product one; the phone can use words.
- **Terminal strip** — always visible, above every screen. Tabs with a close cross *inside* each tab, a `+`,
and a sentence when there are none. This is what makes a terminal a surface the window switches to rather
than a screen you navigate away from, and it is the single most desktop-shaped idea in the product.
- **Quick connect** — a search palette over hosts that connects on Enter.
### The nine destinations
**1. Hosts** — the list of machines, and what is known about the selected one.
- *Sidebar:* filter box; group headings with a chevron and a count, **shown only when groups exist**; host
rows carrying a connected dot, name, sync badge, address and one word for how it authenticates.
- *Editor* (doubles as "add"): name, hostname, port, username, notes, one authentication picker covering
typed password / key / stored credential, a group picker, a relay checkbox with the sentence explaining
that relay puts the address on the server in plain text, and **◆ forget host key** — the only way back from
a legitimately rebuilt server.
- *Actions:* new / edit / delete, **replaced in place** by the delete confirmation rather than stacked under
it.
- *Right column:* the connect banner — a password box only for a host that asks for one, a sentence in its
place when it does not, and CONNECT.
- **◆ Unknown host key prompt** — fingerprint shown in full, TRUST AND CONNECT / CANCEL. Appears on first
contact with any host.
- **◆ Changed host key refusal** — deliberately has *no* continue button. Presenting this as dismissible is
the one design mistake that matters here.
- **◆ Conflict log** — what a merge overrode and what it discarded, scrollable, with DISMISS ALL. The merge
is only allowed to pick a winner because this exists.
- *Groups panel:* the groups as chips with host counts, a name box that both adds and renames, delete with
its own confirmation counting the affected hosts.
**2. Files** — two panes and a queue.
- *Bar:* a HOST / BUCKET toggle, the matching picker, a password box for hosts that need one, CONNECT
(or OPEN for a bucket), DISCONNECT, a connected chip, a status line.
- *Panes:* breadcrumb trail, drive roots on the local side, and a listing with name, size, modified and —
remote only — POSIX permissions. Each pane has an empty state and a drop highlight in two flavours,
accepting and refusing.
- *Queue:* direction arrow, name, the remote path, progress with bytes and rate, state, and RESUME / RETRY /
stop per row.
- **◆ The host key prompts appear here too.** File transfer is a second, separate connection that makes its
own trust decision.
- On Android this becomes one remote pane plus the document picker — see the decision above — but the queue,
its states and the prompts are unchanged.
**3. Keychain** — everything that is not a host.
- *Categories:* ALL / SSH KEYS / PASSWORDS / BUCKETS, each with a count.
- *Table:* name, type, one line of detail, sync badge. The detail is what is *known about* an item and never
the secret.
- *Four editors,* which are four different shapes: an SSH key (label, private key armour shown unmasked so a
truncated paste is visible, passphrase, public key, notes); **generate a key** (algorithm choice, comment,
and it fills the editor rather than saving); a password (label, username, password masked, notes); a bucket
(label, bucket, access key id, secret access key masked, region, endpoint, **◆ a path-style checkbox with
the sentence explaining why**, notes).
- **◆ Delete confirmations that count** — "three hosts authenticate with this key and will refuse to connect".
The number is the difference between a sentence somebody reads and one they click past.
**4. Pins** — the host keys this keychain has approved.
- Filter that matches **fingerprints as well as names**, because the workflow is "the operator published
SHA256:… — do I have that one?".
- Table: host, port, algorithm, **◆ fingerprint never truncated**, approved date.
- Detail pane with the fingerprint in full again, a "no host uses this" chip, the note that the date is
derived from the item id and means first approval rather than last use, and FORGET THIS HOST KEY.
**5. Snippets** — saved commands.
- Filter matching the command text as well as the name.
- Rows: name, **◆ a "runs immediately" chip**, sync badge, and a one-line preview with newlines shown as `⏎`.
- Editor: name, a multi-line command box, notes, and **◆ a "press Enter after inserting this" checkbox with
the paragraph explaining that leaving it off is the whole safety property**.
- Detail pane with the command in full and two buttons that **name the terminal they will type into**
`TYPE INTO prod-db` and, only for a snippet marked as running, `RUN IN prod-db`. Plus the "no terminal
open" state, and the sentence saying whatever is in the terminal receives this.
**6. Logs** — two logs behind one screen.
- A CONNECTIONS / KEYCHAIN toggle and a refresh.
- *Connections:* live dot, host, address, **◆ "still open" rather than a dash for a session in progress**,
kind (terminal or files), started, device, and a chip for a refused host key.
- *Keychain:* item, type, what happened, **the names of the fields that changed**, when.
**7. Preferences** — import `~/.ssh/config`, register or forget this device, sign out.
**8. Team** — nothing behind it, and the screen says so rather than being hidden from the rail.
**9. Import** — a preview before anything is written: tick per row, alias, resolved address, how it
authenticates, a warnings chip, an "already in the keychain" badge, TICK ALL / NONE, and IMPORT N HOSTS.
Nothing is stored until the button.
### States that cut across every screen
- **Empty states**, with copy written per screen — each says what the thing is *for*, not "no items".
- **A read-only item**, written by a newer client: shown, refused for editing, with a message saying to
update. Re-encoding would silently drop a colleague's field.
- **Sync badges** per row: not synced yet, refused and waiting on a person, read-only.
- **Unreadable items** — a count of things that would not decrypt, which is the signal that new key grants
are needed after a rekey.
- **Busy**, and **offline / unreachable**, which are different from each other and both different from
"everything is synced".
### The five most likely to be lost on a phone
In the order I would worry about them, and all of them are full-width blocks today with nowhere obvious to
go at 360dp:
1. The changed-host-key refusal, which must not become dismissible.
2. The recovery code screen, which must not become skippable.
3. The counted delete confirmations, which are the difference between a decision and a reflex.
4. The unknown-host-key prompt, which is the one interruption that is genuinely load-bearing.
5. The conflict log, which is what makes the merge honest rather than last-writer-wins.
---
## The order of work
1. ~~Decide the product questions.~~ **Done** — see [Decisions](#decisions-taken).
2. ~~Move `WindowsDeviceKeyStore` into the desktop head.~~ **Done**`DodoSSH.Client.Session` is now free
of Windows APIs entirely.
3. **The spike, and it answers two questions at once.** One throwaway Android head that unlocks a vault from
a passphrase, then puts a WebView on the screen pointing at the terminal data plane. The first half
settles NSec-on-Android, libsodium's native resolution, SQLite and whether the sync stack runs; the second
settles whether the terminal is possible at all, which the scope decision now depends on. Both are cheap
and both are gates — nothing after this is worth starting until it comes back.
4. **Android device key store**, with biometric or device-credential release. A straightforward
implementation of `IDeviceKeyStore`, and the piece of platform integration most clearly worth doing well:
the Android Keystore is a closer match to what the unlock screen wants than the Windows one is.
5. **Android sign-in**: Custom Tabs plus an app link, behind the existing seams. Not the loopback listener —
on a shared device any app can bind a loopback port, which is the attack RFC 8252 §8.3 names.
6. **The foreground service**, before the terminal rather than after it. A session that dies on backgrounding
would otherwise shape every decision made while building the screen, and be expensive to unpick.
7. **The interface**, phone-first. The actual project, and the one that dominates the estimate.
8. **The terminal**, last — the highest-value screen, and the one whose remaining unknowns are cheapest to
resolve once the shell around it exists. Plus an accessory key row: a software keyboard has no Ctrl, Esc,
Tab or arrows, and every Android SSH client ships one for exactly this reason.
Steps 1 and 2 are done. Step 3 is a few days and retires nearly all the remaining technical risk. Steps 46
are each perhaps a week and are ordinary work behind interfaces that already exist. Step 7 dominates
everything else put together, and step 8 is small only because step 7 came first.
---
## Still open
Neither of these blocks the spike, and both want answering before there is anything to release.
- **Which Android versions.** Less forced than it first looked: the packages *compile against* API 36 and 31
respectively, which constrains `targetSdk` rather than `minSdk`. The floor is therefore a real choice about
which devices are worth supporting, and it should be made deliberately rather than inherited from whatever
restores. Worth settling before the interface work, since it decides which platform APIs are available to
design against.
- **How it is distributed, and what that does to the supply-chain story.** ADR 0001 says plainly that an
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
but it is a change to a documented security property of this product, and it should be reasoned about in
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
costs reach.
## Smaller things, decided by default
Recorded so they are choices rather than accidents. Any of them is cheap to revisit.
- **`ClientPaths`** gets the app's own `filesDir`, injected by the head rather than branched for inside the
record — which already takes an explicit directory for exactly this reason. It satisfies the
local-and-non-roaming requirement more cleanly than any desktop platform does.
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
something like `localhost` on Android and would make every entry from a phone indistinguishable.
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
confirmed to exist before anything is written to solve it.
+106 -27
View File
@@ -26,29 +26,90 @@ file transfer is a *separate connection* rather than a second channel, because S
its own transport. See [File transfer](#file-transfer-the-designs-sftp-screen), which is the one place in its own transport. See [File transfer](#file-transfer-the-designs-sftp-screen), which is the one place in
this document where what shipped differs from what the row predicted. this document where what shipped differs from what the row predicted.
**Teams are schema and nothing else.** The `team` and `team_membership` tables exist from the first **~~Teams are schema and nothing else.~~ Built in M3.** The `team` and `team_membership` tables were there
migration, with entities in `DodoSSH.Domain/Teams.cs` and a `TeamRole` enum — and no endpoint reads or from the first migration with nothing reading them, and `VaultAccessService.ResolveAsync` denied every vault
writes any of it. `VaultAccessService.ResolveAsync` returns `Denied` for every vault that is not the that was not the caller's own. Both changed in M3 and neither needed a migration, which is what carrying two
caller's own personal one. That removes the Teams screen entirely, and with it every role chip, scope and unused tables bought. See [Teams](#teams). What has *not* changed is the split underneath: the server
"shared with" affordance the vault screen was drawn with. decides what it will serve, and only a client can decide who can read it — so "shared with" is two facts on
this screen, not one.
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the **The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway. saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
**Three things the design did not ask for and this build now has.** A key can be generated in the client
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
`OpenSshKeyWriter` for why there was no alternative and why it is written unencrypted). Hosts can be
imported from `~/.ssh/config` (`DodoSSH.Client.Import`; it reads no key material, and `ProxyJump` is
recorded as intent because the SSH layer still has no jump hosts). And the file-transfer screen takes drag
and drop in four directions — remote to Explorer is the one that does not ship, because it needs a virtual
file the platform layer cannot supply; see `docs/manual-checks.md`.
**The rail has six destinations, not five.** The design gives host keys no slot at all. They were a fourth
category on the keychain screen for a while and are now a screen of their own, `KnownHostsScreen`, because
the other categories are things somebody creates and edits and a pin is a decision recorded at the moment
of connecting — and because comparing an untruncated fingerprint against a published one needs a column
layout the shared table could not give it.
**The product surface says "keychain" and everything under it says "vault", deliberately.** The nav rail,
the unlock and enrollment copy, and every sentence a user reads now call the encrypted store a keychain.
The wire does not: the route is `/api/v1/vaults/{vaultId}`, the tables are `vault` and `vault_key_grant`,
the CLR types are `VaultViewModel` and `VaultSession`, and `docs/crypto.md` — which is normative — says
"vault key". Renaming those would break every deployed client, need a table-rename migration, and put
`CryptoSpec.AadResourceType.Vault = 3` inside the blast radius of a find-and-replace, where changing it
would make every item in every vault permanently unreadable. So the split is the answer rather than a
stage on the way to one: read "vault" in this repository as the name of the cryptographic object, and
"keychain" as what the product calls it.
One thing cuts the other way and is worth knowing before planning any of this: **the wire protocol already One thing cuts the other way and is worth knowing before planning any of this: **the wire protocol already
reserves the slots**. `SyncEntityType` (`src/DodoSSH.Contracts/SyncEntityType.cs`) has `HostGroup = 4`, reserves the slots**. `SyncEntityType` (`src/DodoSSH.Contracts/SyncEntityType.cs`) has `HostGroup = 4`,
`Tag = 5`, `HostTag = 6`, `HostCredential = 7`, `Snippet = 8` and `PortForward = 9` — reserved, unused, and `Tag = 5`, `HostTag = 6`, `HostCredential = 7`, `Snippet = 8` and `PortForward = 9` — reserved, unused, and
already covered by the AAD resource-type table. Groups, tags and snippets are new item types on an existing already covered by the AAD resource-type table. Groups, tags and snippets are new item types on an existing
protocol rather than a protocol change. protocol rather than a protocol change.
Two of those slots are now taken. `HostGroup` and `Snippet` shipped as full item kinds — a table, an EF
configuration, a server kind that refuses every plaintext field, a codec, a merge, a cipher and a repository
— and neither needed a contract change, which is what the reservation bought. `Tag`, `HostTag`,
`HostCredential` and `PortForward` are still reserved and still unused.
**A third was added for something the design never mentioned**: `ObjectStore = 13`, an S3-compatible bucket
and the keys that reach it. It is a keychain item like any other — the endpoint and the secret access key are
both inside the payload, and the server refuses every plaintext field including the endpoint, because for
everybody self-hosting that is an address on their own network.
What made it cheap is that the transfer queue never needed anything SSH-specific: `IRemoteFileStore` was
lifted out of `ISftpSession` with only the host key left behind, and a bucket is the same contract with a
different implementation. Three things a bucket genuinely cannot do are refused with a reason rather than
approximated — there are no directories (only keys with slashes in them, and a marker object for an empty
one), no resumable upload (an object cannot be written from the middle), and no atomic rename (a copy and a
delete). Downloads *do* resume, because a ranged GET is part of the protocol.
**Two members were added rather than claimed**: `ConnectionLogEntry = 11` and `ActivityLogEntry = 12`, the
first additions to `SyncEntityType` since it was frozen. Logs are synced items rather than local files
because they are audit records — an administrator has to be able to read a shared vault's history once teams
land, and a log kept only on the machine that produced it can be neither read nor trusted by anybody else.
ADR 0001 records what that costs in metadata; it is not free and it is not hidden.
Three properties are worth knowing before touching them. An entry is **written once, at close** and never
updated, which is what lets a synced log avoid a merge entirely. Neither kind is **audited**, which is the
guard that stops the activity log producing an entry for every entry it writes. And both are **excluded from
the pending-change count and from what a background sync announces** — those numbers answer "how much of my
work is not yet safe", and a log entry is not somebody's work.
**One plaintext field was taken away rather than used.** `SyncPlaintextFields.GroupId` has existed since the
contract was frozen and `host.group_id` since the first migration; no client ever wrote either. Group
membership is inside the encrypted payload instead, the column is dropped, and the server now refuses the
field with a reason — because what it would have handed the operator is a clustering of every user's estate,
and ADR 0004's test for a plaintext concession is that the server *cannot function* without it. The wire
field cannot be removed and stays as a permanently refused member; `SyncEndpointTests` holds the refusal.
--- ---
## Chrome — titlebar, nav rail, status bar ## Chrome — titlebar, nav rail, status bar
| Design element | Layer | What it would take | What ships instead | | Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Org chip `dodotech / platform` | contracts + server | An organisation name a client can read. `VaultSummary.TeamId` exists and is always null. | The vault's own name, and the account this machine is enrolled as. | | Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the TEAMS screen, where they are about something. |
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. | | `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. | | `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". | | `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
@@ -71,13 +132,14 @@ caption buttons and window title drawn on top of the application's own — two s
| Design element | Layer | What it would take | What ships instead | | Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type and a host-tag join. Both reserved on the wire (`Tag = 5`, `HostTag = 6`), neither implemented, plus a payload schema bump on `HostSecret`. | Omitted. The filter box searches name, address and notes instead. | | Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type and a host-tag join. Both reserved on the wire (`Tag = 5`, `HostTag = 6`), neither implemented, plus a payload schema bump on `HostSecret`. | Omitted. The filter box searches name, address and notes instead. |
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | One collapsible heading, named after the vault — the only grouping a host actually has. A second appears when a second vault becomes reachable. | | Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Flat, not nested. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
| Group badge `TEAM·PLATFORM` | server | Teams. | Omitted. | | Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
| Groups on a **team's** hosts | client-domain | Reading groups across every readable vault, a vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | Not yet. Groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED — the same way a host whose group was deleted does. Nothing is lost or misfiled; the grouping is simply not shown. |
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. | | Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. | | `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. | | `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
| `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. | | `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. |
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | Omitted. | | `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. | | Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. | | Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. | | Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
@@ -131,10 +193,10 @@ and both editors. What follows is what the design drew around them.
| Design element | Layer | What it would take | What ships instead | | Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `ACCESS` column and `your access → CONNECT-ONLY` | server | Per-item ACLs. `EncryptedPayload.DataKeyId` is documented as the seam for per-item grants **in M5**. `VaultSummary` does carry an opaque `int Permissions`, but nothing gives the bits a meaning on the wire — `PermissionFlags` itself lives in `DodoSSH.Domain` — and those are per *vault*, never per item. There is no `ConnectOnly` role in `TeamRole` at all. | Omitted. The column shows sync state instead — whether a change is still sitting in this machine's outbox — which the design had no column for. | | `ACCESS` column and `your access → CONNECT-ONLY` | server | Per-item ACLs. `EncryptedPayload.DataKeyId` is documented as the seam for per-item grants **in M5**. `VaultSummary` does carry an opaque `int Permissions`, but nothing gives the bits a meaning on the wire — `PermissionFlags` itself lives in `DodoSSH.Domain` — and those are per *vault*, never per item. There is no `ConnectOnly` role in `TeamRole` at all. | Omitted. The column shows sync state instead — whether a change is still sitting in this machine's outbox — which the design had no column for. |
| `SHARED WITH · 6` avatars | server | The whole sharing stack: grants, a member directory, and re-wrapping a vault key for another account. | Omitted. | | `SHARED WITH · 6` avatars | server | **Built in M3**, minus the avatars — no picture is stored anywhere. `GET /api/v1/vaults/{id}/grants` lists who holds a key. | The list lives on the TEAMS screen, beside the members it is about, rather than as a count on an item row: a grant is per *vault*, and putting a number on an item would imply per-item sharing, which is M5. |
| "Private key never leaves the vault. Sessions sign through the team agent (dodod)" | client-ssh | **Both sentences are false here, and the second cannot be made true by this architecture.** There is no agent of any kind, and the connect path decrypts the private key and hands the bytes to SSH.NET. | Omitted. The key editor already says what is true: the key and its passphrase are encrypted here and never reach the server in a readable form. | | "Private key never leaves the vault. Sessions sign through the team agent (dodod)" | client-ssh | **Both sentences are false here, and the second cannot be made true by this architecture.** There is no agent of any kind, and the connect path decrypts the private key and hands the bytes to SSH.NET. | Omitted. The key editor already says what is true: the key and its passphrase are encrypted here and never reach the server in a readable form. |
| Scope rail: `PERSONAL` / `TEAM · PLATFORM` / `TEAM · DATA` | server | Team vaults. `VaultAccessService.ListAsync` filters to personal vaults owned by the caller. | The real vault list, which today has one entry, with a line saying why. | | Scope rail: `PERSONAL` / `TEAM · PLATFORM` / `TEAM · DATA` | server | **Built in M3.** `VaultAccessService.ListAsync` resolves team membership, so a session can hold several vaults. | Not a rail, because it would be a selector with nothing to select: every list on the screen already spans every vault this session can read. What replaces it is a picker for where a *new* item is filed, which is the only vault question with an answer. |
| `SCOPE` column | server | As above — and note scope here is a property of a *vault*, never of an item. | Omitted. | | `SCOPE` column | server | **Built in M3.** Scope is a property of a vault, never of an item, and that has not changed. | Each row names the vault it is in, and rows are grouped by vault. |
| `LAST` column (`11:02`, `1d ago`) | contracts | No last-used timestamp at any layer. `VaultItem<TSecret>` is `(id, secret, version, three sync flags)`. | Omitted. | | `LAST` column (`11:02`, `1d ago`) | contracts | No last-used timestamp at any layer. `VaultItem<TSecret>` is `(id, secret, version, three sync flags)`. | Omitted. |
| `FINGERPRINT` for SSH keys | client-domain | `SshKeySecret` has no fingerprint field, and computing one means parsing key formats the type deliberately stores verbatim. | The `DETAIL` column carries what *is* known — whether a passphrase and a public half are stored. Pins show their real fingerprint, in full and untruncated. | | `FINGERPRINT` for SSH keys | client-domain | `SshKeySecret` has no fingerprint field, and computing one means parsing key formats the type deliberately stores verbatim. | The `DETAIL` column carries what *is* known — whether a passphrase and a public half are stored. Pins show their real fingerprint, in full and untruncated. |
| `•••• rotated jul 14` for passwords | client-domain | `CredentialSecret` has no rotation date or password age. | The account the password is for. | | `•••• rotated jul 14` for passwords | client-domain | `CredentialSecret` has no rotation date or password age. | The account the password is for. |
@@ -144,7 +206,7 @@ and both editors. What follows is what the design drew around them.
| `added by anna@dodotech.dev` | contracts | The server records `CreatedByUserId`, but `SyncChange` carries no actor field and no other user's name is fetchable. | Omitted, and the detail pane says in one line that items record no author, no timestamps and no sharing. | | `added by anna@dodotech.dev` | contracts | The server records `CreatedByUserId`, but `SyncChange` carries no actor field and no other user's name is fetchable. | Omitted, and the detail pane says in one line that items record no author, no timestamps and no sharing. |
| `created 2026-03-14` | client-sync | Recoverable in principle — entity ids are UUIDv7 and carry a timestamp — but nothing surfaces it. | Omitted. | | `created 2026-03-14` | client-sync | Recoverable in principle — entity ids are UUIDv7 and carry a timestamp — but nothing surfaces it. | Omitted. |
| `TEST CONNECT` | ui | Connecting is host-scoped, not credential-scoped: there is nothing to test a credential *against* without a host. | Omitted. | | `TEST CONNECT` | ui | Connecting is host-scoped, not credential-scoped: there is nothing to test a credential *against* without a host. | Omitted. |
| `REVOKE` | server | Revoking someone else's access needs grants to revoke. Deleting an item is a different act and is already there. **ADR 0001** also constrains how any future revoke may be presented: revocation is not retroactive. | Delete, named for what it does. | | `REVOKE` | server | **Built in M3**, as WITHDRAW KEY on the TEAMS screen — because there are now grants to revoke, and a grant is what it acts on. ADR 0001 constrains how it is presented, and it is: the message says it blocks future reads only, and that what they already hold is unaffected. | On this screen, still Delete, named for what it does. Deleting an item and withdrawing somebody's key remain different acts. |
| `SSH KEY · ED25519` | client-domain | No algorithm field, and deriving it means parsing the armour. | The type without the algorithm. | | `SSH KEY · ED25519` | client-domain | No algorithm field, and deriving it means parsing the armour. | The type without the algorithm. |
| One `+ ADD CREDENTIAL` button | ui | — | Two buttons, one per kind that can be added. "Credential" is a specific item type in this codebase (a username and a password), so using it as an umbrella word would collide with the vocabulary. | | One `+ ADD CREDENTIAL` button | ui | — | Two buttons, one per kind that can be added. "Credential" is a specific item type in this codebase (a username and a password), so using it as an umbrella word would collide with the vocabulary. |
| — | — | — | **`HOST KEYS` is the reverse case:** a fully-backed, shipped category the design had no slot for. It is in the rail. | | — | — | — | **`HOST KEYS` is the reverse case:** a fully-backed, shipped category the design had no slot for. It is in the rail. |
@@ -153,23 +215,40 @@ and both editors. What follows is what the design drew around them.
## Teams ## Teams
Nothing on this screen exists. It is in the nav rail and reaches a screen that says so. **Built in M3.** The screen ships: a team list, a members table, the team's vaults, and the two buttons the
whole design was really about — add a member, and share a vault key. What follows is what it still does not
do, and one thing this document got wrong before it was built.
| Design element | Layer | What it would take | **The correction.** The rows below used to describe a screen with nothing behind it, on the grounds that
`VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. That is now the one
place that changed, exactly as its remark predicted, and no migration was needed: `team`,
`team_membership`, `vault.team_id` and `vault_key_grant` have all been there since the first migration.
What the row did not anticipate is that the interesting half is not the endpoints at all. It is that
**membership and readability are different things**, and the screen is arranged around saying so.
| Design element | Layer | What ships |
| --- | --- | --- | | --- | --- | --- |
| The team itself | server | Team endpoints. The server has eight routes and none is about people. | | The team itself | server | `POST/GET /api/v1/teams`, plus members, roles and team vaults. Ids are client-chosen, so a create whose response was lost is safe to repeat. |
| Shared vaults | server | Vault creation, grants, and membership evaluation. Exactly one vault exists per user, created as a side effect of enrollment. | | Shared vaults | server + client | A team owns vaults; each is created with the creator's own grant, because a vault with no grant is a container nobody can open. |
| Roles (`OWNER`/`ADMIN`/`MEMBER`/`CONNECT-ONLY`/`VIEWER`) | contracts + server | `TeamRole` exists in `DodoSSH.Domain`, is read by no code path, has no wire representation, and has no `ConnectOnly` member. | | Roles | contracts + server | `TeamMemberRole` on the wire, numerically pinned to `DodoSSH.Domain.TeamRole` by a test. Viewer reads, Member writes, Admin and Owner also share and administer. |
| Members table | server | A member DTO and a directory endpoint. | | Members table | server | `TeamMemberSummary`, and a directory that resolves an exact email to a public key. |
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | | Sharing an item | client | `VaultSession.ShareVaultAsync`: verify the recipient's key against the key log, wrap, sign, record. The server stores the wrap and the signature and can check neither. |
| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` exists and is written at just-in-time provisioning and at enrollment, and never on an ordinary authenticated request — so the column cannot answer "last active". |
| Avatars | server | `MeResponse` has no picture field, and no other user's display name is fetchable. |
| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` and `TeamMembership.InvitedByUserId` are the only hints, and nothing writes them. |
| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. |
> **A trap for whoever builds this.** `GET /api/v1/meta` advertises `features: ["teams"]` | Design element | Layer | What it would take | What ships instead |
> *unconditionally* (`MetaEndpoints.cs`). Do not gate a Teams screen on that string — it is true of every | --- | --- | --- | --- |
> deployment today and means nothing. | `CONNECT-ONLY` role | — | Nothing that would be true. Connect is a user-interface hint, not a boundary: SSH terminates on the client, so a session needs the credential's plaintext on that machine. See ADR 0001. | Four roles, all of which are enforceable. `Connect` rides along with `Read` and is documented as a hint. |
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member column carries what *is* known and matters: whether they have published a key a vault can be wrapped to. |
| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` is written at just-in-time provisioning and at enrollment and never on an ordinary authenticated request, so the column cannot answer "last active". | Omitted. |
| Avatars | server | No picture is stored anywhere. | Omitted; the row shows a name and an address. |
| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` remains unwritten. | Adding a member resolves an address the caller types against the directory, so the account has to have signed in here once. The screen says that when the lookup finds nothing. |
| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
| A rekey after a membership change | client | Re-wrapping every item's data key under a fresh vault key, which only a client holding the current one can do. M5. | The vault is flagged `RekeyRequired` and the row says a rotation is owed. |
| Ownership transfer | server | A confirmation flow and a rule for what happens to the outgoing owner. | The owner cannot be removed or demoted, with its own problem code rather than a bare 400. |
> **The trap this document warned about is still a trap.** `GET /api/v1/meta` advertises
> `features: ["teams"]` *unconditionally* (`MetaEndpoints.cs`). It was meaningless when nothing implemented
> teams and it is meaningless now that everything does — it has never been computed from anything. Do not
> gate a client feature on it.
--- ---
+582
View File
@@ -0,0 +1,582 @@
# Things a person still has to check
Automated tests cover what they can reach. This file is the rest: the checks that need a real window, a
real network, a real remote host, or a real Explorer — and the reasons each one is out of reach.
Three constraints put things on this list, and they are worth knowing before adding to it:
- **`MainWindow` cannot be laid out by a test.** WebView2's adapter refuses the headless dispatcher's MTA
thread — see `LayoutHarnessTests.WhyTheWindowItselfIsNeverShown`. Anything that has to be measured lives
on a `UserControl` instead, and what is left in the window is unmeasured by construction.
- **Headless Avalonia has no native window.** So nothing about Win32 focus, about the WebView collapsing,
or about a drag that crosses into another application can be asserted. A headless test of any of those
would pass and confirm the wrong belief.
- **No network, no container, no remote host** in the ordinary suite. The SSH suites that do use one
(`DodoSSH.Client.Ssh.Tests`, `DodoSSH.SystemTests`) need Docker and are the exception.
Each item says what to do, what a pass looks like, and what a failure would mean.
---
## Phase 1 — the shell and the tab strip
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
Open two terminals, then visit every nav rail entry in turn — HOSTS, FILES, KEYS, TEAM, PREFS.
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
five.
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
left edge with the rest unreachable. This window has shipped that defect once. The single wrapper `Panel`
bound to `IsShowingPages` in `MainWindow.axaml` is what should make it impossible.
### 1.2 A tab clicked from another screen takes the keyboard
Go to FILES with a terminal open. Click the tab. **Start typing immediately, without clicking anything
else.**
**Pass:** every character reaches the shell, including the first.
**Failure means:** `FocusTerminalWhenLaidOut` in `MainWindow.axaml.cs` is posting too early.
`NativeControlHost` re-pushes its bounds on the next layout pass, so focusing ahead of that pass races the
thing the focus depends on. The symptom is losing only the first keystroke or two, which is why this has to
be typed immediately rather than after a pause.
### 1.3 Leaving a terminal gives the keyboard back
With a terminal focused, click FILES. Type into the filter box.
**Pass:** the characters appear in the box.
**Failure means:** `ReleaseKeyboardTo` is missing on that path. **Collapsing the WebView does not release
the keyboard** — the native child window goes on holding Win32 focus and Avalonia then sees no key events
at all, so the screen that just appeared silently swallows everything. This was a latent bug before the
strip rework and is now on the hot path. See `docs/platform-flags.md`.
### 1.4 The middle click closes tabs and only tabs
Middle-click a tab (closes it), the strip background to the right of the last tab (closes nothing), and the
`+` button (closes nothing, opens nothing).
**Pass:** as described. Covered by `TerminalTabsTests` headlessly, so this is a confirmation that headless
pointer input matches a real mouse rather than a first look.
### 1.5 Connecting from the palette while on another screen
Press Ctrl+K from the FILES screen and connect to a host whose key is not yet approved.
**Pass:** the window lands on HOSTS with the fingerprint prompt visible and answerable.
**Failure means:** the prompt is behind the screen that asked for it, and the connection is blocked on a
question that cannot be reached.
---
## Phase 2 — Known Hosts as its own page
### 2.1 The fingerprint column is readable end to end
Connect to two or three hosts, approving each fingerprint. Go to PINS and widen the window to its minimum
(880px), then to something ordinary.
**Pass:** the full `SHA256:…` is on screen at both sizes, never cut off and never ellipsised.
**Failure means:** the one thing this screen is for has been broken. A truncated fingerprint cannot be
compared against a published one — it can only be glanced at, which is the habit pinning exists to replace.
`TheHostKeysScreenFitsWithPinsAndOneSelected` measures this at the minimum width, so a failure here is a
size the harness does not cover.
### 2.2 The APPROVED date is plausible
**Pass:** it is roughly the day you first connected to that host.
**Failure means:** the version 7 identifier is being read in the wrong byte order — the symptom is dates
tens of thousands of years out, not an error. Covered by `Uuid7TimestampTests`, so this is a confirmation
that the ids reaching the screen really are the ones this client minted. A pin restored from an older
client or another implementation shows `—`, which is correct rather than a failure.
### 2.3 Forgetting a pin reaches the server
With two machines signed in to the same account: forget a pin on one, sync the other.
**Pass:** the pin is gone on both, and the second machine asks you to check the fingerprint again on the
next connection.
**Failure means:** the forward from the screen's command to the vault's has lost the push. Withdrawing
trust that stays withdrawn only locally is the failure mode that matters here — the machines still refusing
to reach a rebuilt server are the other ones.
---
## Phase 2 — Generating a key
Most of this one *is* covered: `KeyAuthenticationTests.AKeyThisClientGenerated_AuthenticatesAgainstARealServer`
installs a generated public line on a real OpenSSH server in a container and connects with the private half,
for both algorithms. That is the claim that mattered, and it is automated. What is left is the interface
around it.
### 2.4 COPY PUBLIC KEY actually reaches the clipboard
Generate a key, save it, select it, press COPY PUBLIC KEY, then paste somewhere.
**Pass:** one `ssh-ed25519 AAAA… comment` line.
**Failure means:** the clipboard closure in `App.axaml.cs` is not finding the window. No test can see this —
the view models take a delegate precisely so they never touch a visual, which means the one real
implementation of that delegate is exercised by nothing but a person. `CopyingAPublicKey_WithNoClipboard_SaysSo`
covers only the branch where there is none.
### 2.5 RSA-4096 does not freeze the window
Choose RSA 4096 and press GENERATE. While it runs, drag the window and click around.
**Pass:** the window keeps painting and the status line says it is working.
**Failure means:** the `Task.Run` is not actually taking the work off the UI thread. Ed25519 is instant and
will not show this, so it has to be tried with RSA.
### 2.6 The generated key works end to end, by hand
Generate a key, save it, copy the public line, add it to a real host's `~/.ssh/authorized_keys`, bind the
host to the key in its editor, and connect.
**Pass:** it connects without a password.
This duplicates the container test on purpose. The container runs one image; the thing worth knowing is
that it works against whatever you actually run.
---
## Phase 2 — Importing ssh_config
The parser has 22 cases over the shapes a real file contains, and the end-to-end path is covered by
`ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked`. What no test can do is read *your* file.
### 2.7 Scan your own `~/.ssh/config` and read the preview against the file
Preferences → IMPORT HOSTS → SCAN. Do not press import yet.
**Pass:** every entry you would expect is listed, with the address and port you expect, and the warnings
above the table account for anything missing.
**What to look for specifically:**
- A `Host *` block's `User` should appear on hosts that set none, and **not** override hosts that set one.
- Entries whose name is a pattern (`*.internal`, `bastion-?`) should be *absent* from the table and named
in the warnings.
- `Match` blocks should be counted in the warnings and their settings should not have leaked onto any host.
- A `ProxyCommand` should be reported as dropped, not silently kept.
**Failure means:** the importer and `ssh` disagree about what your file means, which produces bookmarks
that nearly connect. That is worse than an import that refused, so it is worth reading the table properly
once.
### 2.8 Nothing is written until the button
Scan, then navigate away without importing.
**Pass:** the Hosts screen is unchanged.
### 2.9 Imported hosts are correct
Import a couple, 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.
---
## Phase 2 — Drag and drop on the SFTP page
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
window and cannot synthesise a platform drag, so a test that claimed to drop a file from Explorer would
pass while confirming nothing. What is automated is the policy — `TransferQueueingTests` covers what may be
queued, what is skipped and what is said about it — and the wiring between a real drag and that policy is
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
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
**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 Remote pane → local pane
**Pass:** the left pane outlines and the drop queues a download.
### 2.13 Local pane → Explorer
**Pass:** the file copies out.
### 2.14 The highlight clears · **the one most likely to be wrong**
Drag something over a pane and then out of it again without dropping.
**Pass:** the outline appears and then goes away.
**Failure means:** an overlay is participating in hit testing. It lays out identically either way — which is
why the layout test cannot catch it — but once visible it swallows the `DragOver` events underneath it, so
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
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
Click rows in both panes, and drag a row a few pixels without releasing.
**Pass:** a click selects; a small movement does not start a drag.
**Failure means:** the 4-pixel threshold in `TransfersScreen.axaml.cs` is not doing its job, and selecting a
row has become impossible.
### Not implemented: remote pane → Explorer
Dragging a *remote* file out to Explorer is deliberately absent. It needs the source to supply a virtual
file — on Windows, `CFSTR_FILEDESCRIPTORW` plus `CFSTR_FILECONTENTS` with delayed rendering — and Avalonia's
`IDataTransfer` marshals `DataFormat.File` only from a storage item that resolves to a real local path.
Pre-downloading to a temp file does not help: the shell demands the bytes during the drop. The only route is
a native COM `IDataObject` behind a platform interface, Windows-only and outside Avalonia's supported
surface. Use the ← button, or drag the file to the local pane first.
---
## Phase 3 — Host groups and snippets
Two synced item kinds, a sidebar that now draws headings, and one new frame between the host process and the
renderer. The data half of all of that is covered: the payloads round-trip, the server refuses the plaintext
fields, the sidebar's grouping and the snippet policy are in `ShellFlowTests`, and both new screens are
measured. What is left here is the part that only exists inside a WebView, plus the two-machine cases no
single-process test can reach.
### 3.1 A keychain with no groups looks exactly as it did
Open the hosts screen without creating any group.
**Pass:** the sidebar list is the flat list of hosts it always was — no headings, no UNGROUPED, nothing
saying the hosts are unfiled.
**Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they
did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature.
### 3.2 Filing hosts, and folding a heading
Make two groups, file some hosts into each through the host editor, then click a heading.
**Pass:** the heading's chevron flips and its hosts disappear; the count on the heading does not change,
because it counts what is in the group rather than what is on screen. Clicking again brings them back.
**Also check:** clicking a heading does not change which host is selected — the buttons at the foot of the
sidebar go on acting on the same machine. This is asserted in a test, but the test drives the view model
directly; what it cannot see is whether the `ListBox` writes something else back through the binding first.
### 3.3 Deleting a group with hosts in it
Select a group with hosts and press DELETE.
**Pass:** the question names how many hosts are filed under it and says they stay. Agreeing removes the
group; the hosts reappear under UNGROUPED with everything else about them unchanged.
**Failure means:** if the hosts vanish, the delete is rewriting host payloads, which it must not — see
`HostGroupRepository`.
### 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
again. Back on A, sync.
**Pass:** A shows the host under UNGROUPED. Open that host's editor: the group picker shows "(a group that is
no longer here)" and *keeps it selected*. Change the port and save.
**Failure means:** if the picker opened on "No group", saving has just unfiled the host — quietly, as a side
effect of an unrelated edit. That is the case `BuildGroupChoices` adds the placeholder for.
### 3.5 A grouped host stays editable on an older build · **needs two builds**
Only worth doing before a release that ships alongside an older client. A host filed into a group is written
at payload schema 4; an older build must show it and refuse to edit it, rather than editing it and dropping
the group.
**Pass:** the older build says the host was written by a newer version. A host with *no* group still edits
normally there — that is what makes the version a maximum over the fields present rather than a stamp.
### 3.6 Inserting a snippet · **the one that cannot be tested here**
Open a terminal, go to SNIPS, select a snippet with `Press Enter after inserting this` **off**, and press the
insert button.
**Pass:** the terminal comes forward with the command sitting at the prompt, not run. The button named the
tab it was going to — check that it named the right one if several are open.
**Failure means:** if the command runs by itself, the flag byte or the JavaScript that reads it is wrong. If
nothing appears at all, the frame reached a pane the page does not have.
### 3.7 A multi-line snippet does not run line by line · **the reason the opcode exists**
Save a snippet whose command is three lines — `echo one`, `echo two`, `echo three` — with the run flag off,
and insert it into a **bash or zsh** session.
**Pass:** all three lines sit at the prompt as one pending command, and nothing runs until Enter. Modern
shells turn bracketed paste on, xterm.js sees that in the output stream, and `term.paste` wraps the text.
**Failure means:** if the first two lines execute and the third waits, the text went through as plain input
and the bracketing did not happen — which is the whole failure this frame was added to prevent.
**Then insert the same snippet into something with bracketed paste off** — a raw `sh`, or a session inside
`vi`. The lines *will* run there, and that is correct and unavoidable: without the mode there is no way to
distinguish pasted newlines from typed ones. It is why the screen says "types this into whatever is there"
rather than "runs this command".
### 3.8 RUN presses Enter
Select a snippet with the run flag on and press RUN.
**Pass:** the command runs.
**Failure means:** if the command appears and does not run, the `\r` is going through `paste` instead of
`input` — inside the bracketed wrapper it is literal text, so nothing executes.
### 3.9 Inserting into a tab whose remote has hung up
Open a terminal, `exit` it, leave the tab open, then insert a snippet at it.
**Pass:** the screen says that tab is no longer connected, and nothing is claimed to have been sent.
### 3.10 Both new kinds reach a second machine · **needs two machines**
Make a group and a snippet on A and sync; sync B.
**Pass:** both arrive, with their names and — for the snippet — its run flag intact. A snippet whose flag
arrives *set* when it was saved unset is the one failure here worth stopping for.
---
## Phase 4 — Logs
Both logs are synced item kinds with the whole pipeline covered: payloads round-trip, the server refuses
every plaintext field, retention is tested against a real vault, the activity hook is tested through the
repository every kind writes through, and the recursion guard has a test of its own. What is left here is
the part that only happens across a lock, across a process exit, or across two machines.
### 4.1 A connection is recorded when the tab closes, not before
Connect to a host, leave the tab open, and open LOGS.
**Pass:** the connection is at the top of CONNECTIONS with a green dot and the words **still open** — not a
dash, and not a duration. Close the tab and press REFRESH: the same connection now has a duration.
**Failure means:** a dash instead of "still open" reads as a recording that failed, which is the opposite of
what is happening. A duration before the tab closes means an entry is being written at open, which would
also mean it gets written twice.
### 4.2 The duration is plausible
Connect, wait a measured minute or two, disconnect.
**Pass:** the LASTED column agrees with the clock, rounded to whole units.
**Failure means:** a wildly wrong number points at the two timestamps coming from different clocks — both
should come from the workspace's own `TimeProvider`.
### 4.3 Closing the application records the tabs that were open · **the one most likely to be wrong**
Open two terminals and close the DodoSSH window without closing the tabs. Start it again, unlock, open LOGS.
**Pass:** both connections have entries, with durations running up to the moment you closed the window.
**Failure means:** they are the ordinary way a session ends, and the workspace's own close-outs happen while
it tears sessions down — *after* the vault they would be written into has gone. `ConnectionRecorder`'s
`DisposeAsync` closes the tickets itself, before the session is disposed, and waits up to two seconds for
the queue. If the entries are missing, that ordering has been broken; if closing the window became slow,
the bounded wait has.
### 4.4 A shell open across a lock still gets its entry
Connect to a host, lock the keychain from the titlebar, unlock again, then close the tab.
**Pass:** the connection is recorded, into the vault it was made in.
**Failure means:** the ticket keeps the repository it was opened against precisely so this works. A missing
entry means it is reading the current one instead, which would also mean an entry could be filed into the
wrong vault once shared vaults land.
### 4.5 A refused host key is recorded
Connect to a host, approve its key, then change the key on the remote (or edit the pin) so the next
connection is refused.
**Pass:** an entry appears with **host key refused** beside it. This is the row the connection log most
exists for — a changed host key is refused with no way past it, so the status line is otherwise its only
trace.
### 4.6 The SFTP session is recorded separately
Open the files screen and connect, then disconnect.
**Pass:** an entry with **files** in the KIND column, separate from any terminal entry.
**Failure means:** if it is missing, our log disagrees with the remote's own `auth.log`, which records the
second login. Anybody comparing the two would be right to believe the host.
### 4.7 The keychain log records names and never values
Edit a stored password: change both the password and the username. Open LOGS → KEYCHAIN.
**Pass:** one row saying **changed**, with `Password, Username` in the FIELDS column.
**Failure means:** if any part of the old or new password appears anywhere on that screen, stop — that is
the one thing this payload must never carry, and it would now be synced to every machine in the vault.
### 4.8 A pin trusted at the prompt is recorded
Connect to a host you have never reached and approve the fingerprint.
**Pass:** the keychain log shows a `KnownHostKey` **created**. This write never goes through a screen, so it
is exactly the one a hook placed in the view models would have missed.
### 4.9 The pending count and the status line stay honest
Save a host while online and watch the titlebar and the status line for a minute.
**Pass:** the pending count returns to zero and stays there, and the status line keeps saying what the save
said — it does not get overwritten a moment later by a sync report.
**Failure means:** every user action queues a log entry a moment afterwards. If the count sticks at 1 or the
status line flickers to "Synchronised: 1 out", the log entries have stopped being excluded from the two
numbers that are about the user's own work.
### 4.10 Both logs reach a second machine · **needs two machines**
Connect and edit something on A, sync; then sync B and open LOGS there.
**Pass:** both entries are on B, with A's device name on them. This is the claim the whole decision to sync
these rests on.
### 4.11 Retention actually prunes · **slow, or needs a clock**
Only checkable honestly by leaving a vault in use for months, or by temporarily lowering
`LogRetention.Default` in a debug build and watching a prune remove the excess and push the tombstones.
**Pass:** the count comes down, and the second machine's copy comes down too at its next sync.
**Failure means:** these entries sync, so a prune that does not push leaves every other machine holding
them — and this machine deleting them again on every pass.
---
## Phase 6 — S3 as a remote in the file browser
The parts that are this client's own reasoning are covered: the path-to-key translation, what a bucket must
have before it can be stored, the server's refusal of every plaintext field, and the item kind end to end.
What is left needs a real endpoint, and a fake would only assert our reading of the protocol back at us.
**Get a bucket first.** MinIO in Docker is the cheapest way and exercises the harder path — path-style
addressing, a custom endpoint, and a region that is ignored:
```bash
docker run -p 9000:9000 -e MINIO_ROOT_USER=dodossh -e MINIO_ROOT_PASSWORD=dodossh-secret minio/minio server /data
```
### 6.1 Adding a bucket
Keychain → BUCKETS → `+ BUCKET`. Endpoint `http://localhost:9000`, path-style **on**, any region.
**Pass:** it saves, appears in the list with the bucket and endpoint under its name, and syncs.
**Failure means:** if saving is refused, read the message — the validation exists so the reason names the
field rather than arriving later as an SDK error about resolving a URI.
### 6.2 Path-style addressing · **the one most likely to be wrong**
Save the same bucket with path-style **off** and open it.
**Pass:** it fails, and the failure is a name-resolution error mentioning `bucket.localhost`.
**Why it is worth doing deliberately:** that is exactly what a user gets when they leave the checkbox at its
default against a self-hosted service, and the message names neither buckets nor the setting. Seeing it once
is what makes the hint under the checkbox worth its space.
### 6.3 Browsing
Files → BUCKET → pick it → OPEN.
**Pass:** the right pane lists the bucket root. Prefixes appear as directories in the directory colour;
objects appear as files with sizes and dates. The PERMS column is empty — a bucket has no POSIX mode, and a
plausible `-rw-r--r--` would be invented.
**Also check** that the timestamps agree with the local pane's. Both columns are UTC; if the bucket's are out
by your machine's offset, the `DateTime.Kind` handling in `S3FileStore.Utc` has regressed.
### 6.4 Upload, and the pipe underneath it
Upload a file of a few hundred megabytes.
**Pass:** it completes, the object is in the bucket at the right key and the right size, and the machine's
memory does not grow with the file. The upload streams through a pipe into a multipart upload — nothing is
buffered to disk twice and nothing is held whole in memory.
**Failure means:** if it hangs at the end, the pipe's writer is not being completed on disposal. If memory
tracks the file size, the multipart path is not being taken.
### 6.5 A failed upload surfaces at the write
Start an upload and stop MinIO halfway.
**Pass:** the queue row fails with a message from the service, reasonably promptly.
**Failure means:** if it hangs instead, the background upload is failing without completing the pipe's
reader — and the copy is blocked on a pipe nobody is draining. That is the case `S3UploadStream` completes
the reader *with* the exception for.
### 6.6 Download, and resume
Download a large object, stop it partway, and resume.
**Pass:** it resumes from where it stopped, and the finished file matches the original. This direction is the
one where a bucket is better than SFTP — a ranged GET is part of the protocol.
### 6.7 Upload resume is refused, and says why
Start an upload, stop it partway, and press RESUME.
**Pass:** it fails with a message saying an object cannot be written from the middle, so an interrupted
upload starts again rather than resuming. RETRY from the start works.
**Why this is not a bug:** objects are immutable. Multipart could rebuild an interrupted transfer, but only
by persisting the upload id and every part's ETag across the interruption. Refusing is honest; silently
starting from zero would corrupt the file.
### 6.8 Delete refuses a prefix with anything under it
Try to delete a directory in the bucket that has objects in it.
**Pass:** refused, saying there are still objects under it. Deleting an empty one works.
### 6.9 The keys never appear anywhere they should not
After adding and editing a bucket, open LOGS → KEYCHAIN.
**Pass:** the entry says `Secret access key` in the FIELDS column and nowhere on that screen does any part of
the key itself appear.
### 6.10 Against real AWS · **needs an account**
Repeat 6.1 and 6.3 with the endpoint blank, a real region, and path-style **off**.
**Pass:** it lists. This is the path that exercises `RegionEndpoint.GetBySystemName` and virtual-host
addressing, neither of which MinIO covers.
+22 -3
View File
@@ -114,9 +114,11 @@ off-screen page. Worth revisiting if idle power ever matters.
rather than refusing, so any path that fits a terminal with almost no viewport sends `window-change` for a rather than refusing, so any path that fits a terminal with almost no viewport sends `window-change` for a
2x1 window and permanently mangles the wrapped scrollback. Reachable today by minimising, and — once splits 2x1 window and permanently mangles the wrapped scrollback. Reachable today by minimising, and — once splits
land — by dragging a splitter to the edge. `terminal.js` now skips the fit below 40 px in either axis. land — by dragging a splitter to the edge. `terminal.js` now skips the fit below 40 px in either axis.
Related and not yet addressed: the conflict log above the terminal is an `ItemsControl` with no Related, and now fixed: the conflict log was an `ItemsControl` with no `ScrollViewer` and no `MaxHeight` on
`ScrollViewer` and no `MaxHeight` on an `Auto` row, so enough conflicts squeeze the terminal row toward an `Auto` row, so enough conflicts squeezed the row below it toward nothing. It survived that long because
nothing. it lived in `MainWindow.axaml`, which no test can lay out. Moving it into `HostsScreen.axaml` — a
`UserControl`, and therefore measurable — is what surfaced it; it now has both, and
`TheHostsScreenFitsWithAConflictLogTooLongToShow` fails without them.
**Keyboard focus crosses into the WebView by itself and does not come back.** This is the asymmetry to **Keyboard focus crosses into the WebView by itself and does not come back.** This is the asymmetry to
know; the connect-focus bug that led here was only its first symptom. Measured on Windows with a harness know; the connect-focus bug that led here was only its first symptom. Measured on Windows with a harness
@@ -241,6 +243,23 @@ MIT, committed as UMD bundles under `WebAssets/vendor` and embedded as Avalonia
esbuild step, so a clean clone builds with the .NET SDK alone. The cost is that upgrades are a manual esbuild step, so a clean clone builds with the .NET SDK alone. The cost is that upgrades are a manual
re-download; the licence and versions are recorded here so that stays visible. re-download; the licence and versions are recorded here so that stays visible.
**Bracketed paste is the renderer's to decide, and it is why snippets go through a frame.** xterm tracks
`\e[?2004h` from the remote's own output and `Terminal.paste(text)` wraps the text in paste markers only
when the mode is on — which is what makes a shell treat embedded newlines as text rather than as "run
this". The host process cannot make that decision: `TerminalDataPlane` moves opaque bytes and never parses
output, so writing a snippet straight into the pump would mean guessing, and guessing wrong executes every
line of a multi-line command. Hence `TerminalServerOpcode.Paste`. Two consequences worth keeping:
- The Enter for a snippet marked as running goes through `Terminal.input('\r')`, **outside** the wrapper. A
`\r` appended to the pasted text is bracketed with it and arrives as a literal character, so nothing runs.
- Against a remote with bracketed paste *off* — a raw `sh`, or a session inside an editor — a multi-line
snippet does run line by line, and nothing can prevent that. It is a property of the terminal protocol,
not of this client, which is why the screen says "types this into whatever is there".
Both methods were confirmed present on the public API of the vendored `@xterm/xterm` 6.0.0 bundle before
being written against; neither is reachable from any test in this repository, so they are in
`docs/manual-checks.md` as checks 3.63.8.
**SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag. **SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag.
`ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely `ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely
observes it: `PtyAndResizeSpikeTests` reads `stty size` back from a real sshd after resizing, and observes it: `PtyAndResizeSpikeTests` reads `stty size` back from a real sshd after resizing, and
@@ -46,13 +46,23 @@ public interface IVaultAccessService
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the /// Two rules, and only two. A personal vault answers to its owner. A team vault answers to the
/// <c>v_user_vault_permission</c> view and per-resource ACLs arrive in M3 — this is the one place /// team's active members, with the role deciding how much. Everything else is denied, which is what
/// that changes, which is why every caller goes through it rather than comparing owner ids inline. /// keeps an unimplemented ownership kind from falling through to a permissive default.
/// </para> /// </para>
/// <para> /// <para>
/// A team vault is explicitly denied for now rather than falling through to a permissive default. /// <b>Permission is not the same thing as readability.</b> This service decides what the
/// Failing closed on an unimplemented path is the only safe direction. /// <em>server</em> will serve; whether the caller can decrypt what it serves depends on holding a
/// vault key grant, which the server cannot produce and cannot verify. A member with Read and no
/// grant is a normal, temporary state — they have just been added, or the vault has been rekeyed —
/// and <c>VaultSummary.WrappedVaultKey</c> is null for exactly that case. Conflating the two here
/// would mean a newly added member's vault silently vanished from their list instead of appearing
/// and saying it is waiting for a key.
/// </para>
/// <para>
/// Deliberately not a database view. <c>v_user_vault_permission</c> was sketched for this, and the
/// rules turned out to be sixteen lines of C# that both methods share — a view would have put the
/// authorization model somewhere migrations own and tests cannot reach without a container.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
@@ -80,14 +90,23 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
return VaultAccess.Denied; return VaultAccess.Denied;
} }
if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId) if (vault.OwnerKind == VaultOwnerKind.Personal)
{ {
return new VaultAccess(vault, OwnerPermissions); return vault.OwnerUserId == userId
? new VaultAccess(vault, OwnerPermissions)
: VaultAccess.Denied;
} }
// Team vaults are not readable until M3 wires up membership and grants. Denying is the if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
// correct behaviour in the meantime. {
return VaultAccess.Denied; return VaultAccess.Denied;
}
var role = await FindRoleAsync(userId, teamId, cancellationToken).ConfigureAwait(false);
return role is { } granted
? new VaultAccess(vault, ForRole(granted))
: VaultAccess.Denied;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -95,16 +114,98 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
Guid userId, Guid userId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Personal ownership only, matching ResolveAsync. When M3 adds the // The memberships first, then one pass over the vaults. The alternative — a join per vault —
// v_user_vault_permission view, both methods change together and neither can drift. // would be the same answer at more round trips, and a user belongs to a handful of teams.
var roles = await database.TeamMemberships
.Where(m => m.UserId == userId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.ToDictionaryAsync(m => m.TeamId, m => m.Role, cancellationToken)
.ConfigureAwait(false);
var teamIds = roles.Keys.ToArray();
var vaults = await database.Vaults var vaults = await database.Vaults
.Where(v => v.OwnerKind == VaultOwnerKind.Personal .Where(v => v.DeletedAtUtc == null
&& v.OwnerUserId == userId && ((v.OwnerKind == VaultOwnerKind.Personal && v.OwnerUserId == userId)
&& v.DeletedAtUtc == null) || (v.OwnerKind == VaultOwnerKind.Team
&& v.TeamId != null
&& teamIds.Contains(v.TeamId.Value))))
.OrderBy(v => v.CreatedAtUtc) .OrderBy(v => v.CreatedAtUtc)
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))]; var accessible = new List<VaultAccess>(vaults.Count);
foreach (var vault in vaults)
{
if (vault.OwnerKind == VaultOwnerKind.Personal)
{
accessible.Add(new VaultAccess(vault, OwnerPermissions));
continue;
}
// The dictionary lookup cannot miss — the query filtered on the same set — but a role
// that somehow is not there must not become a permissive default.
if (vault.TeamId is { } teamId && roles.TryGetValue(teamId, out var role))
{
accessible.Add(new VaultAccess(vault, ForRole(role)));
}
}
return accessible;
}
/// <summary>
/// Maps a team role onto vault permissions.
/// </summary>
/// <remarks>
/// <para>
/// Union-only, with no Deny: evaluation stays monotonic and testable, and restriction is
/// expressed by granting narrowly. See <see cref="PermissionFlags"/>.
/// </para>
/// <para>
/// <see cref="PermissionFlags.Connect"/> rides along with Read for every role that has it,
/// because it is a user-interface hint rather than a boundary — a role that could read a private
/// key but was refused Connect would be describing a restriction this architecture cannot
/// enforce. Granting it to a viewer is honest about that; withholding it would not be.
/// </para>
/// <para>
/// Owner and Admin resolve identically here on purpose. What separates them is what they may do
/// to the <em>team</em> — appoint owners, delete it — which is not a vault permission and is
/// checked where those operations live.
/// </para>
/// </remarks>
private static PermissionFlags ForRole(TeamRole role) => role switch
{
TeamRole.Viewer => PermissionFlags.Read | PermissionFlags.Connect,
TeamRole.Member => PermissionFlags.Read | PermissionFlags.Connect | PermissionFlags.Write,
TeamRole.Admin or TeamRole.Owner => OwnerPermissions,
// Unspecified, or a value written by a newer server. Failing closed is the only safe
// direction for a role this build does not understand.
_ => PermissionFlags.None,
};
/// <remarks>
/// Only an <see cref="MembershipStatus.Active"/> membership confers anything. An invited member
/// has not accepted and a revoked one has been removed; neither is a state in which the server
/// should be serving ciphertext.
/// </remarks>
private async Task<TeamRole?> FindRoleAsync(
Guid userId,
Guid teamId,
CancellationToken cancellationToken)
{
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == userId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return membership?.Role;
} }
} }
@@ -0,0 +1,162 @@
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Identity;
/// <summary>
/// The public-key directory.
/// </summary>
/// <remarks>
/// <para>
/// This is where a client gets the key it is about to wrap a vault key to, so its shape is a security
/// decision rather than a convenience one. Two rules follow from that.
/// </para>
/// <para>
/// <b>Lookup by exact email, never by prefix.</b> There is no search, no wildcard and no listing of
/// everybody. A caller has to already know the address, which keeps this from being a way to
/// enumerate an organisation's staff out of a server that stores their addresses in plaintext.
/// </para>
/// <para>
/// <b>Lookup by id is restricted to people the caller shares a team with.</b> Ids come from a member
/// list the caller can already read, so nothing is hidden that they cannot reach another way — but an
/// unrestricted id lookup would turn a leaked id from any source into a directory hit.
/// </para>
/// <para>
/// What this returns is <em>evidence</em>, not authority. A client must check the identity-provider
/// binding, compare against any fingerprint it has pinned, and confirm the key log head before
/// wrapping anything. Trusting the directory's word is the one mistake that undoes end-to-end
/// encryption entirely; see ADR 0001 and <c>DirectoryEntry</c>'s own remarks.
/// </para>
/// </remarks>
internal sealed class DirectoryService(DodoDbContext database)
{
/// <summary>Looks a user up by exact email address.</summary>
internal async Task<IReadOnlyList<DirectoryEntry>> FindByEmailAsync(
string email,
CancellationToken cancellationToken)
{
var normalised = email.Trim();
if (normalised.Length == 0)
{
return [];
}
// The email column is citext, so this comparison is case-insensitive in the database and the
// partial unique index on it means at most one row can match. Written as a list anyway
// because the response shape must not have to change if a second issuer ever shares one.
var users = await database.Users
.Where(u => u.Email == normalised
&& u.DeletedAtUtc == null
&& u.Status == UserStatus.Active)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
}
/// <summary>Looks up accounts the caller shares an active team with.</summary>
internal async Task<IReadOnlyList<DirectoryEntry>> FindTeammatesAsync(
Guid callerId,
IReadOnlyList<Guid> userIds,
CancellationToken cancellationToken)
{
if (userIds.Count == 0)
{
return [];
}
var teamIds = await database.TeamMemberships
.Where(m => m.UserId == callerId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.Select(m => m.TeamId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (teamIds.Count == 0)
{
return [];
}
var visible = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId)
&& userIds.Contains(m.UserId)
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.Select(m => m.UserId)
.Distinct()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var users = await database.Users
.Where(u => visible.Contains(u.Id) && u.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
}
/// <remarks>
/// A user with no current key is dropped rather than returned with empty key fields. The entry
/// exists to be wrapped to, and one carrying no key is something a caller would have to remember
/// to check for — which is the kind of check that gets forgotten exactly once.
/// </remarks>
private async Task<IReadOnlyList<DirectoryEntry>> BuildAsync(
List<UserAccount> users,
CancellationToken cancellationToken)
{
if (users.Count == 0)
{
return [];
}
var userIds = users.Select(u => u.Id).ToArray();
var keys = await database.UserKeys
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
// The log position of the statement that introduced each key, so a client can compare what
// it is told here against the append-only chain rather than taking this response on trust.
var keyIds = keys.Select(k => k.UserId).ToArray();
var sequences = await database.KeyLog
.Where(e => keyIds.Contains(e.UserId))
.GroupBy(e => new { e.UserId, e.Generation })
.Select(g => new { g.Key.UserId, g.Key.Generation, Sequence = g.Min(e => e.Sequence) })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var entries = new List<DirectoryEntry>(users.Count);
foreach (var user in users)
{
var key = keys.Find(k => k.UserId == user.Id);
if (key is null)
{
continue;
}
var sequence = sequences
.Find(s => s.UserId == user.Id && s.Generation == key.Generation)?
.Sequence ?? 0;
entries.Add(new DirectoryEntry(
UserId: user.Id,
Email: user.Email,
DisplayName: user.DisplayName,
EncryptionPublicKey: key.EncryptionPublicKey,
SigningPublicKey: key.SigningPublicKey,
Fingerprint: key.FingerprintSha256,
KeyGeneration: key.Generation,
KeyLogSequence: sequence));
}
return entries;
}
}
@@ -104,6 +104,117 @@ internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, Enrollment
} }
} }
/// <summary>
/// Looks up the public keys a vault key can be wrapped to.
/// </summary>
/// <remarks>
/// <para>
/// A GET with query parameters rather than a request DTO, because <c>BodyOnlyRequestBinder</c> binds
/// bodies and nothing else — deliberately, so that a query string can never overwrite a body field —
/// and this call has no body to speak of. The parameters are read one at a time, as route values are.
/// </para>
/// <para>
/// Exactly one of <c>email</c> and <c>userId</c> is expected. They are separate parameters rather than
/// one polymorphic term because they answer to different rules: an email may name anybody enrolled
/// here, an id only somebody the caller shares a team with. See <see cref="DirectoryService"/>.
/// </para>
/// </remarks>
internal sealed class LookupDirectoryEndpoint(
ICurrentUserContext currentUser,
DirectoryService directory)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/directory");
// Enrolled. The answer exists to be wrapped to, and a caller with no identity key of their
// own has nothing to wrap and no signature to attribute it with.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("LookupDirectory")
.WithSummary("Looks up a user's published identity keys, by exact email or by id.")
.WithTags("Identity"));
}
/// <inheritdoc />
public override async Task<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var email = HttpContext.Request.Query["email"].ToString();
var rawUserId = HttpContext.Request.Query["userId"].ToString();
if (!string.IsNullOrWhiteSpace(email))
{
return TypedResults.Ok(await directory.FindByEmailAsync(email, ct).ConfigureAwait(false));
}
if (Guid.TryParse(rawUserId, out var userId))
{
return TypedResults.Ok(
await directory.FindTeammatesAsync(user.Id, [userId], ct).ConfigureAwait(false));
}
// An empty result would be indistinguishable from "nobody has that address", which is a
// different fact and one a client would go on to act on.
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.MalformedRequest,
"Supply either an exact 'email' or a 'userId'. This directory has no search.");
}
}
/// <summary>
/// Serves the append-only key log, so a client can verify a public key rather than trust one.
/// </summary>
/// <remarks>
/// Paged with <c>after</c> and <c>limit</c> on the query string, read one at a time as route values
/// are — see <see cref="LookupDirectoryEndpoint"/> for why this endpoint has no request DTO.
/// </remarks>
internal sealed class ReadKeyLogEndpoint(KeyLogService keyLog)
: EndpointWithoutRequest<Ok<KeyLogPage>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/keylog");
// Enrolled rather than authenticated, matching the directory it exists to check. Nothing here
// is secret, but a caller with no key of their own has nothing to verify against.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("ReadKeyLog")
.WithSummary("Reads the append-only key log, with its current head.")
.WithTags("Identity"));
}
/// <inheritdoc />
public override async Task<Ok<KeyLogPage>> ExecuteAsync(CancellationToken ct)
{
// A malformed value reads as "from the beginning" rather than as an error. The log is public
// and ordered, so the worst a bad cursor costs is a larger response — and a 400 here would
// make a client's own paging bug look like a server refusal.
_ = long.TryParse(
HttpContext.Request.Query["after"],
System.Globalization.CultureInfo.InvariantCulture,
out var after);
int? limit = int.TryParse(
HttpContext.Request.Query["limit"],
System.Globalization.CultureInfo.InvariantCulture,
out var parsed)
? parsed
: null;
return TypedResults.Ok(await keyLog.ReadAsync(after, limit, ct).ConfigureAwait(false));
}
}
/// <summary>Registers a device key so this machine can unlock without the passphrase.</summary> /// <summary>Registers a device key so this machine can unlock without the passphrase.</summary>
/// <remarks> /// <remarks>
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the /// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
@@ -0,0 +1,87 @@
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Identity;
/// <summary>
/// Serves the append-only key log.
/// </summary>
/// <remarks>
/// <para>
/// Readable by every enrolled caller, in full. That is the point of it: a log only one party can read
/// proves nothing, and the whole mechanism is that independent clients compare what they were shown.
/// Nothing here is secret — public keys, signatures over them, and hashes.
/// </para>
/// <para>
/// The server never edits this table and this service never writes to it. Appends happen in exactly
/// one place, under a deployment-wide advisory lock, inside the enrollment transaction; see
/// <see cref="EnrollmentService"/> and docs/crypto.md §7.2 for why serialising them is load-bearing.
/// </para>
/// </remarks>
internal sealed class KeyLogService(DodoDbContext database)
{
/// <summary>Largest page served, whatever a caller asks for.</summary>
/// <remarks>
/// A client verifying the chain has to read every entry in order, so paging is a transfer-size
/// concern rather than a filter. The cap is generous because skipping entries is not an option:
/// a gap breaks the link and the verification fails, correctly, on data that was fine.
/// </remarks>
private const int MaxPageSize = 500;
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
internal async Task<KeyLogPage> ReadAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken)
{
var take = Math.Clamp(limit ?? MaxPageSize, 1, MaxPageSize);
var entries = await database.KeyLog
.Where(e => e.Sequence > afterSequence)
.OrderBy(e => e.Sequence)
// One more than asked for, so "is there another page" is answered by what came back
// rather than by a second count that could disagree with it.
.Take(take + 1)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var hasMore = entries.Count > take;
if (hasMore)
{
entries.RemoveAt(entries.Count - 1);
}
var head = await database.KeyLog
.OrderByDescending(e => e.Sequence)
.Select(e => new { e.Sequence, e.Hash })
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return new KeyLogPage(
Entries:
[
.. entries.Select(e => new KeyLogRecord(
e.Sequence,
e.UserId,
e.Generation,
e.EncryptionPublicKey,
e.SigningPublicKey,
e.StatementSignature,
e.PreviousHash,
e.Hash,
e.CreatedAtUtc)),
],
HeadSequence: head?.Sequence ?? 0,
// The genesis predecessor for an empty log, which is the same value the first entry will
// record. A client comparing heads therefore needs no special case for "nothing yet".
Head: head?.Hash ?? KeyLogChain.CreateGenesisPreviousHash(),
HasMore: hasMore);
}
}
+503 -3
View File
@@ -71,6 +71,8 @@ internal static class ItemKinds
new[] new[]
{ {
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(), (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
new HostGroupKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
}.ToDictionary(kind => kind.WireType); }.ToDictionary(kind => kind.WireType);
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary> /// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
@@ -129,6 +131,20 @@ internal sealed class HostKind : IItemKind
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint /// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint
/// violation surfacing as a 500. /// violation surfacing as a 500.
/// </summary> /// </summary>
/// <remarks>
/// <para>
/// The group check comes <em>first</em>, ahead of the relay branch, and that placement is the point: the
/// relay branch returns early on its happy path, so a check placed after it would apply to non-relay
/// hosts only — leaving the one field this refusal exists for reachable by exactly the hosts most likely
/// to carry it.
/// </para>
/// <para>
/// <c>SyncPlaintextFields.GroupId</c> is part of a frozen wire contract and cannot be removed from it, so
/// refusing it here is what actually keeps the value out of the database. The column it used to be copied
/// into was dropped when groups landed; see <see cref="VaultHostGroup"/> for why membership travels inside
/// the payload instead.
/// </para>
/// </remarks>
/// <inheritdoc /> /// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error) public bool ValidateFields(SyncPlaintextFields fields, out string error)
{ {
@@ -136,6 +152,12 @@ internal sealed class HostKind : IItemKind
error = string.Empty; error = string.Empty;
if (fields.GroupId is not null)
{
error = "A host's group is inside its encrypted payload; the server does not store one.";
return false;
}
if (fields.RelayEnabled) if (fields.RelayEnabled)
{ {
if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null) if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
@@ -172,7 +194,6 @@ internal sealed class HostKind : IItemKind
host.RelayEnabled = fields.RelayEnabled; host.RelayEnabled = fields.RelayEnabled;
host.Hostname = fields.RelayEnabled ? fields.Hostname : null; host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
host.Port = fields.RelayEnabled ? fields.Port : null; host.Port = fields.RelayEnabled ? fields.Port : null;
host.GroupId = fields.GroupId;
} }
/// <remarks> /// <remarks>
@@ -197,8 +218,7 @@ internal sealed class HostKind : IItemKind
return new SyncPlaintextFields( return new SyncPlaintextFields(
RelayEnabled: host.RelayEnabled, RelayEnabled: host.RelayEnabled,
Hostname: host.Hostname, Hostname: host.Hostname,
Port: host.Port, Port: host.Port);
GroupId: host.GroupId);
} }
} }
@@ -487,3 +507,483 @@ internal sealed class KnownHostKeyKind : IItemKind
/// <inheritdoc /> /// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null; public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
} }
/// <summary>Host groups: an envelope and nothing else.</summary>
/// <remarks>
/// The kind that closes a hole rather than opening one. <c>SyncPlaintextFields</c> has carried a
/// <c>GroupId</c> since the contract was frozen and <see cref="HostKind"/> used to copy it into a column;
/// nothing ever sent one, and now nothing may. The group itself arrives here as ciphertext with no name the
/// server can read, which is the same answer <see cref="KnownHostKeyKind"/> gives for the same reason.
/// </remarks>
internal sealed class HostGroupKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.HostGroup;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.HostGroup;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.HostGroups.SingleOrDefaultAsync(g => g.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.HostGroups
.Where(g => g.VaultId == vaultId && ids.Contains(g.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var group = new VaultHostGroup { Id = id, VaultId = vaultId };
database.HostGroups.Add(group);
return group;
}
/// <summary>
/// Refuses every plaintext field there is, including the one named after this type.
/// </summary>
/// <remarks>
/// A <c>GroupId</c> on a group would be a parent pointer, and groups are flat — see
/// <see cref="VaultHostGroup"/> for why nesting merged by a scalar three-way merge can produce a cycle
/// nothing is able to repair. Refusing it here means a client that grows a tree cannot store one by
/// accident.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A host group is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null)
{
error = "Host groups are flat, and a group's name is inside its payload.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A host group has no public key.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Snippets: an envelope and nothing else.</summary>
/// <remarks>
/// A label column here would sort a list this server never draws, and the commands beside that label describe
/// the estate as precisely as a list of hostnames would. So this kind is as strict as
/// <see cref="CredentialKind"/>, and for the aggregation reason rather than the secrecy one.
/// </remarks>
internal sealed class SnippetKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.Snippet;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.Snippet;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.Snippets.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.Snippets
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var snippet = new VaultSnippet { Id = id, VaultId = vaultId };
database.Snippets.Add(snippet);
return snippet;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A snippet is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A snippet's contents, including anything it is scoped to, stay inside its payload.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A snippet has no public key.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Connection log entries: an envelope and nothing else.</summary>
/// <remarks>
/// <para>
/// The strictest kind here, and the one where a plaintext column would have been most tempting: a
/// <c>started_at</c> would let this server order and prune a log without any client's help. It gets none,
/// because a timestamp column on this table is a record of when each user works, and the times are the
/// interesting part of a connection log even when the hostnames are sealed.
/// </para>
/// <para>
/// <b>The server cannot enforce write-once, and does not pretend to.</b> That an entry is created and never
/// updated is a client rule — see <see cref="VaultConnectionLogEntry"/> — and the shared write path would
/// accept an upsert with a correct <c>expectedVersion</c> like any other. Adding a refusal here would be a
/// guarantee about payload semantics this server cannot read.
/// </para>
/// </remarks>
internal sealed class ConnectionLogEntryKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.ConnectionLogEntry;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.ConnectionLogEntry;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ConnectionLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ConnectionLog
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var entry = new VaultConnectionLogEntry { Id = id, VaultId = vaultId };
database.ConnectionLog.Add(entry);
return entry;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A log entry is not something the server dials; what was connected to stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A log entry names what it is about inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A log entry carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Activity log entries: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="ConnectionLogEntryKind"/>. <c>SyncPlaintextFields.Kind</c> exists and would fit
/// "which sort of item this entry is about" exactly, which is why it is refused by name: a column recording
/// that a user created four SSH keys last Tuesday is a description of the keychain, assembled from facts
/// that each look harmless.
/// </remarks>
internal sealed class ActivityLogEntryKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.ActivityLogEntry;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.ActivityLogEntry;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ActivityLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ActivityLog
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var entry = new VaultActivityLogEntry { Id = id, VaultId = vaultId };
database.ActivityLog.Add(entry);
return entry;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A log entry is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "Which item a log entry is about stays inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A log entry carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Object stores: an envelope and nothing else.</summary>
/// <remarks>
/// As strict as <see cref="CredentialKind"/>, because it holds the same class of thing. A secret access key
/// is a password; the endpoint beside it is, for everybody self-hosting, an address on their own network. The
/// relay does not dial a bucket, so ADR 0004's one concession has no analogue here and there is nothing to
/// weigh.
/// </remarks>
internal sealed class ObjectStoreKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.ObjectStore;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.ObjectStore;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ObjectStores.SingleOrDefaultAsync(o => o.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ObjectStores
.Where(o => o.VaultId == vaultId && ids.Contains(o.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var store = new VaultObjectStore { Id = id, VaultId = vaultId };
database.ObjectStores.Add(store);
return store;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <remarks>
/// The relay fields are refused although this type <em>does</em> hold an address, exactly as they are for
/// a pinned host key: the address belongs in the ciphertext, and a client sending it here is either
/// confused or trying to get the server to keep a list of where its users store data.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A bucket is not something the server dials; its endpoint stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A bucket's contents are inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A bucket carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
@@ -0,0 +1,371 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
namespace DodoSSH.Api.Features.Teams;
/// <summary>Creates a team, with the caller as its owner.</summary>
/// <remarks>
/// 200 rather than 201, for the reason enrollment gives: the id is chosen by the client, so a retried
/// request returns the identical team and there is no single moment of creation to point a Location
/// header at.
/// </remarks>
internal sealed class CreateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
: Endpoint<CreateTeamRequest, Results<Ok<TeamSummary>, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/teams");
// Enrolled, not merely authenticated. Somebody who has not published an identity key cannot
// be wrapped a vault key, so a team they created would be one they could never share
// anything into — and the flag they would hit instead is a 400 from the grant endpoint,
// several steps later, about a request that was fine.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("CreateTeam")
.WithSummary("Creates a team, with the caller as its owner.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamSummary>, ProblemHttpResult>> ExecuteAsync(
CreateTeamRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
try
{
return TypedResults.Ok(await teams.CreateAsync(user, req, ct).ConfigureAwait(false));
}
catch (TeamSlugTakenException exception)
{
return Problems.Coded(
StatusCodes.Status409Conflict, ProblemCodes.TeamSlugTaken, exception.Message);
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>Lists the teams the caller belongs to.</summary>
internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamService teams)
: EndpointWithoutRequest<Ok<IReadOnlyList<TeamSummary>>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/teams");
// Authenticated rather than enrolled: reading which teams you are in needs no key, and a
// member who has just been added should be able to see that before they set a vault up.
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ListTeams")
.WithSummary("Lists the teams the caller belongs to.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Ok<IReadOnlyList<TeamSummary>>> ExecuteAsync(CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
return TypedResults.Ok(await teams.ListAsync(user, ct).ConfigureAwait(false));
}
}
/// <summary>Lists a team's members.</summary>
internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams)
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/teams/{teamId:guid}/members");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ListTeamMembers")
.WithSummary("Lists a team's members.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
// 404 for a team that is not there and one the caller is not in, identically. See
// VaultAccessService for why the two must not be distinguishable.
if (!access.Granted)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await teams.ListMembersAsync(teamId, ct).ConfigureAwait(false));
}
}
/// <summary>Adds a member to a team.</summary>
internal sealed class AddTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
: Endpoint<AddTeamMemberRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/teams/{teamId:guid}/members");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("AddTeamMember")
.WithSummary("Adds a member to a team.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
AddTeamMemberRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
// 403 rather than 404 here: the team is visible to this caller, so refusing by name leaks
// nothing and "you are not an admin" is a far more useful answer than "no such team".
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can add members.");
}
try
{
var member = await teams.AddMemberAsync(user, teamId, req, ct).ConfigureAwait(false);
return TypedResults.Ok(member);
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>Changes a member's role.</summary>
internal sealed class ChangeTeamMemberRoleEndpoint(ICurrentUserContext currentUser, TeamService teams)
: Endpoint<ChangeTeamMemberRoleRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
// PUT rather than PATCH. The body is the whole of what a role is, so this replaces it
// outright and is idempotent; PATCH would promise a partial update of a single scalar.
Put("/api/v1/teams/{teamId:guid}/members/{userId:guid}/role");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("ChangeTeamMemberRole")
.WithSummary("Changes a member's role.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
ChangeTeamMemberRoleRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var memberId = Route<Guid>("userId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can change roles.");
}
try
{
var member = await teams
.ChangeRoleAsync(user, teamId, memberId, req, ct)
.ConfigureAwait(false);
return TypedResults.Ok(member);
}
catch (LastTeamOwnerException exception)
{
return Problems.Coded(
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>
/// Removes a member from a team.
/// </summary>
/// <remarks>
/// A member may remove themselves — leaving a team needs nobody's permission — but not while they
/// own it. Everyone else needs to be an admin.
/// </remarks>
internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/teams/{teamId:guid}/members/{userId:guid}");
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("RemoveTeamMember")
.WithSummary("Removes a member from a team, revoking their vault key grants.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var memberId = Route<Guid>("userId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister && memberId != user.Id)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can remove other members.");
}
try
{
await teams.RemoveMemberAsync(user, teamId, memberId, ct).ConfigureAwait(false);
return TypedResults.NoContent();
}
catch (LastTeamOwnerException exception)
{
return Problems.Coded(
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
/// <summary>Creates a vault owned by a team.</summary>
internal sealed class CreateTeamVaultEndpoint(
ICurrentUserContext currentUser,
TeamService teams,
VaultGrantService grants)
: Endpoint<CreateTeamVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/teams/{teamId:guid}/vaults");
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("CreateTeamVault")
.WithSummary("Creates a vault owned by a team, with the creator's key grant.")
.WithTags("Teams"));
}
/// <inheritdoc />
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
CreateTeamVaultRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var teamId = Route<Guid>("teamId");
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
if (!access.Granted)
{
return TypedResults.NotFound();
}
if (!access.CanAdminister)
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"Only an admin or the owner of this team can create a vault in it.");
}
try
{
var vault = await grants
.CreateTeamVaultAsync(user, access.Team!, req, ct)
.ConfigureAwait(false);
return TypedResults.Ok(vault);
}
catch (VaultGrantInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
}
catch (TeamInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
}
}
}
@@ -0,0 +1,35 @@
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// A team create or membership change was structurally unacceptable.
/// </summary>
/// <remarks>
/// The message is returned to the caller. Keep it about the shape of their own request, and never
/// about accounts or teams they cannot already see — "no such user" is safe when the caller supplied
/// the id from a directory lookup they just made, and is an enumeration oracle everywhere else.
/// </remarks>
internal sealed class TeamInvalidException(string message) : Exception(message);
/// <summary>The requested slug is already in use.</summary>
/// <remarks>
/// Its own type because it is the one create failure the caller could not have predicted from their
/// own input, and the only one whose remedy is choosing a different value rather than fixing one.
/// </remarks>
internal sealed class TeamSlugTakenException(string message) : Exception(message);
/// <summary>The change would leave a team with no owner.</summary>
/// <remarks>
/// Refused rather than allowed: a team with no owner has nobody who can appoint one, so the only
/// route back would be an operator editing the database by hand.
/// </remarks>
internal sealed class LastTeamOwnerException(string message) : Exception(message);
/// <summary>
/// A vault key grant was rejected.
/// </summary>
/// <remarks>
/// Never about the wrapped key's contents. The server cannot open it, so a grant sealing garbage is
/// accepted here and fails at the recipient as a tag failure, with the signature naming who issued
/// it. See docs/crypto.md §6.
/// </remarks>
internal sealed class VaultGrantInvalidException(string message) : Exception(message);
+68
View File
@@ -0,0 +1,68 @@
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Source-generated log events for teams, membership and vault key grants.
/// </summary>
/// <remarks>
/// Ids, roles and outcomes only. Never a wrapped key, a signature or a fingerprint: the sharing graph
/// is already visible to the operator (docs/crypto.md §10) and there is nothing to gain by adding key
/// material to what a log aggregator keeps.
/// </remarks>
internal static partial class TeamLog
{
[LoggerMessage(
EventId = 2101,
Level = LogLevel.Information,
Message = "Created team {TeamId} for user {UserId}.")]
internal static partial void TeamCreated(ILogger logger, Guid teamId, Guid userId);
[LoggerMessage(
EventId = 2102,
Level = LogLevel.Information,
Message = "Added user {MemberId} to team {TeamId} as {Role}, by {ActorId}.")]
internal static partial void MemberAdded(
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
[LoggerMessage(
EventId = 2103,
Level = LogLevel.Information,
Message = "Changed user {MemberId} in team {TeamId} to {Role}, by {ActorId}.")]
internal static partial void MemberRoleChanged(
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
/// <remarks>
/// Warning rather than information, and it names the grant count. Removal is the operation whose
/// consequences are least like what the word implies — it blocks future reads and returns nothing
/// already downloaded — so it is the one worth being able to find in a log afterwards.
/// </remarks>
[LoggerMessage(
EventId = 2104,
Level = LogLevel.Warning,
Message = "Removed user {MemberId} from team {TeamId} by {ActorId}; revoked {GrantCount} vault "
+ "key grant(s). Vaults are flagged for rekey; already-downloaded data is unaffected.")]
internal static partial void MemberRemoved(
ILogger logger, Guid teamId, Guid memberId, Guid actorId, int grantCount);
[LoggerMessage(
EventId = 2105,
Level = LogLevel.Information,
Message = "Created team vault {VaultId} for team {TeamId}, by {ActorId}.")]
internal static partial void TeamVaultCreated(
ILogger logger, Guid vaultId, Guid teamId, Guid actorId);
[LoggerMessage(
EventId = 2106,
Level = LogLevel.Information,
Message = "Issued a key grant on vault {VaultId} generation {KeyGeneration} to {RecipientId}, "
+ "by {ActorId}.")]
internal static partial void GrantIssued(
ILogger logger, Guid vaultId, int keyGeneration, Guid recipientId, Guid actorId);
[LoggerMessage(
EventId = 2107,
Level = LogLevel.Warning,
Message = "Revoked the key grant on vault {VaultId} held by {RecipientId}, by {ActorId}. "
+ "Blocks future reads only; see ADR 0001.")]
internal static partial void GrantRevoked(
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
}
@@ -0,0 +1,745 @@
using System.Globalization;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>The result of a team access check.</summary>
/// <param name="Team">The team, when the caller is an active member.</param>
/// <param name="Role">The caller's role.</param>
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
{
/// <summary>Whether the caller is in this team at all.</summary>
public bool Granted => Team is not null;
/// <summary>
/// Whether the caller may manage members and vaults.
/// </summary>
/// <remarks>
/// The team-level counterpart of <c>PermissionFlags.Admin</c>, and deliberately not derived from
/// it: those flags describe a vault, and adding a member is not an operation on any vault.
/// </remarks>
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
/// <summary>Denied access.</summary>
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
}
/// <summary>
/// Teams and their membership.
/// </summary>
/// <remarks>
/// <para>
/// <b>Membership is authorization; a key grant is access.</b> Everything in this class moves rows
/// that decide what the <em>server</em> will serve. None of it can make a vault readable, because
/// making a vault readable means wrapping its key to somebody's public key and only a client holding
/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so:
/// add them here, then share the vault key from a machine that has one. Collapsing the two would
/// require the server to hold a key, which is the one thing this design is built to avoid.
/// </para>
/// <para>
/// The reverse direction is the honest half of the same split. Removing a member revokes their
/// grants and flags every team vault for rekey, and that blocks <em>future</em> reads only. Anything
/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See
/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would
/// imply more than it delivers.
/// </para>
/// </remarks>
internal sealed class TeamService(
DodoDbContext database,
TimeProvider clock,
ILogger<TeamService> logger)
{
/// <summary>Longest acceptable slug. Matches the column.</summary>
private const int MaxSlugLength = 128;
/// <summary>Longest acceptable display name. Matches the column.</summary>
private const int MaxNameLength = 256;
/// <summary>Longest acceptable description. Matches the column.</summary>
private const int MaxDescriptionLength = 2048;
/// <summary>Creates a team, with the caller as its owner.</summary>
/// <remarks>
/// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was
/// lost can be re-sent verbatim and returns the same team rather than creating a second one under
/// a name the user meant to type once. A different body under the same id is a client that has
/// lost track of its own state and is refused rather than silently reinterpreted.
/// </remarks>
internal async Task<TeamSummary> CreateAsync(
UserAccount user,
CreateTeamRequest request,
CancellationToken cancellationToken)
{
var name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
var slug = RequireSlug(request.Slug);
var description = OptionalText(request.Description, MaxDescriptionLength);
if (request.TeamId == Guid.Empty)
{
throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client.");
}
var existing = await database.Teams
.SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
return await ResolveExistingAsync(user, existing, name, slug, cancellationToken)
.ConfigureAwait(false);
}
var team = AddTeamWithOwner(user, request.TeamId, name, slug, description);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The partial unique index on slug. Reported as its own code because it is the one
// failure the caller could not have foreseen from their own input.
throw new TeamSlugTakenException(
$"The slug '{slug}' is already in use. Choose another.");
}
TeamLog.TeamCreated(logger, team.Id, user.Id);
return new TeamSummary(
team.Id, team.Name, team.Slug, team.Description,
TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc);
}
/// <summary>
/// Adds the team row and the creator's owner membership.
/// </summary>
/// <remarks>
/// The two together, never one: a team with no members has nobody who can add any, and the row
/// would have to be found and fixed by hand.
/// </remarks>
private Team AddTeamWithOwner(
UserAccount user,
Guid teamId,
string name,
string slug,
string? description)
{
var now = clock.GetUtcNow();
var team = new Team
{
Id = teamId,
Name = name,
Slug = slug,
Description = description,
CreatedByUserId = user.Id,
CreatedAtUtc = now,
};
database.Teams.Add(team);
database.TeamMemberships.Add(new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = team.Id,
UserId = user.Id,
Role = TeamRole.Owner,
Status = MembershipStatus.Active,
JoinedAtUtc = now,
CreatedAtUtc = now,
});
return team;
}
/// <summary>Lists the teams the caller is an active member of.</summary>
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
UserAccount user,
CancellationToken cancellationToken)
{
var memberships = await database.TeamMemberships
.Where(m => m.UserId == user.Id
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (memberships.Count == 0)
{
return [];
}
var teamIds = memberships.Select(m => m.TeamId).ToArray();
var teams = await database.Teams
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
.OrderBy(t => t.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var memberCounts = await database.TeamMemberships
.Where(m => teamIds.Contains(m.TeamId)
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null)
.GroupBy(m => m.TeamId)
.Select(g => new { TeamId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
.ConfigureAwait(false);
var vaultCounts = await database.Vaults
.Where(v => v.OwnerKind == VaultOwnerKind.Team
&& v.TeamId != null
&& teamIds.Contains(v.TeamId.Value)
&& v.DeletedAtUtc == null)
.GroupBy(v => v.TeamId!.Value)
.Select(g => new { TeamId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
.ConfigureAwait(false);
return
[
.. teams.Select(team => new TeamSummary(
team.Id,
team.Name,
team.Slug,
team.Description,
ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role),
memberCounts.GetValueOrDefault(team.Id),
vaultCounts.GetValueOrDefault(team.Id),
team.CreatedAtUtc)),
];
}
/// <summary>
/// Lists a team's members.
/// </summary>
/// <remarks>
/// Available to every member, not only to admins. Whoever is about to be handed a vault key needs
/// to know who else already holds one, and a directory that only administrators can read makes
/// the sharing graph less visible to the people it is about than it is to the operator — who can
/// read it straight out of the database either way.
/// </remarks>
internal async Task<IReadOnlyList<TeamMemberSummary>> ListMembersAsync(
Guid teamId,
CancellationToken cancellationToken)
{
var memberships = await database.TeamMemberships
.Where(m => m.TeamId == teamId && m.DeletedAtUtc == null)
.Include(m => m.User)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (memberships.Count == 0)
{
return [];
}
var userIds = memberships.Select(m => m.UserId).ToArray();
var enrolled = await database.UserKeys
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
.Select(k => k.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var enrolledIds = enrolled.ToHashSet();
return
[
.. memberships
.OrderByDescending(m => m.Role)
.ThenBy(m => m.CreatedAtUtc)
.Select(m => new TeamMemberSummary(
m.UserId,
m.User?.Email,
m.User?.DisplayName,
ToContract(m.Role),
ToContract(m.Status),
enrolledIds.Contains(m.UserId),
m.JoinedAtUtc)),
];
}
/// <summary>Adds a member, or reactivates one who was removed.</summary>
/// <remarks>
/// <para>
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
/// somebody else is a transfer rather than an addition — a different operation with a different
/// confirmation, and not one M3 offers.
/// </para>
/// <para>
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
/// which is what keeps historic audit entries resolvable to one membership. It does <em>not</em>
/// restore their revoked key grants: those were wrapped to a generation the vault has since been
/// flagged to leave behind, and a member holding Share has to wrap the key afresh.
/// </para>
/// </remarks>
internal async Task<TeamMemberSummary> AddMemberAsync(
UserAccount actor,
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken)
{
var role = ToDomain(request.Role);
if (role is TeamRole.Unspecified or TeamRole.Owner)
{
throw new TeamInvalidException(
"Add a member as viewer, member or admin. Ownership is sole and is not transferred "
+ "by adding somebody.");
}
var target = await database.Users
.SingleOrDefaultAsync(
u => u.Id == request.UserId && u.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false)
// Safe to be specific: the caller supplied this id from a directory lookup they just
// made, so it confirms nothing they did not already know.
?? throw new TeamInvalidException(
"No such account on this server. A member has to sign in here once before they can "
+ "be added — that is what creates the account and publishes the key a vault would "
+ "be shared with.");
var now = clock.GetUtcNow();
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId && m.UserId == target.Id && m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (membership is null)
{
membership = new TeamMembership
{
Id = Guid.CreateVersion7(),
TeamId = teamId,
UserId = target.Id,
InvitedByUserId = actor.Id,
CreatedAtUtc = now,
};
database.TeamMemberships.Add(membership);
}
else if (membership.Status == MembershipStatus.Active)
{
throw new TeamInvalidException(
"That account is already a member of this team. Change their role instead.");
}
membership.Role = role;
membership.Status = MembershipStatus.Active;
membership.JoinedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id);
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
}
/// <remarks>
/// Enrollment is looked up rather than inferred, because it is the one field on a member row that
/// is about them and not about the membership: somebody can be added on Monday and set their
/// vault up on Tuesday, and the interface has to stop offering to share with them in between.
/// </remarks>
private async Task<TeamMemberSummary> DescribeAsync(
UserAccount user,
TeamMembership membership,
CancellationToken cancellationToken)
{
var isEnrolled = await database.UserKeys
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
.ConfigureAwait(false);
return new TeamMemberSummary(
user.Id,
user.Email,
user.DisplayName,
ToContract(membership.Role),
ToContract(membership.Status),
isEnrolled,
membership.JoinedAtUtc);
}
/// <summary>Changes a member's role.</summary>
internal async Task<TeamMemberSummary> ChangeRoleAsync(
UserAccount actor,
Guid teamId,
Guid memberId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken)
{
var role = ToDomain(request.Role);
if (role is TeamRole.Unspecified or TeamRole.Owner)
{
throw new TeamInvalidException(
"A member may be made a viewer, a member or an admin. Ownership is sole and is not "
+ "granted this way.");
}
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
// Demoting the owner is what would leave the team ownerless, and there is no transfer to
// do it through yet. Refused with the code a client can act on rather than a bare 400.
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be demoted, because nothing can appoint a replacement yet.");
}
membership.Role = role;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id);
// The account cannot be missing — a membership has a foreign key to it — but the query is
// written to tolerate it rather than to assert, because a null here would become an
// exception on a change that has already been committed.
var user = await database.Users
.SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken)
.ConfigureAwait(false);
return user is null
? new TeamMemberSummary(
memberId, null, null, ToContract(role), ToContract(membership.Status), false,
membership.JoinedAtUtc)
: await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Removes a member, revoking every vault key grant they hold from this team.
/// </summary>
/// <remarks>
/// <para>
/// One transaction, because the two halves are not separable: a membership revoked without its
/// grants leaves a departed member holding a key the server will happily keep serving, and grants
/// revoked without the membership leaves an active member whose vaults have silently stopped
/// opening.
/// </para>
/// <para>
/// Every affected vault is flagged <c>RekeyRequired</c> rather than rekeyed. A rekey re-wraps
/// every item's data key under a new vault key and can only be performed by a client that holds
/// the current one; the server can record that one is owed and nothing more. That is M5's key
/// rotation, and until it lands the flag is what the interface reads to say so out loud.
/// </para>
/// </remarks>
internal async Task RemoveMemberAsync(
UserAccount actor,
Guid teamId,
Guid memberId,
CancellationToken cancellationToken)
{
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
.ConfigureAwait(false);
if (membership.Role == TeamRole.Owner)
{
throw new LastTeamOwnerException(
"This team's owner cannot be removed. Ownership transfer is not implemented, so "
+ "removing them would leave the team with nobody who can manage it.");
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var revoked = await strategy.ExecuteAsync(async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
membership.Status = MembershipStatus.Revoked;
membership.DeletedAtUtc = now;
var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken)
.ConfigureAwait(false);
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return count;
}).ConfigureAwait(false);
TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);
}
/// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
private async Task<int> RevokeTeamGrantsAsync(
Guid teamId,
Guid memberId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var vaults = await database.Vaults
.Where(v => v.TeamId == teamId
&& v.OwnerKind == VaultOwnerKind.Team
&& v.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (vaults.Count == 0)
{
return 0;
}
var vaultIds = vaults.Select(v => v.Id).ToArray();
var grants = await database.VaultKeyGrants
.Where(g => vaultIds.Contains(g.VaultId)
&& g.RecipientUserId == memberId
&& g.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var grant in grants)
{
grant.State = GrantState.Revoked;
grant.RevokedAtUtc = now;
}
// Flagged whether or not this member held a grant. Somebody who was a member without a key
// still saw the vault's existence, its item count and its plaintext columns, and the vault's
// key is what a rekey would change — so "they never had a grant" is not a reason to leave the
// flag clear.
foreach (var vault in vaults)
{
vault.RekeyRequired = true;
vault.RekeyReason = RekeyReason.MemberRemoved;
vault.UpdatedAtUtc = now;
}
return grants.Count;
}
/// <summary>Reads the caller's own membership, for authorization checks.</summary>
internal Task<TeamMembership?> FindActiveMembershipAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) =>
database.TeamMemberships.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == userId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken);
/// <summary>
/// Resolves what the caller may do with a team.
/// </summary>
/// <remarks>
/// Answers <see cref="TeamAccess.Denied"/> identically for a team that does not exist and one the
/// caller is not in, for the reason <c>VaultAccessService</c> gives: a distinct "exists but
/// forbidden" is an oracle for other tenants' team ids.
/// </remarks>
internal async Task<TeamAccess> ResolveAsync(
Guid userId,
Guid teamId,
CancellationToken cancellationToken)
{
var team = await database.Teams
.SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (team is null)
{
return TeamAccess.Denied;
}
var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken)
.ConfigureAwait(false);
return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role);
}
private async Task<TeamMembership> RequireMembershipAsync(
Guid teamId,
Guid memberId,
CancellationToken cancellationToken)
{
var membership = await database.TeamMemberships
.SingleOrDefaultAsync(
m => m.TeamId == teamId
&& m.UserId == memberId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return membership
?? throw new TeamInvalidException("That account is not an active member of this team.");
}
/// <remarks>
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
/// else under an id that is already taken is refused: silently returning somebody else's team
/// would be an existence oracle, and returning a differently-named one would tell a client its
/// rename succeeded when nothing changed.
/// </remarks>
private async Task<TeamSummary> ResolveExistingAsync(
UserAccount user,
Team existing,
string name,
string slug,
CancellationToken cancellationToken)
{
var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken)
.ConfigureAwait(false);
var isRetry = membership?.Role == TeamRole.Owner
&& existing.DeletedAtUtc == null
&& string.Equals(existing.Name, name, StringComparison.Ordinal)
&& string.Equals(existing.Slug, slug, StringComparison.Ordinal);
if (!isRetry)
{
throw new TeamInvalidException(
"That team id is already in use. Generate a new UUIDv7 and retry.");
}
var memberCount = await database.TeamMemberships
.CountAsync(
m => m.TeamId == existing.Id
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
var vaultCount = await database.Vaults
.CountAsync(
v => v.TeamId == existing.Id && v.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return new TeamSummary(
existing.Id, existing.Name, existing.Slug, existing.Description,
TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc);
}
/// <summary>
/// Validates a slug.
/// </summary>
/// <remarks>
/// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
/// purpose: the column is <c>citext</c>, so a slug differing only in case is the same slug, and a
/// value that renders differently from how it compares is how two teams end up looking distinct
/// in a list and colliding on insert.
/// </remarks>
private static string RequireSlug(string? value)
{
var slug = (value ?? string.Empty).Trim();
if (slug.Length is 0 or > MaxSlugLength)
{
throw new TeamInvalidException(
$"A slug of 1 to {MaxSlugLength} characters is required.");
}
var previousWasHyphen = false;
for (var index = 0; index < slug.Length; index++)
{
var character = slug[index];
var isHyphen = character == '-';
var acceptable = (character is >= 'a' and <= 'z')
|| (character is >= '0' and <= '9')
|| isHyphen;
if (!acceptable
|| (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1)))
{
throw new TeamInvalidException(
"A slug is lowercase letters, digits and single hyphens, and cannot start or end "
+ "with a hyphen.");
}
previousWasHyphen = isHyphen;
}
return slug;
}
private static string RequireText(string? value, string field, int maxLength)
{
var text = (value ?? string.Empty).Trim();
if (text.Length == 0 || text.Length > maxLength)
{
throw new TeamInvalidException(
string.Create(
CultureInfo.InvariantCulture,
$"{field} is required, and at most {maxLength} characters."));
}
return text;
}
private static string? OptionalText(string? value, int maxLength)
{
var text = value?.Trim();
if (string.IsNullOrEmpty(text))
{
return null;
}
if (text.Length > maxLength)
{
throw new TeamInvalidException(
string.Create(
CultureInfo.InvariantCulture,
$"A description is at most {maxLength} characters."));
}
return text;
}
/// <remarks>
/// A plain cast, which is why <c>TeamMemberRole</c> pins the same numeric values as
/// <see cref="TeamRole"/> and a test asserts it. An unknown value becomes
/// <see cref="TeamRole.Unspecified"/> rather than a silent cast to a role nobody defined, so a
/// newer client's role is refused instead of resolving to whatever bit pattern it happens to be.
/// </remarks>
private static TeamRole ToDomain(TeamMemberRole role) => role switch
{
TeamMemberRole.Viewer => TeamRole.Viewer,
TeamMemberRole.Member => TeamRole.Member,
TeamMemberRole.Admin => TeamRole.Admin,
TeamMemberRole.Owner => TeamRole.Owner,
_ => TeamRole.Unspecified,
};
private static TeamMemberRole ToContract(TeamRole role) => role switch
{
TeamRole.Viewer => TeamMemberRole.Viewer,
TeamRole.Member => TeamMemberRole.Member,
TeamRole.Admin => TeamMemberRole.Admin,
TeamRole.Owner => TeamMemberRole.Owner,
_ => TeamMemberRole.Unspecified,
};
private static TeamMemberStatus ToContract(MembershipStatus status) => status switch
{
MembershipStatus.Invited => TeamMemberStatus.Invited,
MembershipStatus.Active => TeamMemberStatus.Active,
MembershipStatus.Revoked => TeamMemberStatus.Revoked,
_ => TeamMemberStatus.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}
@@ -0,0 +1,179 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
namespace DodoSSH.Api.Features.Teams;
/// <summary>Lists who can open a vault.</summary>
/// <remarks>
/// Read, not Share. Every member who can read a vault can already see the sharing graph — the server
/// stores it in plaintext and says so in docs/crypto.md §10 — so gating this on Share would hide from
/// the people it is about something the operator can read either way.
/// </remarks>
internal sealed class ListVaultGrantsEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
VaultGrantService grants)
: EndpointWithoutRequest<Results<Ok<VaultGrantsResponse>, NotFound>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/vaults/{vaultId:guid}/grants");
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("ListVaultGrants")
.WithSummary("Lists who holds a key to this vault.")
.WithTags("Vaults"));
}
/// <inheritdoc />
public override async Task<Results<Ok<VaultGrantsResponse>, NotFound>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var access = await vaultAccess
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
.ConfigureAwait(false);
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await grants.ListGrantsAsync(access.Vault!, ct).ConfigureAwait(false));
}
}
/// <summary>Wraps this vault's key to another member.</summary>
/// <remarks>
/// The one call in this API whose body the server can neither produce nor check. It stores a sealed
/// key and a signature over a tuple it never verifies — see docs/crypto.md §6 and §7 — which is
/// exactly why sharing is a client operation with a server-side record rather than a server feature.
/// </remarks>
internal sealed class IssueVaultGrantEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
VaultGrantService grants)
: Endpoint<IssueVaultGrantRequest, Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/vaults/{vaultId:guid}/grants");
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("IssueVaultGrant")
.WithSummary("Records a vault key wrapped to another member.")
.WithTags("Vaults"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
IssueVaultGrantRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var access = await vaultAccess
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
.ConfigureAwait(false);
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
{
return TypedResults.NotFound();
}
if (!access.Permissions.HasFlag(PermissionFlags.Share))
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"You do not have permission to share this vault.");
}
try
{
await grants.IssueGrantAsync(user, access.Vault!, req, ct).ConfigureAwait(false);
// 204. There is nothing to return that the caller does not already hold — it produced
// the wrap — and echoing the sealed key back would put it on the wire twice for nothing.
return TypedResults.NoContent();
}
catch (VaultGrantInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
}
}
}
/// <summary>Withdraws a member's key to this vault.</summary>
/// <remarks>
/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device
/// revocation gives: "revoked" is what the user reads, and reading it about the wrong account is
/// worse than being told to look again. A caller driving towards "they cannot read this any more"
/// can treat 404 as having arrived.
/// </remarks>
internal sealed class RevokeVaultGrantEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
VaultGrantService grants)
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Delete("/api/v1/vaults/{vaultId:guid}/grants/{userId:guid}");
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("RevokeVaultGrant")
.WithSummary("Withdraws a member's key to this vault. Blocks future reads only.")
.WithTags("Vaults"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var access = await vaultAccess
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
.ConfigureAwait(false);
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
{
return TypedResults.NotFound();
}
if (!access.Permissions.HasFlag(PermissionFlags.Share))
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"You do not have permission to share this vault.");
}
try
{
var revoked = await grants
.RevokeGrantAsync(user, access.Vault!, Route<Guid>("userId"), ct)
.ConfigureAwait(false);
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
}
catch (VaultGrantInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
}
}
}
@@ -0,0 +1,415 @@
using System.Security.Cryptography;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Team vaults and the key grants that make them readable.
/// </summary>
/// <remarks>
/// <para>
/// Everything here stores bytes it cannot interpret. A wrapped vault key is sealed to a recipient's
/// X25519 key, and a grant signature is Ed25519 over a tuple this server never verifies — the two
/// together are what let a client detect a fabricated grant, and moving either check onto the server
/// would make it a convenience rather than the boundary. See docs/crypto.md §6 and §7.
/// </para>
/// <para>
/// What the server <em>can</em> check is that a grant is not obviously useless: that the recipient is
/// enrolled, that the fingerprint names their current key, and that the generation is the vault's
/// current one. Each of those would otherwise surface at the far end as a tag failure the recipient
/// reads as data corruption, days later, with nothing pointing at the grant that caused it.
/// </para>
/// </remarks>
internal sealed class VaultGrantService(
DodoDbContext database,
TimeProvider clock,
ILogger<VaultGrantService> logger)
{
/// <summary>
/// Largest wrapped vault key accepted.
/// </summary>
/// <remarks>
/// A <c>SealTo</c> envelope over a 32-byte key is 6 + 32 + 24 + 48 = 110 bytes. The cap is loose
/// enough to survive a future envelope — the reserved hybrid seal in docs/crypto.md §8 is far
/// larger — and tight enough that this column cannot be used as free storage on a server that
/// stores it without being able to read it.
/// </remarks>
private const int MaxWrappedKeyBytes = 4096;
/// <summary>Creates a vault owned by a team, with the creator's own grant.</summary>
/// <remarks>
/// The vault and its first grant are written together, for the reason enrollment gives about a
/// personal vault: a vault with no grant is a container nobody can ever open, including whoever
/// created it, because only a client can wrap the key and it has already moved on.
/// </remarks>
internal async Task<VaultSummary> CreateTeamVaultAsync(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
CancellationToken cancellationToken)
{
var name = RequireVaultName(request.Name);
if (request.VaultId == Guid.Empty)
{
throw new TeamInvalidException("A vault id is required. Generate a UUIDv7 on the client.");
}
RequireWrappedKey(request.WrappedVaultKey);
RequireSignature(request.GrantSignature);
var key = await RequireCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false);
var taken = await database.Vaults
.AnyAsync(v => v.Id == request.VaultId, cancellationToken)
.ConfigureAwait(false);
if (taken)
{
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
return new VaultSummary(
VaultId: request.VaultId,
Name: name,
IsPersonal: false,
TeamId: team.Id,
KeyGeneration: 1,
Permissions: 0,
WrappedVaultKey: request.WrappedVaultKey,
RekeyRequired: false);
}
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
private void AddVaultWithSelfGrant(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
string name,
UserKey key,
DateTimeOffset now)
{
database.Vaults.Add(new Vault
{
Id = request.VaultId,
Name = name,
OwnerKind = VaultOwnerKind.Team,
TeamId = team.Id,
KeyGeneration = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now,
});
database.VaultKeyGrants.Add(new VaultKeyGrant
{
Id = Guid.CreateVersion7(),
VaultId = request.VaultId,
KeyGeneration = 1,
Kind = GrantKind.Member,
RecipientUserId = user.Id,
RecipientKeyFingerprint = key.FingerprintSha256,
WrappedKey = request.WrappedVaultKey,
GranterUserId = user.Id,
GranterKeyFingerprint = key.FingerprintSha256,
// No key log head, exactly as a personal vault's self-grant carries none: there is no
// third party whose key could have been substituted here.
KeyLogHead = null,
Signature = request.GrantSignature,
State = GrantState.Active,
CreatedAtUtc = now,
});
}
private static string RequireVaultName(string? value)
{
var name = (value ?? string.Empty).Trim();
return name.Length is 0 or > 256
? throw new TeamInvalidException("A vault name of 1 to 256 characters is required.")
: name;
}
/// <summary>Lists who can open a vault.</summary>
internal async Task<VaultGrantsResponse> ListGrantsAsync(
Vault vault,
CancellationToken cancellationToken)
{
var grants = await database.VaultKeyGrants
.Where(g => g.VaultId == vault.Id && g.Kind == GrantKind.Member)
.Include(g => g.RecipientUser)
.OrderBy(g => g.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return new VaultGrantsResponse(
VaultId: vault.Id,
KeyGeneration: (uint)vault.KeyGeneration,
RekeyRequired: vault.RekeyRequired,
Grants:
[
.. grants.Select(g => new VaultGrantSummary(
g.RecipientUserId!.Value,
g.RecipientUser?.Email,
g.RecipientUser?.DisplayName,
(uint)g.KeyGeneration,
ToContract(g.State),
g.GranterUserId,
g.CreatedAtUtc,
g.RevokedAtUtc)),
]);
}
/// <summary>Wraps a vault key to another member.</summary>
/// <remarks>
/// Re-issuing to a recipient who already holds a live grant replaces it in place rather than
/// inserting a second row, because the unique index permits exactly one live grant per recipient
/// per generation — and because the operation somebody is actually performing when they do this
/// is "wrap it again", after a rotation or a botched first attempt.
/// </remarks>
internal async Task IssueGrantAsync(
UserAccount actor,
Vault vault,
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
await RequireIssuableAsync(vault, request, cancellationToken).ConfigureAwait(false);
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
.ConfigureAwait(false);
var existing = await database.VaultKeyGrants
.SingleOrDefaultAsync(
g => g.VaultId == vault.Id
&& g.KeyGeneration == vault.KeyGeneration
&& g.RecipientUserId == request.RecipientUserId
&& g.RevokedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
var grant = existing ?? new VaultKeyGrant
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
KeyGeneration = vault.KeyGeneration,
Kind = GrantKind.Member,
RecipientUserId = request.RecipientUserId,
CreatedAtUtc = clock.GetUtcNow(),
};
grant.RecipientKeyFingerprint = request.RecipientKeyFingerprint;
grant.WrappedKey = request.WrappedVaultKey;
grant.GranterUserId = actor.Id;
// Taken from the server's own view of the caller's key rather than from the request. The
// client signed over the same value, so an honest client is unaffected; a field that could
// disagree with reality is one a reader would have to decide which copy to believe.
grant.GranterKeyFingerprint = granterKey.FingerprintSha256;
grant.KeyLogHead = request.KeyLogHead;
grant.Signature = request.GrantSignature;
grant.State = GrantState.Active;
if (existing is null)
{
database.VaultKeyGrants.Add(grant);
}
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.GrantIssued(
logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id);
}
/// <summary>
/// Everything that can be checked about a grant without holding the vault key.
/// </summary>
/// <remarks>
/// None of this verifies that the wrap contains the right key — nothing on this machine can. Each
/// check exists because failing it would otherwise surface at the recipient as a tag failure they
/// read as data corruption, long after the request that caused it.
/// </remarks>
private async Task RequireIssuableAsync(
Vault vault,
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
// Team vaults only. A personal vault has exactly one subject who may reach it, so a grant on
// one would seal a key to somebody the access check will go on refusing — a row that looks
// like sharing and is not. Moving the items into a team vault is the operation that shares.
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
{
throw new VaultGrantInvalidException(
"Only a team vault can be shared. A personal vault is reachable by its owner alone, "
+ "so a grant on one would seal a key to somebody who still could not fetch it.");
}
RequireWrappedKey(request.WrappedVaultKey);
RequireSignature(request.GrantSignature);
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
RequireDigest(request.KeyLogHead, "key log head");
if (request.KeyGeneration != (uint)vault.KeyGeneration)
{
throw new VaultGrantInvalidException(
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
+ $"{request.KeyGeneration} would open nothing.");
}
var member = await database.TeamMemberships
.AnyAsync(
m => m.TeamId == teamId
&& m.UserId == request.RecipientUserId
&& m.Status == MembershipStatus.Active
&& m.DeletedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
if (!member)
{
throw new VaultGrantInvalidException(
"That account is not an active member of the team that owns this vault. Add them to "
+ "the team first — a key wrapped to somebody the server will refuse to serve is a "
+ "grant that does nothing.");
}
var recipientKey = await RequireCurrentKeyAsync(request.RecipientUserId, cancellationToken)
.ConfigureAwait(false);
// The fingerprint the client signed over must be the key the recipient actually holds.
// Otherwise the grant is sealed to a superseded key, opens nothing, and surfaces at the far
// end as an unexplained decryption failure rather than as the mistake it is.
if (!CryptographicOperations.FixedTimeEquals(
recipientKey.FingerprintSha256, request.RecipientKeyFingerprint))
{
throw new VaultGrantInvalidException(
"The fingerprint does not name the recipient's current identity key. Re-read the "
+ "directory and wrap the key again — theirs has been rotated since you fetched it.");
}
}
/// <summary>
/// Withdraws a member's key grant.
/// </summary>
/// <returns>Whether there was a live grant to withdraw.</returns>
/// <remarks>
/// Blocks future reads and nothing else. Anything the recipient has already pulled is on their
/// machine and stays there, which is why the vault is flagged for rekey and why the honest
/// remediation for a departure is rotating the SSH credential itself. See ADR 0001.
/// </remarks>
internal async Task<bool> RevokeGrantAsync(
UserAccount actor,
Vault vault,
Guid recipientUserId,
CancellationToken cancellationToken)
{
if (recipientUserId == actor.Id)
{
throw new VaultGrantInvalidException(
"You cannot withdraw your own key. It would leave you unable to read a vault you can "
+ "still write to, and nothing here can hand it back.");
}
var grants = await database.VaultKeyGrants
.Where(g => g.VaultId == vault.Id
&& g.RecipientUserId == recipientUserId
&& g.RevokedAtUtc == null)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (grants.Count == 0)
{
return false;
}
var now = clock.GetUtcNow();
foreach (var grant in grants)
{
grant.State = GrantState.Revoked;
grant.RevokedAtUtc = now;
}
vault.RekeyRequired = true;
vault.RekeyReason = RekeyReason.Requested;
vault.UpdatedAtUtc = now;
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id);
return true;
}
private async Task<UserKey> RequireCurrentKeyAsync(Guid userId, CancellationToken cancellationToken)
{
var key = await database.UserKeys
.SingleOrDefaultAsync(k => k.UserId == userId && k.IsCurrent, cancellationToken)
.ConfigureAwait(false);
return key
?? throw new VaultGrantInvalidException(
"That account has not published an identity key yet, so there is nothing to wrap a "
+ "vault key to. They have to sign in and set up their vault first.");
}
private static void RequireWrappedKey(byte[]? value)
{
if (value is null || value.Length == 0 || value.Length > MaxWrappedKeyBytes)
{
throw new VaultGrantInvalidException(
$"A wrapped vault key of 1 to {MaxWrappedKeyBytes} bytes is required.");
}
}
private static void RequireSignature(byte[]? value)
{
if (value is null || value.Length != 64)
{
throw new VaultGrantInvalidException("An Ed25519 grant signature is 64 bytes.");
}
}
private static void RequireDigest(byte[]? value, string field)
{
if (value is null || value.Length != 32)
{
throw new VaultGrantInvalidException($"A {field} is 32 bytes.");
}
}
private static VaultGrantState ToContract(GrantState state) => state switch
{
GrantState.Active => VaultGrantState.Active,
GrantState.AwaitingRewrap => VaultGrantState.AwaitingRewrap,
GrantState.Revoked => VaultGrantState.Revoked,
_ => VaultGrantState.Unspecified,
};
private static bool IsUniqueViolation(DbUpdateException exception) =>
string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal);
}
+5
View File
@@ -1,6 +1,7 @@
using DodoSSH.Api.Authorization; using DodoSSH.Api.Authorization;
using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Sync; using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Api.Setup; using DodoSSH.Api.Setup;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Authorization.Policy;
@@ -32,6 +33,10 @@ builder.Services.AddScoped<SyncService>();
builder.Services.AddScoped<IdentityService>(); builder.Services.AddScoped<IdentityService>();
builder.Services.AddScoped<EnrollmentService>(); builder.Services.AddScoped<EnrollmentService>();
builder.Services.AddScoped<DeviceService>(); builder.Services.AddScoped<DeviceService>();
builder.Services.AddScoped<DirectoryService>();
builder.Services.AddScoped<KeyLogService>();
builder.Services.AddScoped<TeamService>();
builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>(); builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>(); builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
+15 -3
View File
@@ -1,6 +1,7 @@
using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta; using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync; using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using FastEndpoints; using FastEndpoints;
@@ -38,15 +39,26 @@ internal static class EndpointRegistration
typeof(EnrollEndpoint), typeof(EnrollEndpoint),
typeof(RegisterDeviceEndpoint), typeof(RegisterDeviceEndpoint),
typeof(RevokeDeviceEndpoint), typeof(RevokeDeviceEndpoint),
typeof(LookupDirectoryEndpoint),
typeof(ReadKeyLogEndpoint),
typeof(SyncPullEndpoint), typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint), typeof(SyncPushEndpoint),
typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint),
typeof(ListTeamMembersEndpoint),
typeof(AddTeamMemberEndpoint),
typeof(ChangeTeamMemberRoleEndpoint),
typeof(RemoveTeamMemberEndpoint),
typeof(CreateTeamVaultEndpoint),
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
typeof(RevokeVaultGrantEndpoint),
// Registered as each feature lands: // Registered as each feature lands:
// Identity — key rotation, passphrase change // Identity — key rotation, passphrase change
// Directory — public-key lookup // Vaults — rekey, per-item ACLs
// Vaults — grants, rekey, ACL
// Relay — tickets and the WebSocket // Relay — tickets and the WebSocket
// Teams, Audit, Admin // Audit, Admin
}); });
/// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary> /// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary>
+35 -1
View File
@@ -934,6 +934,21 @@
"dodossh.client.domain": { "dodossh.client.domain": {
"type": "Project" "type": "Project"
}, },
"dodossh.client.import": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Domain": "[1.0.0, )"
}
},
"dodossh.client.objectstore": {
"type": "Project",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, )",
"AWSSDK.S3": "[4.0.101.6, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.session": { "dodossh.client.session": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
@@ -942,7 +957,8 @@
"DodoSSH.Client.Domain": "[1.0.0, )", "DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )", "DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )" "DodoSSH.Client.Sync": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )"
} }
}, },
"dodossh.client.shell": { "dodossh.client.shell": {
@@ -950,6 +966,8 @@
"dependencies": { "dependencies": {
"Avalonia": "[12.1.1, )", "Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )", "CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Import": "[1.0.0, )",
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
"DodoSSH.Client.Session": "[1.0.0, )", "DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )", "DodoSSH.Client.Terminal": "[1.0.0, )",
@@ -959,6 +977,7 @@
"dodossh.client.ssh": { "dodossh.client.ssh": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )" "SSH.NET": "[2025.1.0, )"
} }
}, },
@@ -1002,6 +1021,21 @@
"NSec.Cryptography": "[26.4.0, )" "NSec.Cryptography": "[26.4.0, )"
} }
}, },
"AWSSDK.Core": {
"type": "CentralTransitive",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "CentralTransitive",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
}
},
"BouncyCastle.Cryptography": { "BouncyCastle.Cryptography": {
"type": "CentralTransitive", "type": "CentralTransitive",
"requested": "[2.6.2, )", "requested": "[2.6.2, )",
+306 -1
View File
@@ -58,6 +58,115 @@ public interface IAccountApi
Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken); Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
} }
/// <summary>
/// Teams, their members, and the vaults they own.
/// </summary>
/// <remarks>
/// Separated from <see cref="IVaultGrantApi"/> although the two are used together, because they are
/// different kinds of act. Everything here changes what the <em>server</em> will serve and can be
/// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked
/// session has — so the two live behind different interfaces and are tested against different fakes.
/// </remarks>
public interface ITeamApi
{
/// <summary>Lists the teams the caller belongs to.</summary>
Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken);
/// <summary>Creates a team, with the caller as its owner.</summary>
Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
/// <summary>Lists a team's members.</summary>
Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken);
/// <summary>Adds a member to a team.</summary>
Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken);
/// <summary>Changes a member's role.</summary>
Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Removes a member, revoking every vault key grant they hold from this team.
/// </summary>
/// <returns>
/// Whether the team had that member. False means it did not, which a caller driving towards
/// "they are not in this team" should treat as having arrived.
/// </returns>
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken);
}
/// <summary>
/// The public-key directory and the log that makes it checkable.
/// </summary>
/// <remarks>
/// The two belong together and are used together: a directory answer is a claim, and the key log is
/// what turns it into something a client can verify. Splitting them would make it possible to build a
/// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that
/// undoes end-to-end encryption entirely.
/// </remarks>
public interface IDirectoryApi
{
/// <summary>Looks a user up by exact email address. There is no search.</summary>
Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
string email,
CancellationToken cancellationToken);
/// <summary>Looks up an account the caller shares a team with.</summary>
Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken);
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
Task<KeyLogPage> ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken);
}
/// <summary>
/// Vault key grants: who can open a vault, and the record of who let them.
/// </summary>
/// <remarks>
/// The wrapped key and the signature are produced by an unlocked session and are opaque to everything
/// between it and the recipient, this interface included.
/// </remarks>
public interface IVaultGrantApi
{
/// <summary>Lists who holds a key to this vault.</summary>
Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
/// <summary>Records a vault key wrapped to another member.</summary>
Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws a member's key to this vault.
/// </summary>
/// <returns>Whether there was a live grant to withdraw.</returns>
/// <remarks>
/// Blocks future reads and nothing else. Whatever they have already pulled is on their machine;
/// the remediation for a departure is rotating the SSH credential. See ADR 0001.
/// </remarks>
Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken);
}
/// <summary> /// <summary>
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP. /// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
/// </summary> /// </summary>
@@ -99,13 +208,16 @@ public interface ISyncApi
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
: IAccountApi, ISyncApi : IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
{ {
private const string MetaPath = "/api/v1/meta"; private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration"; private const string ConfigurationPath = "/.well-known/dodossh-configuration";
private const string MePath = "/api/v1/me"; private const string MePath = "/api/v1/me";
private const string EnrollmentPath = "/api/v1/me/enrollment"; private const string EnrollmentPath = "/api/v1/me/enrollment";
private const string DevicesPath = "/api/v1/me/devices"; private const string DevicesPath = "/api/v1/me/devices";
private const string DirectoryPath = "/api/v1/directory";
private const string KeyLogPath = "/api/v1/keylog";
private const string TeamsPath = "/api/v1/teams";
/// <summary> /// <summary>
/// Reads the server's capabilities, versions and limits. /// Reads the server's capabilities, versions and limits.
@@ -209,6 +321,168 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.SyncPushResponse, DodoSshJsonContext.Default.SyncPushResponse,
cancellationToken); cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
TeamsPath,
null,
DodoSshJsonContext.Default.IReadOnlyListTeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamSummary> CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
TeamsPath,
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest),
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest),
DodoSshJsonContext.Default.TeamMemberSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"),
JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest),
DodoSshJsonContext.Default.TeamMemberSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
cancellationToken);
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
/// <summary>
/// Looks a user up by exact email address.
/// </summary>
/// <remarks>
/// The address is escaped into the query string, which is the one place in this client where a
/// value a user typed reaches a URL. <see cref="Uri.EscapeDataString"/> rather than string
/// concatenation: an unescaped <c>&amp;</c> or <c>#</c> in an address would silently become a
/// lookup for something else.
/// </remarks>
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
string email,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(email);
return SendAsync(
HttpMethod.Get,
$"{DirectoryPath}?email={Uri.EscapeDataString(email)}",
null,
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
cancellationToken);
}
/// <inheritdoc />
public async Task<DirectoryEntry?> LookupByIdAsync(
Guid userId,
CancellationToken cancellationToken)
{
var entries = await SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"),
null,
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
cancellationToken)
.ConfigureAwait(false);
return entries.Count == 0 ? null : entries[0];
}
/// <inheritdoc />
public Task<KeyLogPage> ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken)
{
var path = limit is null
? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}")
: string.Create(
CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}");
return SendAsync(
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
}
/// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
null,
DodoSshJsonContext.Default.VaultGrantsResponse,
cancellationToken);
/// <inheritdoc />
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken) =>
SendNoContentAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"),
cancellationToken);
private async Task<T> GetAnonymousAsync<T>( private async Task<T> GetAnonymousAsync<T>(
string path, string path,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo, System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
@@ -242,6 +516,37 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug /// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer. /// worth an exception; here it is the answer.
/// </remarks> /// </remarks>
/// <summary>
/// Sends a request whose success carries no body.
/// </summary>
/// <remarks>
/// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
/// not be recorded is a failure with a problem document behind it, so there is nothing here to
/// translate into a return value.
/// </remarks>
private async Task SendNoContentAsync(
HttpMethod method,
string path,
HttpContent? content,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
}
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken) private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
{ {
using var request = new HttpRequestMessage(HttpMethod.Delete, path); using var request = new HttpRequestMessage(HttpMethod.Delete, path);
+313
View File
@@ -0,0 +1,313 @@
using System.Security.Cryptography;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Api;
/// <summary>Why a directory entry was or was not accepted.</summary>
public enum RecipientVerdict
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>
/// The key log verifies, and it introduces exactly the key the directory described.
/// </summary>
/// <remarks>
/// This is the strongest statement a client can make without an out-of-band fingerprint check. It
/// says the server has been consistent, not that the key is the right person's — see
/// <see cref="VerifiedRecipient.Fingerprint"/> and ADR 0001.
/// </remarks>
Verified = 1,
/// <summary>No account with that address, or none the caller may look up.</summary>
NotFound = 2,
/// <summary>The account exists but has published no identity key, so there is nothing to wrap to.</summary>
NotEnrolled = 3,
/// <summary>
/// The key log's hash chain does not verify.
/// </summary>
/// <remarks>
/// Either the log has been edited or this build and the server disagree about how an entry is
/// hashed. Both are refusals: wrapping a vault key against a log that cannot be checked is the
/// same as not checking one.
/// </remarks>
ChainBroken = 4,
/// <summary>
/// The log holds no entry matching the key the directory returned.
/// </summary>
/// <remarks>
/// The exact case key transparency exists for. A server that wants to substitute a key it holds
/// has to publish it in the append-only log to get past this, where every other client will see
/// it.
/// </remarks>
NotInKeyLog = 5,
/// <summary>The fingerprint does not match the keys it is supposed to be over.</summary>
FingerprintMismatch = 6,
/// <summary>
/// The log introduces a newer generation for this user than the directory returned.
/// </summary>
/// <remarks>
/// A rotation the directory has not caught up with, or a stale answer being served on purpose.
/// Refused either way: a key wrapped to a superseded generation opens nothing, and the recipient
/// reads that as corruption rather than as a race.
/// </remarks>
Superseded = 7,
}
/// <summary>
/// A recipient whose published key has been checked against the key log.
/// </summary>
/// <param name="Entry">The directory entry, as returned.</param>
/// <param name="KeyLogHead">
/// The log head observed while verifying, to be recorded in the grant. This is what makes a forked
/// view detectable: two clients handed different logs sign over different heads, and the mismatch
/// surfaces the next time either touches a vault the other can see.
/// </param>
/// <param name="Fingerprint">
/// The recipient's identity fingerprint, recomputed here rather than taken from the response.
/// <para>
/// <b>Show this to a human before sharing anything that matters.</b> Everything above proves the
/// server has been internally consistent; only somebody comparing this value with the recipient over
/// a channel the server does not control can prove it is the right person's key.
/// </para>
/// </param>
public sealed record VerifiedRecipient(
DirectoryEntry Entry,
byte[] KeyLogHead,
byte[] Fingerprint);
/// <summary>The outcome of verifying a recipient.</summary>
/// <param name="Verdict">What happened.</param>
/// <param name="Recipient">The recipient, present only when verified.</param>
/// <param name="Message">One line for a person. Never contains key material.</param>
public sealed record RecipientVerification(
RecipientVerdict Verdict,
VerifiedRecipient? Recipient,
string Message)
{
/// <summary>Whether a key came back that is safe to wrap to.</summary>
public bool IsVerified => Verdict == RecipientVerdict.Verified && Recipient is not null;
}
/// <summary>
/// Reads the whole key log, checks its hash chain, and decides whether a directory answer agrees
/// with it.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the check that makes sharing safe to offer at all.</b> A directory lookup is a claim by
/// the server about somebody else's public key; wrapping a vault key to an unverified claim hands the
/// vault to whoever made it, and no amount of transport security helps, because the server is inside
/// the threat model. See ADR 0001 and docs/crypto.md §7.2.
/// </para>
/// <para>
/// The whole log is read from the beginning, every time, rather than from a cached cursor. It is
/// small — one entry per identity key ever published, so a few hundred rows for a large deployment —
/// and a client that verified only the tail would accept a chain whose earlier links it had never
/// seen. Caching a verified prefix is a worthwhile optimisation and is deliberately not done yet:
/// it needs somewhere to keep the prefix that the server cannot influence, and the client's
/// preferences store does not exist.
/// </para>
/// <para>
/// What this cannot do is tell you the key belongs to the person you mean. A server that publishes a
/// substituted key in the log passes every check here — it is now on the record, which is the whole
/// mechanism: detectable, attributable, not prevented. The fingerprint comes back for a human to
/// compare out of band, which is the only step that closes it.
/// </para>
/// </remarks>
public static class KeyLogAudit
{
/// <summary>Entries requested per page.</summary>
private const int PageSize = 500;
/// <summary>
/// Pages the whole log with the chain checked link by link.
/// </summary>
/// <returns>The verified log, or a null <c>Entries</c> when a link did not hold.</returns>
public static async Task<AuditedKeyLog> ReadAsync(
IDirectoryApi directory,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(directory);
var entries = new List<KeyLogRecord>();
var previous = KeyLogChain.CreateGenesisPreviousHash();
var after = 0L;
var head = previous;
while (true)
{
var page = await directory.ReadKeyLogAsync(after, PageSize, cancellationToken)
.ConfigureAwait(false);
foreach (var entry in page.Entries)
{
if (!Links(entry, previous))
{
return new AuditedKeyLog(null, head);
}
entries.Add(entry);
previous = entry.Hash;
after = entry.Sequence;
}
head = page.Head;
if (!page.HasMore)
{
break;
}
// A page that advanced nothing would loop for ever. It means the server is answering a
// cursor it will not move past, which is a broken log from this side of the wire.
if (page.Entries.Count == 0)
{
return new AuditedKeyLog(null, head);
}
}
// The last link has to be the head the server claims, or the log served and the log
// summarised are two different things.
return entries.Count > 0 && !CryptographicOperations.FixedTimeEquals(previous, head)
? new AuditedKeyLog(null, head)
: new AuditedKeyLog(entries, head);
}
/// <summary>Decides whether a directory entry agrees with a verified log.</summary>
public static RecipientVerification Verify(AuditedKeyLog log, DirectoryEntry? entry)
{
ArgumentNullException.ThrowIfNull(log);
if (log.Entries is null)
{
return new RecipientVerification(
RecipientVerdict.ChainBroken,
null,
"The server's key log does not verify. Nothing will be shared with anyone until it "
+ "does — an unverifiable log is the same as no log.");
}
if (entry is null)
{
return new RecipientVerification(
RecipientVerdict.NotFound,
null,
"No account here has that address. They have to sign in to this server once before "
+ "anything can be shared with them.");
}
var fingerprint = DshCrypto.ComputeFingerprint(
entry.EncryptionPublicKey, entry.SigningPublicKey);
if (!CryptographicOperations.FixedTimeEquals(fingerprint, entry.Fingerprint))
{
return new RecipientVerification(
RecipientVerdict.FingerprintMismatch,
null,
"The fingerprint the directory returned is not the fingerprint of the keys it "
+ "returned with it.");
}
return Compare(log, entry, fingerprint);
}
/// <summary>Compares one directory entry with the log entries for that account.</summary>
private static RecipientVerification Compare(
AuditedKeyLog log,
DirectoryEntry entry,
byte[] fingerprint)
{
var forUser = log.Entries!.Where(e => e.UserId == entry.UserId).ToList();
if (forUser.Count == 0)
{
return new RecipientVerification(
RecipientVerdict.NotEnrolled,
null,
"That account has published no identity key, so there is nothing to wrap a vault key "
+ "to.");
}
var latest = forUser.Max(e => e.Generation);
if (latest > entry.KeyGeneration)
{
return new RecipientVerification(
RecipientVerdict.Superseded,
null,
$"The key log has generation {latest} for that account and the directory offered "
+ $"{entry.KeyGeneration}. Wrapping to a superseded key would open nothing.");
}
var matching = forUser.Find(e =>
e.Generation == entry.KeyGeneration
&& e.EncryptionPublicKey.AsSpan().SequenceEqual(entry.EncryptionPublicKey)
&& e.SigningPublicKey.AsSpan().SequenceEqual(entry.SigningPublicKey));
if (matching is null)
{
return new RecipientVerification(
RecipientVerdict.NotInKeyLog,
null,
"The key the directory returned does not appear in the append-only key log. This is "
+ "exactly the substitution the log exists to catch; do not share anything with this "
+ "account until it is explained.");
}
return new RecipientVerification(
RecipientVerdict.Verified,
new VerifiedRecipient(entry, log.Head, fingerprint),
"Verified against the key log. Compare the fingerprint with them out of band before "
+ "sharing anything that matters.");
}
/// <remarks>
/// Recomputed rather than compared: the point is that this client derives the hash from the
/// entry's own contents, so a server that edited a field cannot hand over a hash that covers the
/// original.
/// </remarks>
private static bool Links(KeyLogRecord entry, byte[] previous)
{
if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash, previous))
{
return false;
}
byte[] computed;
try
{
computed = KeyLogChain.ComputeEntryHash(
entry.PreviousHash,
entry.UserId,
entry.Generation,
entry.EncryptionPublicKey,
entry.SigningPublicKey,
entry.StatementSignature,
entry.CreatedAt);
}
catch (ArgumentException)
{
// A key or signature of the wrong length. Malformed rather than merely mismatched, and a
// refusal either way.
return false;
}
return CryptographicOperations.FixedTimeEquals(computed, entry.Hash);
}
}
/// <summary>A key log that has been read, with its chain checked.</summary>
/// <param name="Entries">
/// Every entry in order, or <see langword="null"/> when a link did not hold. Null is the only
/// signal a caller needs: a partially verified log is not a weaker answer, it is no answer.
/// </param>
/// <param name="Head">The head the server reported, for recording in a grant.</param>
public sealed record AuditedKeyLog(IReadOnlyList<KeyLogRecord>? Entries, byte[] Head);
+57 -1
View File
@@ -271,13 +271,32 @@
runs horizontally and a left bar on a row of tabs reads as a divider between them. runs horizontally and a left bar on a row of tabs reads as a divider between them.
--> -->
<Style Selector="Button.tab"> <Style Selector="Button.tab">
<Setter Property="Padding" Value="12,0" /> <!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
<Setter Property="Padding" Value="12,0,7,0" />
<Setter Property="VerticalAlignment" Value="Stretch" /> <Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" /> <Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="Medium" /> <Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" /> <Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style> </Style>
<!--
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
state, because it is never the thing showing, and no right border, because it is not separating
itself from anything.
-->
<Style Selector="Button.tab.plus">
<Setter Property="Padding" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderThickness" Value="0,2,0,0" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Button.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Raised}" />
</Style>
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextDim}" /> <Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" /> <Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
@@ -290,6 +309,34 @@
<Setter Property="BorderThickness" Value="0,2,0,0" /> <Setter Property="BorderThickness" Value="0,2,0,0" />
</Style> </Style>
<!--
A pair of buttons standing in for a two-way choice, inside a pane rather than down a rail. Not the
.cat style, which stretches to fill a 176-pixel rail row and would be wrong at this width — and which
the category rail's own test counts, so borrowing it would have made this a fourth category.
-->
<Style Selector="Button.choice">
<Setter Property="Padding" Value="10,5" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="LetterSpacing" Value="0.5" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.choice /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="Button.choice:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="Button.choice.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!-- The close box on a tab, and the window controls. Square, quiet, and red only where it means it. --> <!-- The close box on a tab, and the window controls. Square, quiet, and red only where it means it. -->
<Style Selector="Button.close /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="Button.close /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" /> <Setter Property="Foreground" Value="{StaticResource TextFaint}" />
@@ -299,6 +346,15 @@
<Setter Property="Foreground" Value="{StaticResource Danger}" /> <Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style> </Style>
<!--
The one inside a tab, as opposed to the ones in the titlebar. Rounded and small, because a square
full-height red panel inside a tab reads as a divider between two tabs rather than as part of one —
which is what it looked like while it was a sibling of the tab instead of a child.
-->
<Style Selector="Button.close.inline /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="3" />
</Style>
<!-- <!--
Text input. Fluent draws a filled box with a thick focus underline; this design draws a hairline field Text input. Fluent draws a filled box with a thick focus underline; this design draws a hairline field
that changes border colour, and the two do not sit together in one row. that changes border colour, and the two do not sit together in one row.
+31 -4
View File
@@ -1,11 +1,14 @@
using Avalonia; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using DodoSSH.Client.Shell.Terminal; using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.App.Views; using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth; using DodoSSH.Client.Auth;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh; using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage; using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal; using DodoSSH.Client.Terminal;
@@ -47,6 +50,28 @@ internal sealed partial class DodoSshApp : Application
/// nowhere honest to release them. /// nowhere honest to release them.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <summary>
/// Puts one line of text on the system clipboard.
/// </summary>
/// <remarks>
/// The clipboard is reached through the window, and at composition time there is no window yet — hence
/// a closure that looks it up on each call rather than a reference captured now. A machine with no
/// clipboard falls through silently here; the view model is the one that decides what to say, and it
/// distinguishes "no clipboard on this machine" from "copied" because they are different answers.
/// <para>
/// A delegate rather than handing the view model an <c>IClipboard</c>, so that nothing in the view
/// models needs a visual and every test that drives them stays window-free.
/// </para>
/// </remarks>
private static Func<string, Task> ClipboardWriter(IClassicDesktopStyleApplicationLifetime desktop) =>
async text =>
{
if (TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard })
{
await clipboard.SetTextAsync(text).ConfigureAwait(false);
}
};
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop) private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{ {
var paths = ClientPaths.Default; var paths = ClientPaths.Default;
@@ -74,7 +99,7 @@ internal sealed partial class DodoSshApp : Application
// Chosen once, here, because it is a property of the machine and not of any session. A computer with // Chosen once, here, because it is a property of the machine and not of any session. A computer with
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else // a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007. // gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
var deviceKeys = DeviceKeyStores.ForThisMachine(paths); var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
var viewModel = new MainWindowViewModel( var viewModel = new MainWindowViewModel(
paths, paths,
@@ -93,7 +118,9 @@ internal sealed partial class DodoSshApp : Application
// makes a launch after the first one arrive online rather than merely enrolled. // makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken) .ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false)); .ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop));
desktop.MainWindow = new MainWindow { DataContext = viewModel }; desktop.MainWindow = new MainWindow { DataContext = viewModel };
@@ -30,8 +30,10 @@
head. This project is now the desktop *views* and the desktop platform integration, and nothing else. head. This project is now the desktop *views* and the desktop platform integration, and nothing else.
--> -->
<ProjectReference Include="../DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" /> <ProjectReference Include="../DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" />
<ProjectReference Include="../DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" /> <ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" /> <ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" /> <ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" /> <ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
</ItemGroup> </ItemGroup>
@@ -51,3 +53,4 @@
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,17 +1,28 @@
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Security.Cryptography; using System.Security.Cryptography;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Session; namespace DodoSSH.Client.App.Platform;
/// <summary> /// <summary>
/// Picks the device key store this machine can actually offer. /// Picks the device key store this desktop machine can actually offer.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that /// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the /// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
/// passphrase — which is the honest answer rather than a degraded one. /// passphrase — which is the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
/// to live in <c>DodoSSH.Client.Session</c>, which was the one thing keeping that project from being
/// portable: everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the
/// vault code meant a second head could not reference it without dragging Windows along. The seam that
/// makes the move free is <see cref="IDeviceKeyStore"/>, which was already there — the session takes a
/// store and has never known which one. See <c>docs/android-port.md</c>.
/// </para>
/// </remarks> /// </remarks>
public static class DeviceKeyStores public static class DesktopDeviceKeyStores
{ {
/// <summary>The best store this machine supports.</summary> /// <summary>The best store this machine supports.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths) public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
+60 -8
View File
@@ -36,8 +36,10 @@
</Border> </Border>
<!-- <!--
One heading, for one vault. The chevron folds the list away; the count is the collection's own, so it One heading, which names the vault while there is one and says ALL VAULTS once a team's is readable
follows the filter without a second number to keep in step. too — a heading that went on naming the personal vault over a list containing a team's hosts would be
a quiet lie, so the rows carry the vault name instead. The chevron folds the list away; the count is
the collection's own, so it follows the filter without a second number to keep in step.
--> -->
<Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}" <Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"> HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
@@ -65,10 +67,37 @@
--> -->
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True" <ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
IsVisible="{Binding AreHostsExpanded}" IsVisible="{Binding AreHostsExpanded}"
ItemsSource="{Binding VisibleHosts}" ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedHost}"> SelectedItem="{Binding SelectedSidebarRow}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel"> <!--
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
group. A vault with no groups produces no heading rows at all, so this is the list it always was.
The heading is a row rather than a container, which means the control will happily select it. That is
turned back into the previous host selection in the view model — see SelectedSidebarRow — because
CONNECT, EDIT and DELETE all act on a host and a highlighted heading is not one.
-->
<ListBox.DataTemplates>
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Chevron}" Foreground="{StaticResource TextFaint}"
FontSize="8" VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5"> <Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5">
<!-- The accent strip a selected row carries; see the style in App.axaml. --> <!-- The accent strip a selected row carries; see the style in App.axaml. -->
@@ -102,11 +131,20 @@
--> -->
<TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5" <TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" /> Foreground="{StaticResource TextFaint}" />
<!--
Which vault this host is in, and only when there is more than one to be in. It decides
who else can see the host and where an edit goes back to, so on a list that spans
several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="9.5"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate>
</ListBox.DataTemplates>
</ListBox> </ListBox>
<!-- The editor doubles as the "add" form; there is no separate dialog. --> <!-- The editor doubles as the "add" form; there is no separate dialog. -->
@@ -148,6 +186,20 @@
</DataTemplate> </DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<!--
Which group this host is filed under. Inside the encrypted payload like everything else here, so
the server learns nothing about how the estate is organised — and a group the vault no longer has
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox IsChecked="{Binding EditorRelayEnabled}" <CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay" /> Content="Connect through the server relay" />
<!-- <!--
@@ -193,7 +245,7 @@
--> -->
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}" <Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsConfirmingDeletion}"> IsVisible="{Binding IsConfirmingHostDeletion}">
<views:ConfirmDeleteCard /> <views:ConfirmDeleteCard />
</Border> </Border>
@@ -0,0 +1,252 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostsScreen"
x:DataType="vm:MainWindowViewModel">
<!--
The hosts screen: the list of machines, and what this application has to say about the one that is
selected.
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
promised: an overview.
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
has to reproduce by hand.
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
two cannot be put on one element.
-->
<Grid ColumnDefinitions="268,*">
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
<!--
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
one of them is fine.
-->
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="200" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
FontSize="11" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" />
</StackPanel>
</Border>
<StackPanel Grid.Row="1">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
through the one warning that matters.
-->
<Border Padding="12,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding Vault.TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding Vault.RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
and shown; without this panel it would be last-writer-wins with a longer explanation.
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
for as long as it did because no test could lay this markup out; that is the other half of why this
file exists.
-->
<Border Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
</StackPanel>
<!--
The overview proper: what is known about the host the list has selected.
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
terminal until this screen stopped hosting one, and filling it with something that needed new state
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
list of forty.
-->
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
<Panel Margin="24">
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
VerticalAlignment="Center" />
<Border Classes="chip" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
<TextBlock Text="CONNECTED" />
</Border>
</StackPanel>
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
</StackPanel>
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
MaxWidth="440" TextWrapping="Wrap"
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
</Panel>
</ScrollViewer>
<!--
Groups: making them, renaming them, and taking them away.
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
in the host's own editor, on the left, for the same reason its key and its password are.
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
happen.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
</StackPanel>
<!--
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
third of this column to say what a row of chips says in one line.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding Vault.HasGroups}">
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
Background="Transparent" MaxHeight="72">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<StackPanel Margin="2,4" Spacing="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
FontSize="11.5" />
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</StackPanel>
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
FontSize="11" MinHeight="26" Padding="8,3" />
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Content="RENAME SELECTED" Command="{Binding Vault.EditGroupCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
</StackPanel>
<!--
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
two panels share one pending deletion, and the sidebar checks the same way.
-->
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,27 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The hosts screen: the host list, and an overview of the one that is selected.
/// </summary>
/// <remarks>
/// Its data context is the shell rather than the vault, unlike <see cref="HostSidebar"/> and
/// <see cref="VaultScreen"/>. The sidebar is handed the vault from inside the markup; everything else here
/// reaches it through <c>Vault.*</c>. That split is not tidiness — this element's visibility is the shell's
/// business and the sidebar's bindings are the vault's, and an element carrying both resolves the first
/// against the second, where it does not exist.
/// </remarks>
internal sealed partial class HostsScreen : UserControl
{
public HostsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// Forwarded to the sidebar, which answers for itself: the host list can be folded away, and
/// <c>Focus()</c> on a collapsed control is measurably a no-op that is not replayed when the control is
/// revealed. Nothing in the right column can take the keyboard — it is a heading and three sentences.
/// </remarks>
internal IInputElement KeyboardTarget => Sidebar.KeyboardTarget;
}
@@ -0,0 +1,119 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.ImportScreen"
x:DataType="vm:ImportViewModel">
<!--
Importing ~/.ssh/config.
A preview and then a button, rather than one action, and that is the whole design. This reads a file
the application did not write, out of the user's home directory, and a real ssh_config often holds
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
says what each entry means; importing is a separate press on a set somebody has looked at.
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
something almost nobody is looking at.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
</Grid>
</Border>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="11" Margin="14,12,14,0"
TextWrapping="Wrap" />
<!--
What could not be honoured, above the list rather than beside it. Every one of these is a way the
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
comparing the two needs to be told before they conclude the parser lost something.
-->
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" IsVisible="{Binding HasWarnings}">
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="10"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel">
<StackPanel Margin="14,0">
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="11"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="9.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</Grid>
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="9.5" Margin="34,0,0,8"
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
IsVisible="{Binding HasWarnings}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding HasRows}">
<StackPanel Spacing="8">
<!--
Said before the button, not after. A key path is recorded and the key itself is not read: that is
the difference between a bookmark that connects and one that asks for a password, and somebody
who is not told will conclude the import was broken.
-->
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain. Nothing here reaches into ~/.ssh for private key material." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,37 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Importing hosts from <c>~/.ssh/config</c>.
/// </summary>
/// <remarks>
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
/// </remarks>
internal sealed partial class ImportScreen : UserControl
{
public ImportScreen()
{
InitializeComponent();
// The count on the import button is derived from the ticks, and a CheckBox bound with
// {Binding IsSelected} tells its own row and nothing else. Rather than have every row hold a
// reference back to the screen, the screen listens for the event they all bubble.
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
}
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
internal IInputElement KeyboardTarget => this;
private void OnTickChanged(object? sender, RoutedEventArgs e)
{
if (DataContext is ImportViewModel import)
{
import.NoteSelectionChanged();
}
}
}
@@ -0,0 +1,144 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.KnownHostsScreen"
x:DataType="vm:KnownHostsViewModel">
<!--
The host keys this keychain has approved.
These were a category on the keychain screen, alongside SSH keys and passwords, and they do not belong
there: the other two are things a person creates and edits, and a pin is a decision recorded at the
moment of connecting. Nobody goes looking for one in a list of credentials. They are also the only items
with a workflow of their own — compare a fingerprint against what the operator published — and that
workflow needs a filter and a column layout the shared table could not give them.
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
-->
<Grid ColumnDefinitions="*,244">
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
Matches fingerprints as well as host names, which is the point of it. What somebody does with
this screen is check whether a published SHA256:… is the one they approved, and searching only
by name would answer a different question.
-->
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
</Grid>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
IsVisible="{Binding HasVisiblePins}">
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
ItemsSource="{Binding VisiblePins}"
SelectedItem="{Binding Selected}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="11"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="9.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="9"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
Never trimmed, and this column is why the table is laid out the way it is. The only thing
anybody does with a fingerprint is compare it character by character against one an operator
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
mechanism exists to replace.
-->
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="340"
IsVisible="{Binding !HasVisiblePins}" />
</Grid>
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="14,16" Spacing="6">
<TextBlock Classes="hint" FontSize="11"
Text="Choose a pinned key to see it in full, and to withdraw it."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<Border Classes="chip warn" HorizontalAlignment="Left"
IsVisible="{Binding !Selected.IsDialledByAHost}">
<TextBlock Text="no host uses this" />
</Border>
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="10"
Foreground="{StaticResource TextDim}" />
<!--
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
the item's own version 7 id — which records when the pin was created and knows nothing about
it being re-approved since. Presenting that as "last used" would be inventing a fact.
-->
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,12,0,0"
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
HorizontalAlignment="Left"
Command="{Binding ForgetSelectedCommand}"
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The host keys this keychain has approved.
/// </summary>
/// <remarks>
/// Its data context is a <c>KnownHostsViewModel</c>, which is a screen-scoped wrapper over the vault rather
/// than an owner of anything: the pins, the reload and the withdrawal all still belong to
/// <c>VaultViewModel</c>. See that class for why.
/// </remarks>
internal sealed partial class KnownHostsScreen : UserControl
{
public KnownHostsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// The filter box rather than the list, unlike the keychain screen. This screen is reached to answer a
/// question — is this fingerprint one of mine — and the first thing anybody does is type part of it.
/// The box is also always there, where the list is empty on a fresh keychain, and <c>Focus()</c> on a
/// collapsed control is a no-op that is not replayed.
/// </remarks>
internal IInputElement KeyboardTarget => PinFilter;
}
@@ -0,0 +1,154 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.LogsScreen"
x:DataType="vm:LogsViewModel">
<!--
What has been connected to, and what has been changed.
Two logs behind one screen, chosen by two buttons rather than by a selector's selection — the same idiom
the keychain screen's categories use, and for the same reason: a selection binding moves before a command
can refuse it.
Both are ordinary synced keychain items, encrypted like everything else. The server holds them and cannot
read a single field; what it does learn is that rows exist and when they were written, which ADR 0001
records as the metadata this design cannot hide.
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
connection closes, so a live session is deliberately not in the vault yet.
-->
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,14,0" />
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
Classes.active="{Binding ShowsConnections}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Connections}" />
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
Classes.active="{Binding ShowsActivity}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Activity}" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
</Border>
<!-- ============ Connections ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
IsVisible="{Binding HasConnections}">
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
ItemsSource="{Binding Connections}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="11" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="9.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="9.5"
Foreground="{StaticResource TextDim}" />
</StackPanel>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="9"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
<!--
Only when there is something to say. A connection that opened and closed says nothing
here; one that was refused says so, and that is the row worth finding in a long list.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
<TextBlock Text="{Binding Outcome}" FontSize="8.5" />
</Border>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasConnections}" />
</Grid>
<!-- ============ Keychain changes ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsActivity}">
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
IsVisible="{Binding HasActivity}">
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="11"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="9.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The names of the fields that changed, and never what they changed to. A log that recorded
an old password would be a plaintext credential store with a vault drawn around it.
-->
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
IsVisible="{Binding HasChangedFields}" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasActivity}" />
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,28 @@
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// What has been connected to, and what has been changed.
/// </summary>
/// <remarks>
/// Its data context is a <c>LogsViewModel</c>, a screen-scoped wrapper over the open session. Both logs are
/// ordinary synced keychain items; nothing about them is local.
/// </remarks>
internal sealed partial class LogsScreen : UserControl
{
public LogsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// Whichever list is on screen, because this screen has no filter box and a collapsed control cannot
/// take focus — <c>Focus()</c> on one is a no-op that nothing replays when it is revealed. The lists are
/// focusable explicitly for the same reason the host list is: Avalonia leaves focus to the items, and an
/// empty list has none.
/// </remarks>
internal IInputElement KeyboardTarget =>
DataContext is LogsViewModel { ShowsActivity: true } ? ActivityList : ConnectionList;
}
+114 -161
View File
@@ -15,7 +15,8 @@
Focusable="True"> Focusable="True">
<!-- <!--
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar. The shell window: a titlebar it draws itself, a nav rail, a tab strip, one surface at a time, and a
status bar.
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
@@ -28,6 +29,13 @@
removes the caption and keeps the resize border and the drop shadow, which is the half of the system removes the caption and keeps the resize border and the drop shadow, which is the half of the system
chrome worth having. chrome worth having.
TWO SURFACES, ONE RECTANGLE.
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
visible and reachable from every other one. What that costs is that the terminal and the pages now share
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
enum rather than two flags, so there is no way to write the state where both are showing.
THE OCCLUSION RULE, which every arrangement in this file obeys. THE OCCLUSION RULE, which every arrangement in this file obeys.
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
@@ -36,15 +44,16 @@
buttons unreachable, which this window has shipped once already. buttons unreachable, which this window has shipped once already.
So anything that would occupy the terminal's rectangle collapses the terminal instead, and So anything that would occupy the terminal's rectangle collapses the terminal instead, and
IsTerminalShowing is the one place that decision is made: a locked vault, a screen other than Hosts, or IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
the quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2 control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
still starts, still loads the page and still lets the renderer attach its socket while it is false. It still starts, still loads the page and still lets the renderer attach its socket while it is false. It
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds. only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
What the first connection after unlocking actually depends on is the await in Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate is is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
that await, never this control's visibility. Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
only chooses between the pages.
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
@@ -64,167 +73,111 @@
<views:NavRail Grid.Column="0" /> <views:NavRail Grid.Column="0" />
<Panel Grid.Column="1"> <!--
The rail is full height and the strip is not, so the strip spans exactly the area it navigates.
The other arrangement — strip above rail — would put a row of tabs over a column of destinations
they have nothing to do with.
-->
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<!-- ============ HOSTS + TERMINAL ============ --> <views:TerminalTabs Grid.Row="0" />
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" /> <Panel Grid.Row="1">
<!-- ============ THE PAGES ============ -->
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*"> <Panel IsVisible="{Binding IsShowingPages}">
<views:TerminalTabs Grid.Row="0" />
<!-- <!--
Connecting. A password box only for a host that asks to be — a host bound to a stored Bound directly rather than wrapped, unlike the two below it: this screen's data context is
credential or a key wants nothing typed here — and a sentence in its place when it does not, the shell's, so IsHostsScreen resolves. It hands the vault to the sidebar from inside its
because "nothing needs typing" and "something needs typing and the box has not appeared yet" own markup.
look identical and only one of them is fine.
--> -->
<Border Grid.Row="1" Padding="12,8" Background="{StaticResource Panel}" <views:HostsScreen x:Name="HostsPane" IsVisible="{Binding IsHostsScreen}" />
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="200" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Vault and bind this host to it in the host's own editor." />
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
FontSize="11" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" />
</StackPanel>
</Border>
<StackPanel Grid.Row="2">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
other is a refusal. Presenting a changed key with a "continue" button is how users are
taught to click through the one warning that matters.
-->
<Border Padding="12,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding Vault.TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding Vault.RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode
is kept and shown; without this panel it would be last-writer-wins with a longer
explanation.
-->
<Border Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
</StackPanel>
<!-- ============ FILES ============ -->
<!-- <!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser Wrapped rather than bound directly, for the same reason the vault screen is: this element's
process tree, so twenty tabs would cost twenty of them. visibility is the shell's business and its data context is the transfers view model, and
putting both on one element resolves IsVisible against that view model, where
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible IsTransfersScreen does not exist.
then falls back to its default of true, and the occlusion comes back silently. Not reachable
at runtime — the DataContext is set before the window is shown — but it is what the previewer
does.
--> -->
<NativeWebView Grid.Row="3" x:Name="Terminal" <Panel IsVisible="{Binding IsTransfersScreen}">
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" /> <views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
</Grid> <!-- ============ KEYCHAIN ============ -->
</Grid> <!--
Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsVaultScreen does
not exist.
-->
<Panel IsVisible="{Binding IsVaultScreen}">
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ HOST KEYS ============ -->
<!--
Wrapped, like the two above and for the same reason: its data context is the screen's own
view model, where IsKnownHostsScreen does not exist.
-->
<Panel IsVisible="{Binding IsKnownHostsScreen}">
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
</Panel>
<!-- ============ SNIPPETS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsSnippetsScreen}">
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
</Panel>
<!-- ============ LOGS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsLogsScreen}">
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel>
<!-- ============ TEAM ============ -->
<!--
Wrapped, for the reason the vault and transfers screens are: the visibility is the shell's
business and the data context is the teams view model, and both on one element would resolve
IsTeamScreen against a type that does not have it.
-->
<Panel IsVisible="{Binding IsTeamScreen}">
<views:TeamsScreen DataContext="{Binding Teams}" />
</Panel>
<!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
<!-- ============ IMPORT ============ -->
<!--
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
the others whose data context is their own view model.
-->
<Panel IsVisible="{Binding IsImportScreen}">
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
</Panel>
</Panel>
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
process tree, so twenty tabs would cost twenty of them.
A sibling of the page area rather than a child of any screen, which is the structural half of
the tab rework: the terminal belongs to the window now, not to the hosts screen.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
then falls back to its default of true, and the occlusion comes back silently. Not reachable
at runtime — the DataContext is set before the window is shown — but it is what the previewer
does.
-->
<NativeWebView x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
<!-- ============ FILES ============ -->
<!--
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
visibility is the shell's business and its data context is the transfers view model, and putting
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
exist.
-->
<Panel IsVisible="{Binding IsTransfersScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel> </Panel>
</Grid>
<!-- ============ VAULT ============ -->
<!--
Wrapped rather than bound directly, for the reason the vault column always was: this element's
visibility is the shell's business and its data context is the vault, and put both on one element
and IsVisible resolves against the vault as well, where IsVaultScreen does not exist.
-->
<Panel IsVisible="{Binding IsVaultScreen}">
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ TEAM ============ -->
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
Title="TEAM"
Milestone="MILESTONE M3"
Summary="The design shows members, roles, shared vaults and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every vault that is not your own — so there is nobody to list and no shared vault to open."
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Vault screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
<views:NotBuiltScreen.Missing>
<sys:List x:TypeArguments="x:String">
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
<x:String>Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
<x:String>Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
</sys:List>
</views:NotBuiltScreen.Missing>
</views:NotBuiltScreen>
<!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
</Panel>
</Grid> </Grid>
<!-- <!--
@@ -262,12 +215,12 @@
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}"> <Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Choose a vault passphrase" /> <TextBlock Classes="heading" Text="Choose a keychain passphrase" />
<TextBlock Classes="hint" <TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." /> Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" /> <TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" /> <TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
<Button Classes="accent" Content="CREATE MY VAULT" Command="{Binding EnrollCommand}" <Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel> </StackPanel>
@@ -275,14 +228,14 @@
<!-- <!--
Shown once and impossible to skip. This is the only moment the code exists, and losing it Shown once and impossible to skip. This is the only moment the code exists, and losing it
together with the passphrase means the vault is unrecoverable — there is no server-side reset by together with the passphrase means the keychain is unrecoverable — there is no server-side reset
design. by design.
--> -->
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}"> <Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Write this recovery code down" /> <TextBlock Classes="heading" Text="Write this recovery code down" />
<TextBlock Classes="hint" <TextBlock Classes="hint"
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." /> Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}" <Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="6" Padding="14"> BorderThickness="1" CornerRadius="6" Padding="14">
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}" <SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
+154 -43
View File
@@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading;
using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -66,14 +67,56 @@ internal sealed partial class MainWindow : Window
/// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears /// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears
/// it outright, and the fallback's own <c>Focus()</c> call was failing silently. /// it outright, and the fallback's own <c>Focus()</c> call was failing silently.
/// </para> /// </para>
/// <para>
/// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> still
/// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
/// keyboard to a host list nobody can see.
/// </para>
/// </remarks> /// </remarks>
private IInputElement KeyboardHome => shell?.Screen switch private IInputElement KeyboardHome => shell switch
{ {
ShellScreen.Vault => VaultPane.KeyboardTarget, { IsTerminalShowing: true } => Terminal,
ShellScreen.Hosts => Hosts.KeyboardTarget, { Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
_ => this, _ => this,
}; };
/// <summary>
/// Asks for the terminal to take the keyboard, once layout has run.
/// </summary>
/// <remarks>
/// <para>
/// <b>Posted, not called.</b> Every path that reaches here has revealed the WebView in this same turn —
/// a session opened from another screen, a tab clicked while a page was showing, the palette closing
/// back onto a terminal. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so
/// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
/// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
/// </para>
/// <para>
/// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
/// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
/// </para>
/// <para>
/// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
/// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
/// into a collapsed WebView would leave the window with nothing focused at all.
/// </para>
/// </remarks>
private void FocusTerminalWhenLaidOut() =>
Dispatcher.UIThread.Post(
() =>
{
if (shell is { IsTerminalShowing: true })
{
Terminal.Focus();
}
},
DispatcherPriority.Loaded);
/// <summary> /// <summary>
/// Where the keyboard belongs once the vault is no longer open. /// Where the keyboard belongs once the vault is no longer open.
/// </summary> /// </summary>
@@ -160,14 +203,19 @@ internal sealed partial class MainWindow : Window
} }
/// <remarks> /// <remarks>
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes /// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> call is
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is — /// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
/// a session can only be opened from the hosts screen of an unlocked vault, and that is exactly the /// reliably is at this instant. A session can now be opened from any screen, so this event routinely
/// state in which the terminal is showing. Focus() on a collapsed control is measurably a no-op and is /// arrives in the same turn that revealed the WebView. Hence the post; see
/// not replayed when it is revealed. /// <see cref="FocusTerminalWhenLaidOut"/>.
/// </remarks> /// </remarks>
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus(); private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
/// <remarks>
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
/// point at which "does this one return early" stops being obvious to a reader.
/// </remarks>
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {
if (shell is not { } viewModel) if (shell is not { } viewModel)
@@ -175,54 +223,117 @@ internal sealed partial class MainWindow : Window
return; return;
} }
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal)) switch (e.PropertyName)
{ {
var unlocked = viewModel.IsUnlocked; case nameof(MainWindowViewModel.IsUnlocked):
OnVaultOpenedOrClosed(viewModel);
break;
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state case nameof(MainWindowViewModel.IsSearching):
// change, and reacting to all of them would move focus during setup and sign-in. OnPaletteToggled(viewModel);
if (wasUnlocked && !unlocked) break;
{
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
wasUnlocked = unlocked; // One arm for both, deliberately. They mean the same thing to this handler — what the window is
// showing may have changed — and answering them separately would make the order of two
// PropertyChanged raises decide the outcome. Connecting from the palette moves both.
case nameof(MainWindowViewModel.Surface):
case nameof(MainWindowViewModel.Screen):
OnShowingSomethingElse(viewModel);
break;
case nameof(MainWindowViewModel.SelectedTab):
OnSelectedTabChanged(viewModel);
break;
default:
break;
}
}
private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
{
var unlocked = viewModel.IsUnlocked;
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
// change, and reacting to all of them would move focus during setup and sign-in.
if (wasUnlocked && !unlocked)
{
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
wasUnlocked = unlocked;
}
/// <remarks>
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
/// which is a moment this handler is measurably ahead of: it runs from the view model's
/// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
/// that is still collapsed is a no-op that is not replayed when it is revealed.
/// </remarks>
private void OnPaletteToggled(MainWindowViewModel viewModel)
{
if (viewModel.IsSearching)
{
return; return;
} }
// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is // Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
// expected to start typing into immediately — but the palette does that for itself when it becomes // focus rather than the immediate one.
// visible, which is a moment this handler is measurably ahead of: it runs from the view model's if (viewModel.IsTerminalShowing)
// PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
// still collapsed is a no-op that is not replayed when it is revealed.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
{ {
if (!viewModel.IsSearching) FocusTerminalWhenLaidOut();
{ }
ReleaseKeyboardTo(KeyboardHome); else
} {
ReleaseKeyboardTo(KeyboardHome);
}
}
/// <summary>
/// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
/// </summary>
/// <remarks>
/// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
/// are both one click away at all times.
/// <para>
/// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
/// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> The native
/// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
/// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
/// was rare; it is the hot path now. See <c>docs/platform-flags.md</c>, and
/// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
/// </para>
/// </remarks>
private void OnShowingSomethingElse(MainWindowViewModel viewModel)
{
if (!viewModel.IsUnlocked)
{
return; return;
} }
// Switching screens moves the keyboard to whatever the new screen offers, for the same reason: if (viewModel.IsTerminalShowing)
// leaving it on a control that has just been collapsed leaves the window with nothing focused.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.Screen), StringComparison.Ordinal)
&& viewModel.IsUnlocked)
{ {
KeyboardHome.Focus(); FocusTerminalWhenLaidOut();
return;
} }
else
// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click
// is what took the WebView's Win32 focus away in the first place. term.focus() in the page only
// ever reaches document.activeElement, which does nothing for a page that no longer holds the
// native focus, so without this the pane looks selected and every keystroke goes to the button
// instead of the shell until the user clicks inside the terminal by hand.
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.SelectedTab), StringComparison.Ordinal)
&& viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
{ {
Terminal.Focus(); ReleaseKeyboardTo(KeyboardHome);
}
}
/// <remarks>
/// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is
/// what took the WebView's Win32 focus away in the first place. <c>term.focus()</c> in the page only
/// ever reaches <c>document.activeElement</c>, which does nothing for a page that no longer holds the
/// native focus, so without this the pane looks selected and every keystroke goes to the button instead
/// of the shell until the user clicks inside the terminal by hand.
/// </remarks>
private void OnSelectedTabChanged(MainWindowViewModel viewModel)
{
if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
{
FocusTerminalWhenLaidOut();
} }
} }
+33 -9
View File
@@ -5,7 +5,7 @@
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
<!-- <!--
Five destinations down the left edge. Six destinations down the left edge.
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
@@ -16,6 +16,12 @@
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
of those hold the selection themselves, so a click moves the highlight before the shell can decide of those hold the selection themselves, so a click moves the highlight before the shell can decide
anything. Buttons carry no state and cannot disagree with the screen that is showing. anything. Buttons carry no state and cannot disagree with the screen that is showing.
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
back — and a rail entry lit while a terminal filled the window would be pointing at a screen that is not
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
mark, in the strip, and two "you are here" marks is one too many.
--> -->
<Border Width="54" Background="{StaticResource Chrome}" <Border Width="54" Background="{StaticResource Chrome}"
@@ -23,26 +29,44 @@
<DockPanel LastChildFill="False"> <DockPanel LastChildFill="False">
<StackPanel DockPanel.Dock="Top" Margin="0,8,0,0"> <StackPanel DockPanel.Dock="Top" Margin="0,8,0,0">
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsScreen}" <Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" CommandParameter="{x:Static vm:ShellScreen.Hosts}"
ToolTip.Tip="Your hosts, and the terminals open on them" /> ToolTip.Tip="Your hosts, and what is known about the one you have selected" />
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}" <Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Transfers}" CommandParameter="{x:Static vm:ShellScreen.Transfers}"
ToolTip.Tip="Move files to and from a host over SFTP" /> ToolTip.Tip="Move files to and from a host over SFTP" />
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}" <Button Classes="flat nav" Content="KEYS" Classes.active="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}" CommandParameter="{x:Static vm:ShellScreen.Vault}"
ToolTip.Tip="SSH keys, stored passwords, and the host keys you have approved" /> ToolTip.Tip="Your keychain: SSH keys, stored passwords, and the host keys you have approved" />
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamScreen}" <!--
PINS, not HOST KEYS. The rail is 54 pixels wide at mono FontSize 9, which is five characters —
and "pins" is what this codebase calls them everywhere else anyway.
-->
<Button Classes="flat nav" Content="PINS" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one" />
<!-- SNIPS, for the same five-character reason as PINS above. -->
<Button Classes="flat nav" Content="SNIPS" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
ToolTip.Tip="Commands you have saved, and how to put one into a terminal" />
<!-- LOGS, four characters, so it needs no abbreviating at all. -->
<Button Classes="flat nav" Content="LOGS" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}"
ToolTip.Tip="What has been connected to, and what has been changed in this keychain" />
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Team}" CommandParameter="{x:Static vm:ShellScreen.Team}"
ToolTip.Tip="Shared vaults and the people in them. Not built yet — see the screen for what is missing." /> ToolTip.Tip="Shared keychains and the people in them. Not built yet — see the screen for what is missing." />
</StackPanel> </StackPanel>
<Button DockPanel.Dock="Bottom" Classes="flat nav" Content="PREFS" <Button DockPanel.Dock="Bottom" Classes="flat nav" Content="PREFS"
Classes.active="{Binding IsPreferencesScreen}" Classes.active="{Binding IsPreferencesShowing}"
Command="{Binding ShowScreenCommand}" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}" CommandParameter="{x:Static vm:ShellScreen.Preferences}"
ToolTip.Tip="Preferences, and this machine's device key" /> ToolTip.Tip="Preferences, and this machine's device key" />
@@ -34,7 +34,7 @@
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="12" <TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" /> FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10" <TextBlock Classes="hint" FontSize="10"
Text="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." /> Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="accent" Content="REGISTER" <Button Grid.Column="1" Classes="accent" Content="REGISTER"
Command="{Binding RegisterDeviceCommand}" Command="{Binding RegisterDeviceCommand}"
@@ -54,20 +54,20 @@
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. --> <!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
<TextBlock Classes="hint" FontSize="10" Margin="0,8,0,0" <TextBlock Classes="hint" FontSize="10" Margin="0,8,0,0"
Text="This machine has nowhere to keep a device key, so the vault will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key." Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
IsVisible="{Binding HasNoDeviceKeyOption}" /> IsVisible="{Binding HasNoDeviceKeyOption}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" /> <Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="VAULT" FontSize="13" FontWeight="SemiBold" <TextBlock Classes="mono" Text="KEYCHAIN" FontSize="13" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" /> LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0"> <Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0"> <StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Lock the vault" Foreground="{StaticResource Text}" FontSize="12" <TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" /> FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10" <TextBlock Classes="hint" FontSize="10"
Text="Closes the vault and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the vault, not this machine's access to your hosts." /> Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" /> <Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
</Grid> </Grid>
@@ -77,7 +77,7 @@
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12" <TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" /> FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10" <TextBlock Classes="hint" FontSize="10"
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." /> Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}" <Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
@@ -87,6 +87,18 @@
</StackPanel> </StackPanel>
</Grid> </Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10"
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Import}" />
</Grid>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" /> <Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold" <TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
@@ -100,7 +112,7 @@
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12" <TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
FontWeight="Medium" /> FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="10" <TextBlock Classes="hint" FontSize="10"
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." /> Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
</StackPanel> </StackPanel>
<!-- <!--
Hidden rather than disabled while the confirmation is up, because the card below carries the Hidden rather than disabled while the confirmation is up, because the card below carries the
@@ -137,7 +149,7 @@
<TextBlock Classes="gap" <TextBlock Classes="gap"
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." /> Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
<TextBlock Classes="gap" <TextBlock Classes="gap"
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the vault." /> Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the keychain." />
<TextBlock Classes="gap" <TextBlock Classes="gap"
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." /> Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
<TextBlock Classes="gap" <TextBlock Classes="gap"
@@ -28,7 +28,7 @@
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." /> Text="This deletes this machine's copy of the keychain — the profile, the cached hosts, keys and passwords, and this machine's device key. Your keychain is on the server and is not touched: signing in again brings it all back." />
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" <Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="10,8" BorderThickness="1" CornerRadius="4" Padding="10,8"
@@ -0,0 +1,174 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SnippetsScreen"
x:DataType="vm:SnippetsViewModel">
<!--
Commands somebody has saved, and how to get one into a terminal.
The list and the writing belong to the vault, as every other item kind's do; this screen is the filter,
the editor and the insert over the top. See SnippetsViewModel.
The two buttons at the bottom right are the whole safety design, and their wording is load-bearing.
A terminal is one input stream with no notion of being at a prompt — the remote may be inside vi, or at
a sudo password prompt with echo off — so this application cannot say "run this command", only "type
this into whatever is there". RUN appears solely for a snippet whose own flag says it runs, which makes
that a decision taken once while writing it rather than a button beside every one of them.
-->
<Grid ColumnDefinitions="*,300">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
The command is searched as well as the name: half of what anybody remembers about a saved
command is a word that was inside it.
-->
<TextBox Grid.Column="2" x:Name="SnippetFilter" Text="{Binding Filter}" Width="240"
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
</Grid>
</Border>
<ListBox Grid.Row="1" x:Name="SnippetList" Focusable="True"
ItemsSource="{Binding Visible}"
SelectedItem="{Binding Selected}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Grid ColumnDefinitions="2,*" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<!--
The flag, where the decision is made. A snippet that presses Enter for you is not the
same kind of thing as one that does not, and the list is where somebody chooses between
them.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
<TextBlock Text="runs immediately" FontSize="8.5" />
</Border>
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</StackPanel>
<!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
text reads as a single command, which is the thing being decided about on this row.
-->
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" />
<Border Grid.Row="2" Padding="14,8" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="+ NEW SNIPPET" Command="{Binding NewCommand}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
IsEnabled="{Binding HasSelection}" />
</StackPanel>
</Border>
</Grid>
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="14,16" Spacing="8">
<!-- ============ The editor ============ -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
waiting for one that never comes.
-->
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
FontSize="11" />
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
Content="Press Enter after inserting this" />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCommand}" />
</StackPanel>
</StackPanel>
<!-- ============ The selected snippet ============ -->
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
<TextBlock Classes="hint" FontSize="11"
Text="Choose a snippet to see it in full and put it into a terminal."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="10"
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
The button names the tab it will type into. This screen is not the terminal — the strip
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
about to receive a command, at the moment that is worst to be wrong about.
-->
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
Command="{Binding RunCommand}"
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,10,0,0"
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,24 @@
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The commands this keychain has saved.
/// </summary>
/// <remarks>
/// Its data context is a <c>SnippetsViewModel</c>, a screen-scoped wrapper over the vault rather than an
/// owner of anything: the list, the storage and the push all still belong to <c>VaultViewModel</c>.
/// </remarks>
internal sealed partial class SnippetsScreen : UserControl
{
public SnippetsScreen() => InitializeComponent();
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
/// <remarks>
/// The filter box rather than the list, for the reason the pins screen gives: the box is there on a
/// keychain with nothing saved yet, where the list is empty and <c>Focus()</c> on it would be a no-op
/// nothing replays.
/// </remarks>
internal IInputElement KeyboardTarget => SnippetFilter;
}
@@ -0,0 +1,188 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.TeamsScreen"
x:DataType="vm:TeamsViewModel">
<!--
Teams.
The screen is built around one fact that every other product in this category hides: adding somebody to
a team and giving them a vault key are two different acts, and only the first is something a server can
do. The second needs a machine that holds the key, because this server never does. So the members table
and the vaults table are side by side, an addition says out loud that it granted nothing readable yet,
and SHARE KEY is its own button rather than a checkbox on the member row.
What the design asked for and is still not here: pending invitations (there is no outbound mail path and
no invitation token), two-factor state and last-active (the server records neither), and avatars (no
picture is stored anywhere). None of them is drawn with invented data.
-->
<Grid ColumnDefinitions="268,*">
<!-- ============ The team list ============ -->
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<Grid RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="ghost" Content="NEW"
Command="{Binding NewTeamCommand}" IsEnabled="{Binding !IsBusy}" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel>
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
<TextBlock Classes="hint" FontSize="10" Text="{Binding Detail}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10" Margin="14,12" TextWrapping="Wrap"
IsVisible="{Binding !HasTeams}"
Text="No teams yet. A team is what makes a vault shareable: its vaults can be opened by every member you wrap a key to." />
</StackPanel>
</ScrollViewer>
<!-- The create form, in place rather than in a modal: this window has no idiom for one. -->
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsCreatingTeam}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="NEW TEAM" />
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- ============ Members and vaults ============ -->
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<TextBlock Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</Border>
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
<StackPanel Margin="14,14" Spacing="18">
<!-- Members -->
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MEMBERS" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0" MaxHeight="240">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding Email}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="hint" FontSize="10" VerticalAlignment="Center"
Text="{Binding KeyState}" TextWrapping="Wrap" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every vault key they hold from this team. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
</Grid>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
IsVisible="{Binding CanAdministerSelected}"
Text="Adding somebody lets the server serve them this team's vaults. It does not let them read one: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Vaults -->
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
Command="{Binding CreateVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
</Grid>
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0" MaxHeight="200">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
IsVisible="{Binding !HasSelection}"
Text="Select a team to see its vaults." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Wraps the selected vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
</StackPanel>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
</StackPanel>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="1" Classes="hint" FontSize="11" Margin="20" TextWrapping="Wrap"
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
Text="Create a team on the left, or wait to be added to one. A team owns vaults; a vault's key is what makes its contents readable, and that key is handed out by people rather than by the server." />
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding Status}" TextWrapping="Wrap" />
</Border>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Teams: who is in one, what they may do, and which vaults they hold a key to.</summary>
internal sealed partial class TeamsScreen : UserControl
{
public TeamsScreen() => InitializeComponent();
}
+86 -50
View File
@@ -5,17 +5,20 @@
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel">
<!-- <!--
The tab strip above the terminal. The tab strip, above every screen.
Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
one is the only thing in this application that deliberately ends a session. one is the only thing in this application that deliberately ends a session.
Three of the design's header controls are absent: SPLIT, FORWARDS and SNIPPETS. Splits would need a It spans the whole window rather than the hosts screen, which is what the strip is for: a connection you
second pane geometry the renderer does not have, port forwarding does not exist in the SSH layer, and opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
there is no snippet item type in the vault. Three disabled buttons would teach nobody anything; see Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
docs/design-import-gaps.md.
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
@@ -24,10 +27,15 @@
<Border Height="34" Background="{StaticResource Chrome}" <Border Height="34" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto" <!--
VerticalScrollBarVisibility="Disabled"> Everything in one scrolling row: the tabs, then the button that opens another, then the sentence for
when there are none. The strip stays rather than collapsing — a row of chrome that appears and
disappears would move every screen up and down by 34 pixels each time the last tab closed.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal">
<ItemsControl ItemsSource="{Binding Tabs}"> <ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
@@ -36,57 +44,85 @@
</ItemsControl.ItemsPanel> </ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel"> <DataTemplate x:DataType="vm:TerminalTabViewModel">
<Grid ColumnDefinitions="*,Auto">
<Button Grid.Column="0" Classes="flat tab" <!--
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}" The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
CommandParameter="{Binding}" the cross was as tall as the strip and sat outside the tab's own background, so it read as a
Classes.active="{Binding IsSelected}"> divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"> for the tab to its right.
<!--
Green while the shell behind this tab is running, grey once it has ended. The pane
keeps its scrollback either way, which is usually why somebody is still looking at a
tab whose dot has gone out.
-->
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="flat close" Width="20" Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
VerticalAlignment="Stretch" Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}" event handled — so a left press on the cross does not also select the tab. It deliberately
CommandParameter="{Binding}" does not handle any other button, which is exactly what lets a middle press bubble out of
ToolTip.Tip="Closes this terminal and ends its shell."> the cross and reach the handler below.
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center" -->
VerticalAlignment="Center" /> <Button Classes="flat tab"
</Button> Classes.active="{Binding IsSelected}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}"
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green while the shell behind this tab is running, grey once it has ended. The pane
keeps its scrollback either way, which is usually why somebody is still looking at a
tab whose dot has gone out.
-->
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<!--
Always drawn, never on hover only. The strip has no other close affordance, and one
that appears when the pointer is already over the tab cannot be found by somebody
looking for it.
-->
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="✕" FontSize="9" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
</Button>
</Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer>
<!-- <!--
Nothing open, and this is where that is said. The strip stays rather than collapsing — a row of Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
chrome that appears and disappears moves the terminal up and down by 34 pixels every time the last honestly, and there is one way to start a connection rather than two that have to agree.
tab closes — and it is also the only place near the terminal that can carry a sentence at all: the
rectangle below is a native child window, and anything Avalonia draws in it is drawn underneath.
-->
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5"
Text="no terminals open · choose a host and press Connect, or Ctrl+K"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding !HasTabs}" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5" Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0" verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
TextTrimming="CharacterEllipsis" MaxWidth="280" dropping into it composites above a native child window depends on whether Avalonia gives it its
IsVisible="{Binding HasTabs}" /> own platform window. docs/platform-flags.md records what this project already paid for treating a
rendering claim as settled without a screenshot. The palette has no such question — opening it
collapses the terminal outright.
-->
<Button Classes="flat tab plus" Width="30"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Grid> <!--
Nothing open, and this is where that is said. It is also the only place near the terminal that can
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
in it is drawn underneath.
-->
<TextBlock Classes="mono" FontSize="9.5"
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding !HasTabs}" />
</StackPanel>
</ScrollViewer>
</Border> </Border>
</UserControl> </UserControl>
@@ -1,9 +1,56 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
/// <summary>The tab strip above the terminal.</summary> /// <summary>The tab strip, above every screen.</summary>
internal sealed partial class TerminalTabs : UserControl internal sealed partial class TerminalTabs : UserControl
{ {
public TerminalTabs() => InitializeComponent(); public TerminalTabs() => InitializeComponent();
/// <summary>
/// Closes a tab on a middle click.
/// </summary>
/// <remarks>
/// <para>
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
/// </para>
/// <para>
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
/// and for every press during a middle drag. The question here is which button caused this press, and
/// that is the one thing only <c>PointerUpdateKind</c> answers.
/// </para>
/// <para>
/// On press rather than on release, which is what every browser and every terminal does. Matching a
/// release to its press would need capture tracking, to buy the ability to change your mind about a
/// middle click — a gesture nobody makes by accident and nobody aborts.
/// </para>
/// </remarks>
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|| DataContext is not MainWindowViewModel shell)
{
return;
}
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
is not PointerUpdateKind.MiddleButtonPressed)
{
return;
}
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
e.Handled = true;
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
// it. Its failures are the workspace's to report, not this strip's.
shell.CloseTabCommand.Execute(tab);
}
} }
@@ -65,15 +65,32 @@
<!-- ============ The host, and the connection ============ --> <!-- ============ The host, and the connection ============ -->
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> <Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center"> <Grid ColumnDefinitions="Auto,Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold" <TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,12,0" /> Margin="0,0,12,0" />
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}" <!--
Which sort of remote. Two buttons rather than one picker holding hosts and buckets together, and
the reason is that the two are not interchangeable: a host brings a password box, a host key
prompt and a mismatch refusal with it, and a bucket has no equivalent of any of them. One picker
would mean half this bar appearing and disappearing with the selection.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2" Margin="0,0,8,0"
IsVisible="{Binding !IsConnected}">
<Button Classes="flat cat" Content="HOST" Classes.active="{Binding ShowsHostPicker}"
Command="{Binding ShowRemoteCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}" />
<Button Classes="flat cat" Content="BUCKET" Classes.active="{Binding ShowsBucketPicker}"
Command="{Binding ShowRemoteCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}" />
</StackPanel>
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}" SelectedItem="{Binding SelectedHost}"
IsEnabled="{Binding !IsConnected}" IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsHostPicker}"
PlaceholderText="choose a host"> PlaceholderText="choose a host">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel"> <DataTemplate x:DataType="vm:HostRowViewModel">
@@ -87,26 +104,43 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<ComboBox Grid.Column="2" ItemsSource="{Binding Buckets}"
SelectedItem="{Binding SelectedBucket}"
IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsBucketPicker}"
PlaceholderText="choose a bucket">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- <!--
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
holding a different value. This connection authenticates separately, so a password typed to open a holding a different value. This connection authenticates separately, so a password typed to open a
terminal was never offered here. terminal was never offered here.
--> -->
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•" <TextBox Grid.Column="3" Width="150" Margin="6,0,0,0" PasswordChar="•"
Text="{Binding TypedPassword}" PlaceholderText="password" Text="{Binding TypedPassword}" PlaceholderText="password"
IsVisible="{Binding SelectedHostAsksForAPassword}" IsVisible="{Binding SelectedHostAsksForAPassword}"
IsEnabled="{Binding !IsConnected}" /> IsEnabled="{Binding !IsConnected}" />
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0" <Button Grid.Column="4" Classes="accent" Content="{Binding ConnectLabel}" Margin="6,0,0,0"
Command="{Binding ConnectCommand}" Command="{Binding ConnectCommand}"
IsVisible="{Binding !IsConnected}" IsVisible="{Binding !IsConnected}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0" <Button Grid.Column="4" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
Command="{Binding DisconnectCommand}" Command="{Binding DisconnectCommand}"
IsVisible="{Binding IsConnected}" /> IsVisible="{Binding IsConnected}" />
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0" <Border Grid.Column="5" Classes="chip accent" Margin="8,0,0,0"
IsVisible="{Binding IsConnected}"> IsVisible="{Binding IsConnected}">
<TextBlock Text="{Binding ConnectedTo}" /> <TextBlock Text="{Binding ConnectedTo}" />
</Border> </Border>
@@ -122,7 +156,12 @@
<Grid Grid.Row="1" ColumnDefinitions="*,64,*"> <Grid Grid.Row="1" ColumnDefinitions="*,64,*">
<!-- ==== This machine ==== --> <!-- ==== This machine ==== -->
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*"> <!--
AllowDrop on the pane rather than on the list, because an empty directory lays its ListBox out at
zero height behind the empty-state sentence — a handler on the list would have nothing to hit.
This side takes remote rows only; see TransfersScreen.axaml.cs.
-->
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}" <Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
@@ -204,6 +243,17 @@
Text="Nothing in this folder. Use the trail above to go somewhere else." Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" /> IsVisible="{Binding !HasLocalEntries}" />
<!--
The drop highlight, over the whole pane and last so it is on top.
IsHitTestVisible="False" is not optional. An overlay that takes part in hit testing swallows the
DragOver events underneath it the moment it appears — so the pointer leaves, the highlight never
clears, and the drop lands nowhere.
-->
<Border Grid.Row="0" Grid.RowSpan="4" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsLocalDropTarget}" />
</Grid> </Grid>
<!-- ==== The two directions ==== --> <!-- ==== The two directions ==== -->
@@ -226,7 +276,8 @@
</Border> </Border>
<!-- ==== The host ==== --> <!-- ==== The host ==== -->
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*"> <Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,*"
DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}" <Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1">
@@ -345,6 +396,24 @@
IsVisible="{Binding IsConnected}" /> IsVisible="{Binding IsConnected}" />
</StackPanel> </StackPanel>
<!--
Two highlights rather than one, because refusing is worth showing. Something dragged over a
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
window that has stopped answering, and the answer arriving after the drop is the answer arriving
too late. See the local pane for why neither may hit-test.
-->
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="11"
Foreground="{StaticResource Danger}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid> </Grid>
</Grid> </Grid>
@@ -1,5 +1,8 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -8,12 +11,42 @@ namespace DodoSSH.Client.App.Views;
/// The two-pane file browser and the transfer queue. /// The two-pane file browser and the transfer queue.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a /// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See /// transfer in flight has to survive a lock, the same policy that keeps shells running. See
/// <c>MainWindowViewModel.LockAsync</c>. /// <c>MainWindowViewModel.LockAsync</c>.
/// </para>
/// <para>
/// <b>Everything about drag and drop is in this file and nothing about it is policy.</b> The handlers pull
/// paths or rows out of a drop and hand them to <c>QueueUploads</c>/<c>QueueDownloads</c>; what may be
/// queued, what is skipped and what is said about it all live in the view model, where they can be tested
/// without a window. Nothing headless can synthesise a real platform drag, so the wiring below is verified
/// by hand — see <c>docs/manual-checks.md</c>.
/// </para>
/// </remarks> /// </remarks>
internal sealed partial class TransfersScreen : UserControl internal sealed partial class TransfersScreen : UserControl
{ {
/// <summary>
/// How remote rows travel while being dragged.
/// </summary>
/// <remarks>
/// An in-process format, so the rows themselves cross rather than a list of path strings that would
/// have to be looked up again on the other side. It also cannot be confused with a drop from the
/// operating system: a file dragged out of the file manager arrives as <c>DataFormat.File</c> and never
/// as this, so "did this come from our own remote pane" needs no guessing.
/// </remarks>
private static readonly DataFormat<RemoteDragPayload> RemoteEntries =
DataFormat.CreateInProcessFormat<RemoteDragPayload>("dodossh/remote-entries");
/// <summary>How far the pointer moves before a press becomes a drag.</summary>
/// <remarks>
/// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
/// </remarks>
private const double DragThreshold = 4;
private PointerPressedEventArgs? pressed;
private Point pressedAt;
public TransfersScreen() public TransfersScreen()
{ {
InitializeComponent(); InitializeComponent();
@@ -23,11 +56,32 @@ internal sealed partial class TransfersScreen : UserControl
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them. // Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
LocalList.DoubleTapped += OnLocalActivated; LocalList.DoubleTapped += OnLocalActivated;
RemoteList.DoubleTapped += OnRemoteActivated; RemoteList.DoubleTapped += OnRemoteActivated;
// On the pane rather than on the list. A directory with nothing in it lays its ListBox out at zero
// height behind the empty-state sentence, and a drop handler on the list would have nothing to hit.
LocalPane.AddHandler(DragDrop.DragOverEvent, OnLocalDragOver);
LocalPane.AddHandler(DragDrop.DragLeaveEvent, OnLocalDragLeave);
LocalPane.AddHandler(DragDrop.DropEvent, OnLocalDrop);
RemotePane.AddHandler(DragDrop.DragOverEvent, OnRemoteDragOver);
RemotePane.AddHandler(DragDrop.DragLeaveEvent, OnRemoteDragLeave);
RemotePane.AddHandler(DragDrop.DropEvent, OnRemoteDrop);
// Tunnelling, so noting where a press started does not take the press away from the ListBox — a row
// still selects, and the drag only begins once the pointer has moved far enough.
foreach (var list in new Control[] { LocalList, RemoteList })
{
list.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
list.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
list.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
}
} }
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
private void OnLocalActivated(object? sender, TappedEventArgs e) private void OnLocalActivated(object? sender, TappedEventArgs e)
{ {
if (DataContext is TransfersViewModel transfers) if (Transfers is { } transfers)
{ {
transfers.OpenLocalCommand.Execute(null); transfers.OpenLocalCommand.Execute(null);
} }
@@ -40,9 +94,211 @@ internal sealed partial class TransfersScreen : UserControl
/// </remarks> /// </remarks>
private void OnRemoteActivated(object? sender, TappedEventArgs e) private void OnRemoteActivated(object? sender, TappedEventArgs e)
{ {
if (DataContext is TransfersViewModel transfers) if (Transfers is { } transfers)
{ {
_ = transfers.OpenRemoteCommand.ExecuteAsync(null); _ = transfers.OpenRemoteCommand.ExecuteAsync(null);
} }
} }
// ---- Starting a drag ----
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed)
{
pressed = e;
pressedAt = e.GetPosition(this);
}
}
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => pressed = null;
/// <remarks>
/// The drag starts here rather than on the press, because a press is also how a row is selected.
/// <c>DoDragDropAsync</c> wants the original <c>PointerPressedEventArgs</c>, so it is held from the
/// press until either the pointer moves far enough or the button comes back up.
/// </remarks>
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (pressed is not { } origin || Transfers is not { } transfers)
{
return;
}
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
pressed = null;
return;
}
var moved = e.GetPosition(this) - pressedAt;
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
{
return;
}
pressed = null;
if (ReferenceEquals(sender, RemoteList))
{
StartRemoteDrag(origin, transfers);
}
else
{
_ = StartLocalDragAsync(origin, transfers);
}
}
private static void StartRemoteDrag(PointerPressedEventArgs origin, TransfersViewModel transfers)
{
if (transfers.SelectedRemoteEntry is not { } row)
{
return;
}
using var transfer = new DataTransfer();
transfer.Add(DataTransferItem.Create(RemoteEntries, new RemoteDragPayload([row])));
_ = DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy);
}
/// <remarks>
/// Local files travel as the platform's own file format rather than as an in-process one, which is what
/// makes a single drag work both onto the remote pane and out into the file manager. It needs a real
/// <see cref="IStorageItem"/>, hence the asynchronous lookup — and hence a fire-and-forget call, because
/// nothing on a pointer-moved path can await.
/// </remarks>
private async Task StartLocalDragAsync(PointerPressedEventArgs origin, TransfersViewModel transfers)
{
if (transfers.SelectedLocalEntry is not { IsFile: true } row
|| TopLevel.GetTopLevel(this) is not { } top)
{
return;
}
var file = await top.StorageProvider.TryGetFileFromPathAsync(row.FullPath).ConfigureAwait(true);
if (file is null)
{
return;
}
using var transfer = new DataTransfer();
transfer.Add(DataTransferItem.CreateFile(file));
await DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy).ConfigureAwait(true);
}
// ---- Accepting a drop ----
/// <remarks>
/// The local pane takes remote rows and nothing else. A file dragged from the file manager onto it
/// would be a copy from this machine to this machine, which is not what this screen is for.
/// </remarks>
private void OnLocalDragOver(object? sender, DragEventArgs e)
{
var accepted = e.DataTransfer.Contains(RemoteEntries);
e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None;
if (Transfers is { } transfers)
{
transfers.IsLocalDropTarget = accepted;
}
e.Handled = true;
}
private void OnLocalDragLeave(object? sender, DragEventArgs e)
{
if (Transfers is { } transfers)
{
transfers.IsLocalDropTarget = false;
}
}
private void OnLocalDrop(object? sender, DragEventArgs e)
{
if (Transfers is not { } transfers)
{
return;
}
transfers.IsLocalDropTarget = false;
e.Handled = true;
if (e.DataTransfer.TryGetValue(RemoteEntries) is { } payload)
{
transfers.QueueDownloads(payload.Rows);
}
}
/// <remarks>
/// The remote pane takes files: from the file manager, and from the local pane, which offers the same
/// platform format. A drop while disconnected is refused visibly rather than accepted and then
/// explained, because a red pane under the pointer is the answer arriving before the drop rather than
/// after it.
/// </remarks>
private void OnRemoteDragOver(object? sender, DragEventArgs e)
{
var files = e.DataTransfer.Contains(DataFormat.File);
var connected = Transfers is { IsConnected: true };
e.DragEffects = files && connected ? DragDropEffects.Copy : DragDropEffects.None;
if (Transfers is { } transfers)
{
transfers.IsRemoteDropTarget = files && connected;
transfers.IsRemoteDropRefused = files && !connected;
}
e.Handled = true;
}
private void OnRemoteDragLeave(object? sender, DragEventArgs e) => ClearRemoteHighlight();
private void OnRemoteDrop(object? sender, DragEventArgs e)
{
if (Transfers is not { } transfers)
{
return;
}
ClearRemoteHighlight();
e.Handled = true;
if (e.DataTransfer.TryGetFiles() is not { } files)
{
return;
}
// TryGetLocalPath, because the queue reads bytes off a real path. A storage item that is not a
// local file — one from a cloud provider's virtual folder — has none, and dropping it is a thing
// this screen declines rather than a thing it half does.
var paths = files
.Select(file => file.TryGetLocalPath())
.OfType<string>()
.ToList();
transfers.QueueUploads(paths);
}
private void ClearRemoteHighlight()
{
if (Transfers is { } transfers)
{
transfers.IsRemoteDropTarget = false;
transfers.IsRemoteDropRefused = false;
}
}
} }
/// <summary>
/// The remote rows carried by one drag.
/// </summary>
/// <remarks>
/// A record wrapping the list rather than the list itself, because <c>DataFormat.CreateInProcessFormat</c>
/// keys on the type and a bare <c>IReadOnlyList&lt;T&gt;</c> is too general a key to be sure of.
/// </remarks>
internal sealed record RemoteDragPayload(IReadOnlyList<RemoteEntryRowViewModel> Rows);
@@ -19,7 +19,7 @@
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Unlock your vault" /> <TextBlock Classes="heading" Text="Unlock your keychain" />
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" /> <TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
<!-- <!--
@@ -33,7 +33,7 @@
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over. exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
--> -->
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}" <TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
PlaceholderText="vault passphrase" PasswordChar="•"> PlaceholderText="keychain passphrase" PasswordChar="•">
<TextBox.KeyBindings> <TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" /> <KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
</TextBox.KeyBindings> </TextBox.KeyBindings>
@@ -52,7 +52,7 @@
Command="{Binding UnlockWithDeviceCommand}" Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}" IsVisible="{Binding CanUnlockWithDevice}"
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." /> ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
</StackPanel> </StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -73,7 +73,7 @@
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}" <TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
FontWeight="SemiBold" TextWrapping="Wrap" /> FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." /> Text="Locking closes the keychain, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the keychain, not the connections. Quit DodoSSH to end them." />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -88,7 +88,7 @@
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." /> Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the keychain is on the server and comes back." />
<Button Classes="ghost" Content="RESET THIS MACHINE" <Button Classes="ghost" Content="RESET THIS MACHINE"
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" /> Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
</StackPanel> </StackPanel>
+150 -36
View File
@@ -2,25 +2,30 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views" xmlns:views="using:DodoSSH.Client.App.Views"
xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.App.Views.VaultScreen" x:Class="DodoSSH.Client.App.Views.VaultScreen"
x:DataType="vm:VaultViewModel"> x:DataType="vm:VaultViewModel">
<!-- <!--
Everything in the vault that is not a host: the keys, the stored passwords, and the host keys this user The keychain: the SSH keys and the stored passwords. Things a person creates and edits.
has approved.
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
shape for every kind, which is what makes the ALL category possible and is why the row projection shape for every kind, which is what makes the ALL category possible and is why the row projection
exists; see VaultItemRowViewModel. exists; see VaultItemRowViewModel.
Two of the design's five categories are not here. IDENTITIES and CERTIFICATES have no item type behind HOST KEYS was a fourth category here and is now a screen of its own; see KnownHostsScreen. It never fit:
them — the vault holds exactly four kinds and two of those are hosts and pins — so listing them would be the two categories left are things somebody made on purpose, and a pin is a decision recorded at the
two headings that could never have anything under them. HOST KEYS is the other way round: a real, moment of connecting — nobody goes looking for one in a list of credentials. It also has a workflow the
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md. shared table could not serve, which is comparing an untruncated fingerprint against a published one.
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The Two of the design's five categories are still not here. IDENTITIES and CERTIFICATES have no item type
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its behind them, so listing them would be two headings that could never have anything under them. Recorded
access service, so a rail with three entries would be showing two vaults nothing can open. in docs/design-import-gaps.md.
The SCOPES rail below the categories is the keychain list. Since M3 it genuinely has more than one entry
when somebody is in a team — but it is still not a selector, because every table on this screen already
spans every keychain this session holds a key for and each row names its own. What it carries instead is
the one keychain question with an answer: where a new item is filed.
--> -->
<Grid ColumnDefinitions="176,*,244"> <Grid ColumnDefinitions="176,*,244">
@@ -31,7 +36,7 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="0,12"> <StackPanel Margin="0,12">
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" /> <TextBlock Classes="label" Text="KEYCHAIN" Margin="14,0,14,8" />
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}" <Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.All}" CommandParameter="{x:Static vm:VaultSection.All}"
@@ -66,13 +71,18 @@
</Grid> </Grid>
</Button> </Button>
<!--
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a bucket
is something somebody creates, edits and keeps a secret for, which is what the other two
categories are. A pin is a decision recorded at connect time and is not.
-->
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}" <Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.KnownHosts}" CommandParameter="{x:Static vm:VaultSection.Buckets}"
Classes.active="{Binding ShowsKnownHosts}"> Classes.active="{Binding ShowsBuckets}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" /> <Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="HOST KEYS" Margin="12,0,0,0" /> <TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding KnownHostPins.Count}" <TextBlock Grid.Column="2" Text="{Binding ObjectStores.Count}"
Foreground="{StaticResource TextFaint}" /> Foreground="{StaticResource TextFaint}" />
</Grid> </Grid>
</Button> </Button>
@@ -82,18 +92,35 @@
<TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" /> <TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" />
<!-- <!--
One entry per vault this session opened. Not a selector: every list on this screen reads the Still not a selector. Every list on this screen now spans every vault this session holds a key
active vault, and a rail that let you click a vault you cannot switch to would be a control that for, and each row names its own vault — so there is nothing to switch to. What the picker below
does nothing. It is here because knowing which vault you are looking at is worth a line, and chooses is where a *new* item is filed, which is a different question and the only one that has
because this is where a second one appears when shared vaults arrive. an answer worth asking for.
--> -->
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7"> <StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" /> <Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10" <TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10"
Foreground="{StaticResource Text}" VerticalAlignment="Center" /> Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
Text="One vault, because the server grants access to your own and refuses the rest. Sharing is a later milestone." /> <!--
Hidden at one vault, which is where most people stay. A control offering a single option is a
question with no answer.
-->
<StackPanel Margin="14,10,14,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
<TextBlock Classes="label" Text="NEW ITEMS GO TO" />
<ComboBox ItemsSource="{Binding TargetVaults}"
SelectedItem="{Binding SelectedTargetVault}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="11" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="An item filed into a team's vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own." />
</StackPanel>
<!-- <!--
Items that would not decrypt. Shown here rather than only in the status line because this is the Items that would not decrypt. Shown here rather than only in the status line because this is the
@@ -121,10 +148,18 @@
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5" <TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" /> Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6"> <StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<!--
Always offered. Every category left on this screen is one things can be added to — the one
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
either. See KnownHostsScreen.
-->
<Button Classes="ghost" Content="GENERATE KEY" Command="{Binding NewGeneratedKeyCommand}"
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}" <Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
IsVisible="{Binding CanAddToSection}" /> ToolTip.Tip="Pastes in a key you already have." />
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" <Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
IsVisible="{Binding CanAddToSection}" /> <Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border> </Border>
@@ -218,7 +253,7 @@
empty rows, this says what is missing in one line. empty rows, this says what is missing in one line.
--> -->
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0" <TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." /> Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0" <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding ShowsItemActions}"> IsVisible="{Binding ShowsItemActions}">
@@ -226,6 +261,17 @@
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" /> <Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel> </StackPanel>
<!--
The public half only, and there is no button for the other one. Installing a key means pasting
this line into a host's authorized_keys; a private key on the clipboard is a private key in
every application on the machine.
-->
<Button Classes="ghost" Content="COPY PUBLIC KEY" Margin="0,6,0,0"
HorizontalAlignment="Left"
IsVisible="{Binding SelectedItemIsKey}"
Command="{Binding CopyPublicKeyCommand}"
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
<!-- <!--
The question DELETE asks, in the place those two buttons were. Here rather than over the The question DELETE asks, in the place those two buttons were. Here rather than over the
screen, because this pane is where the item being deleted is described: the name, the kind and screen, because this pane is where the item being deleted is described: the name, the kind and
@@ -238,19 +284,50 @@
<views:ConfirmDeleteCard /> <views:ConfirmDeleteCard />
</Border> </Border>
</StackPanel>
<!--
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm, a
comment, and a button. What it produces lands in the editor below, unsaved — so there is still
exactly one thing on this screen that writes a key, and it is still SAVE.
-->
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
<TextBlock Classes="label" Text="NEW SSH KEY" Margin="0,0,0,4" />
<StackPanel Orientation="Horizontal" Spacing="6">
<!--
Buttons and a command rather than a selector bound to the algorithm, which is the same
choice the category rail makes and for the same reason: a selector moves its own highlight
before anything can refuse, so it can end up showing a choice nobody made.
-->
<Button Classes="flat choice" Content="ED25519"
Classes.active="{Binding GeneratesEd25519}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}"
ToolTip.Tip="What every current OpenSSH prefers. Small, fast, and generated instantly." />
<Button Classes="flat choice" Content="RSA 4096"
Classes.active="{Binding GeneratesRsa}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}"
ToolTip.Tip="For servers too old to accept Ed25519. Larger, and a few seconds to generate." />
</StackPanel>
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
<!-- <!--
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate: Said plainly rather than left to be discovered. Writing an encrypted openssh-key-v1 file needs
a pin appears because somebody approved a fingerprint at the moment of connecting, which is bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
the one place it can be checked against what the operator published. What it does have is a already makes: a passphrase protects a key file on a disk, and this key is never on one.
way out, because a changed host key is refused outright and a rebuilt server would otherwise
be unreachable for ever.
--> -->
<StackPanel Spacing="6" Margin="0,14,0,0" IsVisible="{Binding SelectedItemIsPin}"> <TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap" Margin="0,4,0,0"
<TextBlock Classes="hint" FontSize="9.5" Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
<Button Classes="danger" Content="FORGET THIS HOST KEY" HorizontalAlignment="Left" <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
Command="{Binding ForgetPinCommand}" <Button Classes="accent" Content="GENERATE" Command="{Binding GenerateKeyCommand}"
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." /> IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGenerateKeyCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -273,7 +350,7 @@
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True" <TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" /> Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5" <TextBlock Classes="hint" FontSize="9.5"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." /> Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" /> <Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" /> <Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
@@ -307,6 +384,43 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<!-- The bucket editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
<!--
Masked, like a password and for the same reason: a secret access key is one. The access key id
beside it is an identifier and is shown, which is also why the two are separate boxes.
-->
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
PasswordChar="•" />
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
<!--
Blank means Amazon, and then the region resolves the host. Anything else is a full URL, which
is what makes this work against a self-hosted service.
-->
<TextBox Text="{Binding BucketEditorEndpoint}"
PlaceholderText="endpoint (blank: Amazon S3)" />
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}"
Content="Address the bucket as a path" />
<!--
Said where the decision is made. Getting this wrong produces a DNS failure whose message
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
-->
<TextBlock Classes="hint" FontSize="9.5"
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelObjectStoreEditCommand}" />
</StackPanel>
</StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Border> </Border>
+35 -1
View File
@@ -349,6 +349,21 @@
"dodossh.client.domain": { "dodossh.client.domain": {
"type": "Project" "type": "Project"
}, },
"dodossh.client.import": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Domain": "[1.0.0, )"
}
},
"dodossh.client.objectstore": {
"type": "Project",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, )",
"AWSSDK.S3": "[4.0.101.6, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.session": { "dodossh.client.session": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
@@ -357,7 +372,8 @@
"DodoSSH.Client.Domain": "[1.0.0, )", "DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )", "DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )" "DodoSSH.Client.Sync": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )"
} }
}, },
"dodossh.client.shell": { "dodossh.client.shell": {
@@ -365,6 +381,8 @@
"dependencies": { "dependencies": {
"Avalonia": "[12.1.1, )", "Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )", "CommunityToolkit.Mvvm": "[8.4.2, )",
"DodoSSH.Client.Import": "[1.0.0, )",
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
"DodoSSH.Client.Session": "[1.0.0, )", "DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )", "DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )", "DodoSSH.Client.Terminal": "[1.0.0, )",
@@ -374,6 +392,7 @@
"dodossh.client.ssh": { "dodossh.client.ssh": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )" "SSH.NET": "[2025.1.0, )"
} }
}, },
@@ -417,6 +436,21 @@
"NSec.Cryptography": "[26.4.0, )" "NSec.Cryptography": "[26.4.0, )"
} }
}, },
"AWSSDK.Core": {
"type": "CentralTransitive",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "CentralTransitive",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
}
},
"BouncyCastle.Cryptography": { "BouncyCastle.Cryptography": {
"type": "CentralTransitive", "type": "CentralTransitive",
"requested": "[2.6.2, )", "requested": "[2.6.2, )",
@@ -0,0 +1,112 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>What was done to an item.</summary>
public enum ActivityOperation
{
/// <summary>It was created.</summary>
Created = 0,
/// <summary>It was changed.</summary>
Updated = 1,
/// <summary>It was deleted.</summary>
Deleted = 2,
}
/// <summary>
/// One create, edit or delete of a keychain item, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b><see cref="ChangedFields"/> holds names and never values.</b> That is the rule the whole type is built
/// around, and it is the same one ADR 0006 imposes on the server's own <c>detail</c> column: an audit log
/// that recorded what a password used to be would be a plaintext credential store with a vault drawn around
/// it. "Password" is what somebody needs to see; the old password is what nobody does.
/// </para>
/// <para>
/// <b><see cref="ItemLabel"/> is a copy, taken at the time.</b> Deleting the item is one of the three things
/// this records, so a lookup would resolve to nothing in exactly the case the entry matters most. It is also
/// what makes a rename readable — an entry saying "renamed 'old-db'" is useful, and one saying "renamed
/// 'prod-db'" because that is what it is called now is not.
/// </para>
/// </remarks>
public sealed record ActivityLogSecret : IVaultSecret
{
/// <summary>What kind of item this was about, as the sync contract names it.</summary>
/// <remarks>
/// Stored as the wire enum's name rather than its number, so an entry written by a build that knows a
/// kind this one does not still reads as something — an unknown name is shown as itself, where an
/// unknown number would have to be shown as a number.
/// </remarks>
public required string ItemKind { get; init; }
/// <summary>The item, so an entry can be traced to what it was about.</summary>
public required Guid ItemId { get; init; }
/// <summary>What the item was called at the time.</summary>
public required string ItemLabel { get; init; }
/// <summary>What was done.</summary>
public ActivityOperation Operation { get; init; }
/// <summary>
/// The names of the fields that changed, separated by <c>", "</c>. Never their values.
/// </summary>
/// <remarks>
/// <para>
/// One string rather than a collection, and the choice is about equality. A plain
/// <see cref="IReadOnlyList{T}"/> on a record gets reference equality from the compiler-generated
/// <c>Equals</c>, which is the trap <see cref="JumpChain"/> exists to avoid — and a second type of that
/// shape is a lot of machinery for a value that is written once and only ever displayed. The separator is
/// unambiguous because these are C# property names, which cannot contain one.
/// </para>
/// <para>
/// Empty for a create and for a delete, where "which fields" has no meaning — every field arrived, or all
/// of them went. Empty is also the honest answer when an update's before and after could not be compared,
/// which is why nothing reading this may take empty to mean "nothing changed".
/// </para>
/// </remarks>
public string ChangedFields { get; init; } = string.Empty;
/// <summary>When it happened.</summary>
public required DateTimeOffset At { get; init; }
/// <summary>Which machine it was done from, as that machine calls itself.</summary>
public required string DeviceName { get; init; }
/// <summary>Which account in this organisation did it.</summary>
public Guid ActorUserId { get; init; }
/// <summary>What this entry is called, derived from what it records.</summary>
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
public string Label => $"{Operation} {ItemLabel}";
/// <summary>Whether this is storable, and why not if it is not.</summary>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(ItemKind))
{
reason = "An activity log entry needs the kind of item it was about.";
return false;
}
if (ItemId == Guid.Empty)
{
reason = "An activity log entry needs the item it was about.";
return false;
}
if (string.IsNullOrWhiteSpace(DeviceName))
{
reason = "An activity log entry needs the machine it was done from.";
return false;
}
// The label is deliberately not checked. An item somebody created and never named has an empty one,
// and refusing to record that would mean the log's completeness depended on the user's tidiness.
reason = null;
return true;
}
}
@@ -0,0 +1,137 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded activity log payload, together with the schema version it was written at.</summary>
/// <param name="Entry">The entry.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record ActivityLogSecretDocument(ActivityLogSecret Entry, int SchemaVersion)
{
/// <inheritdoc cref="ConnectionLogSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > ActivityLogSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside an activity log entry's encrypted payload.
/// </summary>
/// <remarks>
/// <see cref="ActivityLogSecret.ItemKind"/> travels as its name and not its number, which is the one thing
/// here worth deciding on purpose: item kinds are an open set, so a build that has not heard of the fifth one
/// can still show "PortForward" where a number would leave it showing "9".
/// </remarks>
public static class ActivityLogSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
public static byte[] Encode(ActivityLogSecret entry)
{
ArgumentNullException.ThrowIfNull(entry);
if (!entry.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(entry));
}
var document = new ActivityLogPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
ItemKind = entry.ItemKind,
ItemId = entry.ItemId,
ItemLabel = entry.ItemLabel,
Operation = (int)entry.Operation,
ChangedFields = entry.ChangedFields.Length == 0 ? null : entry.ChangedFields,
At = entry.At,
DeviceName = entry.DeviceName,
ActorUserId = entry.ActorUserId,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out ActivityLogSecretDocument? document)
{
document = null;
ActivityLogPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new ActivityLogSecret
{
ItemKind = parsed.ItemKind ?? string.Empty,
ItemId = parsed.ItemId,
ItemLabel = parsed.ItemLabel ?? string.Empty,
Operation = Enum.IsDefined((ActivityOperation)parsed.Operation)
? (ActivityOperation)parsed.Operation
: default,
ChangedFields = parsed.ChangedFields ?? string.Empty,
At = parsed.At,
DeviceName = parsed.DeviceName ?? string.Empty,
ActorUserId = parsed.ActorUserId,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new ActivityLogSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class ActivityLogPayloadDocument
{
public int SchemaVersion { get; set; }
public string? ItemKind { get; set; }
public Guid ItemId { get; set; }
public string? ItemLabel { get; set; }
public int Operation { get; set; }
/// <remarks>
/// Written as null when empty rather than as <c>""</c>, so that a create and a delete — which have no
/// changed fields by definition — omit the property entirely instead of carrying an empty one.
/// </remarks>
public string? ChangedFields { get; set; }
public DateTimeOffset At { get; set; }
public string? DeviceName { get; set; }
public Guid ActorUserId { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ActivityLogPayloadDocument))]
internal sealed partial class ActivityLogPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,147 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace DodoSSH.Client.Domain;
/// <summary>How a connection ended.</summary>
public enum ConnectionOutcome
{
/// <summary>The session ran and then ended — by the user, by the remote, or by the process closing.</summary>
/// <remarks>
/// One value for all three, deliberately. From an auditor's side "this person had a shell on that machine
/// for eleven minutes" is the fact; which of the two ends hung up first is not something this client can
/// establish reliably — a tab close and a remote hangup both arrive as the pump finishing — and a field
/// that guessed would be worse than one that does not claim to know.
/// </remarks>
Closed = 0,
/// <summary>The connection was attempted and did not open.</summary>
Failed = 1,
/// <summary>The host key was not the pinned one, so the client refused before authenticating.</summary>
/// <remarks>
/// Its own outcome rather than a kind of <see cref="Failed"/>, because it is the only one that means
/// something about the <em>host</em> rather than about the network or the credentials. A run of these on
/// one machine is the single most interesting thing a connection log can show.
/// </remarks>
Refused = 2,
}
/// <summary>What kind of session a log entry is about.</summary>
public enum ConnectionKind
{
/// <summary>An interactive terminal.</summary>
Terminal = 0,
/// <summary>An SFTP session for moving files.</summary>
/// <remarks>
/// Recorded separately and not hidden. Opening the file browser is a second login as far as the remote's
/// own <c>auth.log</c> is concerned, so a log of ours that quietly omitted it would disagree with the
/// host's — and the person comparing the two would be right to trust the host.
/// </remarks>
Sftp = 1,
}
/// <summary>
/// One connection that was made, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b>What is here, and what deliberately is not.</b> The host's label, its item id, the address as dialled,
/// when it started, how long it lasted, how it ended, and which user on which device did it. An audit log
/// with no actor is not an audit log — the whole reason these sync is that an administrator will read them
/// once teams land — so the actor is recorded and the SSH username is not. The two are different questions:
/// "who in this organisation opened a shell" is what an audit answers, and "which account they logged in as"
/// is a detail of the host that the host's own logs already have.
/// </para>
/// <para>
/// <b>Written once, at close.</b> Every field is known by then, so an entry never needs a second write —
/// which is what keeps a synced log from needing a merge, an outbox row per update, or any way to collide
/// with itself. A connection that is still running is not in here at all; it is shown from the workspace's
/// live state, which is the only place that knows.
/// </para>
/// </remarks>
public sealed record ConnectionLogSecret : IVaultSecret
{
/// <summary>What the host was called at the time, or a plain address when nothing named it.</summary>
/// <remarks>
/// A copy rather than a lookup through <see cref="HostId"/>, and that is the point of it: the bookmark
/// can be renamed or deleted, and a history that changed retroactively when somebody tidied their
/// keychain would be a history nobody could rely on.
/// </remarks>
public required string HostLabel { get; init; }
/// <summary>The address as dialled, <c>user@host:port</c> style, or whatever was typed.</summary>
public required string Address { get; init; }
/// <summary>The host item this was, or null when the connection did not come from one.</summary>
public Guid? HostId { get; init; }
/// <summary>Whether this was a terminal or a file-transfer session.</summary>
public ConnectionKind Kind { get; init; }
/// <summary>When it started.</summary>
public required DateTimeOffset StartedAt { get; init; }
/// <summary>How long it lasted.</summary>
/// <remarks>
/// A duration rather than an end time, because it is the thing anybody reads — and because the two clocks
/// involved are the same one, so storing both would be storing a value and its own arithmetic.
/// </remarks>
public TimeSpan Duration { get; init; }
/// <summary>How it ended.</summary>
public ConnectionOutcome Outcome { get; init; }
/// <summary>Which machine it was made from, as that machine calls itself.</summary>
public required string DeviceName { get; init; }
/// <summary>Which account in this organisation made it.</summary>
public Guid ActorUserId { get; init; }
/// <summary>What this entry is called, derived from what it records.</summary>
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
public string Label => string.Create(CultureInfo.InvariantCulture, $"{HostLabel} ({Address})");
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// A negative duration is refused rather than clamped. It can only come from a payload written elsewhere
/// — nothing here can produce one — and a log that displayed "-3 hours" would leave a reader unable to
/// tell a corrupt entry from a clock they should worry about.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(HostLabel))
{
reason = "A connection log entry needs the host it was about.";
return false;
}
if (string.IsNullOrWhiteSpace(Address))
{
reason = "A connection log entry needs the address that was dialled.";
return false;
}
if (string.IsNullOrWhiteSpace(DeviceName))
{
reason = "A connection log entry needs the machine it was made from.";
return false;
}
if (Duration < TimeSpan.Zero)
{
reason = "A connection cannot have lasted a negative amount of time.";
return false;
}
if (HostId == Guid.Empty)
{
reason = "A host reference cannot be an empty id; use no host instead.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,153 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded connection log payload, together with the schema version it was written at.</summary>
/// <param name="Entry">The entry.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record ConnectionLogSecretDocument(ConnectionLogSecret Entry, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
/// <remarks>
/// Answered for consistency and never acted on: nothing edits a log entry, so there is no re-encode that
/// could drop a newer client's field. It stays because the reconciler asks every kind.
/// </remarks>
public bool IsReadOnly => SchemaVersion > ConnectionLogSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a connection log entry's encrypted payload.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="KnownHostSecretCodec"/>. The two enums are written as numbers rather than names,
/// unlike <see cref="ActivityLogSecret.ItemKind"/>: they are closed sets this codec owns, where the item kind
/// is an open one that a newer build may extend.
/// </para>
/// <para>
/// An unknown enum value decodes to the default rather than failing the whole entry. A log written by a
/// newer client that has learned a fourth outcome is still worth showing with its host, its times and its
/// actor intact — refusing it would lose the entry to save the one field nobody could have acted on anyway.
/// </para>
/// </remarks>
public static class ConnectionLogSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
public static byte[] Encode(ConnectionLogSecret entry)
{
ArgumentNullException.ThrowIfNull(entry);
if (!entry.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(entry));
}
var document = new ConnectionLogPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
HostLabel = entry.HostLabel,
Address = entry.Address,
HostId = entry.HostId,
Kind = (int)entry.Kind,
StartedAt = entry.StartedAt,
DurationMs = (long)entry.Duration.TotalMilliseconds,
Outcome = (int)entry.Outcome,
DeviceName = entry.DeviceName,
ActorUserId = entry.ActorUserId,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out ConnectionLogSecretDocument? document)
{
document = null;
ConnectionLogPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new ConnectionLogSecret
{
HostLabel = parsed.HostLabel ?? string.Empty,
Address = parsed.Address ?? string.Empty,
HostId = parsed.HostId,
Kind = Enum.IsDefined((ConnectionKind)parsed.Kind) ? (ConnectionKind)parsed.Kind : default,
StartedAt = parsed.StartedAt,
Duration = TimeSpan.FromMilliseconds(parsed.DurationMs),
Outcome = Enum.IsDefined((ConnectionOutcome)parsed.Outcome)
? (ConnectionOutcome)parsed.Outcome
: default,
DeviceName = parsed.DeviceName ?? string.Empty,
ActorUserId = parsed.ActorUserId,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new ConnectionLogSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class ConnectionLogPayloadDocument
{
public int SchemaVersion { get; set; }
public string? HostLabel { get; set; }
public string? Address { get; set; }
public Guid? HostId { get; set; }
public int Kind { get; set; }
public DateTimeOffset StartedAt { get; set; }
/// <remarks>
/// Milliseconds as an integer rather than a <see cref="TimeSpan"/>, which <c>System.Text.Json</c> writes
/// as <c>"00:11:03.4560000"</c> — a format whose parsing varies between platforms and whose precision
/// invites a round-trip that is nearly but not exactly the value written.
/// </remarks>
public long DurationMs { get; set; }
public int Outcome { get; set; }
public string? DeviceName { get; set; }
public Guid ActorUserId { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ConnectionLogPayloadDocument))]
internal sealed partial class ConnectionLogPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,48 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A folder hosts can be filed under, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
/// </para>
/// <para>
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> on each host, so filing two
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
/// writes to one item, and <see cref="ThreeWayMerge"/> has no set merge — the collision would resolve by one
/// side winning outright and the other host silently leaving the group it was just put in.
/// </para>
/// <para>
/// <b>No parent.</b> Groups are flat. Two clients can each re-parent A under B and B under A while offline,
/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
/// </para>
/// </remarks>
public sealed record HostGroupSecret : IVaultSecret
{
/// <summary>What the group is called. The only name it has anywhere.</summary>
public required string Label { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
/// cannot tell apart.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A group needs a name.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,101 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded group payload, together with the schema version it was written at.</summary>
/// <param name="Group">The group.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > HostGroupSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a group item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field
/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
/// version, which is what lets a later build add a field without every older client silently dropping it on
/// the next edit. See <see cref="HostSecretDocument.IsReadOnly"/>.
/// </remarks>
public static class HostGroupSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a group to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The group is not valid for storage.</exception>
public static byte[] Encode(HostGroupSecret group)
{
ArgumentNullException.ThrowIfNull(group);
if (!group.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(group));
}
var document = new HostGroupPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = group.Label,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out HostGroupSecretDocument? document)
{
document = null;
HostGroupPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty };
if (!candidate.TryValidate(out _))
{
return false;
}
document = new HostGroupSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class HostGroupPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(HostGroupPayloadDocument))]
internal sealed partial class HostGroupPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,63 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged group, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The group to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record HostGroupMergeResult(
HostGroupSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a group against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it
/// does <em>not</em> have to consider. Filing a host into a group does not write to the group, so two people
/// organising the same vault at the same time never collide here — the only way to reach this code is for two
/// people to rename the same group differently, which is a real disagreement and gets a conflict notice.
/// </para>
/// <para>
/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name
/// differed" would leave the user unable to tell which of their two names survived.
/// </para>
/// </remarks>
public static class HostGroupSecretMerge
{
/// <summary>Produces the merged group.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static HostGroupMergeResult Merge(
HostGroupSecret ancestor,
HostGroupSecret local,
HostGroupSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merge = ThreeWayMerge.Scalar(
ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
if (merge.IsConflicted)
{
// The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
// fixed here rather than derived from the outcome.
conflicts.Add(new HostFieldConflict(
nameof(HostGroupSecret.Label),
MergeSide.Local,
merge.Value,
merge.Discarded,
DiscardedWasRemoval: false));
}
return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts);
}
}
+32
View File
@@ -101,6 +101,32 @@ public sealed record HostSecret : IVaultSecret
/// </remarks> /// </remarks>
public Guid? CredentialId { get; init; } public Guid? CredentialId { get; init; }
/// <summary>
/// The group this host is filed under, or null for none.
/// </summary>
/// <remarks>
/// <para>
/// The pointer lives on the host rather than a member list living on the group, and the reason is the
/// merge: filing two different hosts into one group on two machines has to be two writes to two items.
/// Held the other way round it would be two writes to one item, and with no set merge available the
/// collision would resolve by one side winning and the other host quietly leaving the group.
/// </para>
/// <para>
/// <b>Inside the payload, and it did not have to be.</b> <c>SyncPlaintextFields</c> has carried a
/// <c>GroupId</c> since the contract was frozen and the server had a column for it. Nothing ever wrote
/// one, the column is gone, and the server now refuses the field — because what it would hand over is a
/// clustering of the estate, and the one plaintext concession the design allows itself is the relay
/// address, which the relay genuinely cannot work without. This is not that. See ADR 0004.
/// </para>
/// <para>
/// <b>The reference may dangle</b>, exactly as <see cref="SshKeyId"/> may: a group deleted on another
/// machine leaves this pointing at nothing. That is handled where it is noticed — the host appears under
/// the ungrouped heading — rather than prevented here, because preventing it would mean one group delete
/// rewriting every host that named it.
/// </para>
/// </remarks>
public Guid? GroupId { get; init; }
/// <summary> /// <summary>
/// Whether this host may be dialled through the server relay. /// Whether this host may be dialled through the server relay.
/// </summary> /// </summary>
@@ -174,6 +200,12 @@ public sealed record HostSecret : IVaultSecret
return false; return false;
} }
if (GroupId == Guid.Empty)
{
reason = "A group reference cannot be an empty id; use no group instead.";
return false;
}
reason = null; reason = null;
return true; return true;
} }
+40 -10
View File
@@ -62,8 +62,11 @@ public static class HostSecretCodec
/// <summary>The version that introduced <see cref="HostSecret.CredentialId"/>.</summary> /// <summary>The version that introduced <see cref="HostSecret.CredentialId"/>.</summary>
public const int CredentialIdSchemaVersion = 3; public const int CredentialIdSchemaVersion = 3;
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
public const int GroupIdSchemaVersion = 4;
/// <summary>The highest schema version this build can write.</summary> /// <summary>The highest schema version this build can write.</summary>
public const int CurrentSchemaVersion = CredentialIdSchemaVersion; public const int CurrentSchemaVersion = GroupIdSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary> /// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception> /// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -95,6 +98,7 @@ public static class HostSecretCodec
RelayEnabled = host.RelayEnabled, RelayEnabled = host.RelayEnabled,
SshKeyId = host.SshKeyId, SshKeyId = host.SshKeyId,
CredentialId = host.CredentialId, CredentialId = host.CredentialId,
GroupId = host.GroupId,
}; };
return JsonSerializer.SerializeToUtf8Bytes( return JsonSerializer.SerializeToUtf8Bytes(
@@ -120,18 +124,40 @@ public static class HostSecretCodec
/// did not make every host in every vault look like a change to the sync engine. /// did not make every host in every vault look like a change to the sync engine.
/// </para> /// </para>
/// <para> /// <para>
/// The two bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so this reads as /// <b>A maximum, not a ladder, and the difference arrived with <see cref="HostSecret.GroupId"/>.</b> The
/// a ladder rather than a maximum. If a future field is <em>not</em> exclusive with an older one, this /// two authentication bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so
/// becomes the maximum over the versions of the fields present, which is the same rule stated more /// while they were the only versioned fields, a <c>switch</c> that returned the first match was
/// generally. /// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
/// a credential <em>and</em> a group, and the ladder would have answered 3 for it, writing a version that
/// cannot represent the group it just wrote. An older client would then decode that host as editable and
/// drop the field on the next save.
/// </para>
/// <para>
/// Written as a maximum over the fields actually present, which is the general form of the same rule and
/// stays correct however the next field relates to these.
/// </para> /// </para>
/// </remarks> /// </remarks>
private static int SchemaVersionFor(HostSecret host) => host switch private static int SchemaVersionFor(HostSecret host)
{ {
{ CredentialId: not null } => CredentialIdSchemaVersion, var version = BaseSchemaVersion;
{ SshKeyId: not null } => SshKeyIdSchemaVersion,
_ => BaseSchemaVersion, if (host.SshKeyId is not null)
}; {
version = Math.Max(version, SshKeyIdSchemaVersion);
}
if (host.CredentialId is not null)
{
version = Math.Max(version, CredentialIdSchemaVersion);
}
if (host.GroupId is not null)
{
version = Math.Max(version, GroupIdSchemaVersion);
}
return version;
}
/// <summary> /// <summary>
/// Parses a decrypted payload. /// Parses a decrypted payload.
@@ -199,6 +225,7 @@ public static class HostSecretCodec
RelayEnabled = parsed.RelayEnabled, RelayEnabled = parsed.RelayEnabled,
SshKeyId = parsed.SshKeyId, SshKeyId = parsed.SshKeyId,
CredentialId = parsed.CredentialId, CredentialId = parsed.CredentialId,
GroupId = parsed.GroupId,
}; };
if (!candidate.TryValidate(out _)) if (!candidate.TryValidate(out _))
@@ -254,6 +281,9 @@ internal sealed class HostPayloadDocument
/// <inheritdoc cref="SshKeyId" /> /// <inheritdoc cref="SshKeyId" />
public Guid? CredentialId { get; set; } public Guid? CredentialId { get; set; }
/// <inheritdoc cref="SshKeyId" />
public Guid? GroupId { get; set; }
} }
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
+36 -6
View File
@@ -103,10 +103,35 @@ public static class HostSecretMerge
remote.RelayEnabled, remote.RelayEnabled,
conflicts, conflicts,
static enabled => enabled ? "enabled" : "disabled"), static enabled => enabled ? "enabled" : "disabled"),
};
// The id is shown in a clash rather than redacted. It is not a secret — it names a vault item, return new HostMergeResult(
// it is not the key — and hiding it would leave the user unable to tell which of two keys the WithReferences(merged, ancestor, local, remote, conflicts), conflicts);
// merge dropped. }
/// <summary>
/// Merges the three ids a host can point at: its key, its credential and its group.
/// </summary>
/// <remarks>
/// <para>
/// Split out for length, and they do belong together: each is a reference to another vault item, each
/// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on
/// another machine. None of that is the merge's problem — it is handled where the reference is used.
/// </para>
/// <para>
/// <b>The ids are shown in a clash rather than redacted.</b> An id is not a secret — it names a vault
/// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the
/// merge dropped.
/// </para>
/// </remarks>
private static HostSecret WithReferences(
HostSecret merged,
HostSecret ancestor,
HostSecret local,
HostSecret remote,
List<HostFieldConflict> conflicts) =>
merged with
{
SshKeyId = Field( SshKeyId = Field(
nameof(HostSecret.SshKeyId), nameof(HostSecret.SshKeyId),
ancestor.SshKeyId, ancestor.SshKeyId,
@@ -122,10 +147,15 @@ public static class HostSecretMerge
remote.CredentialId, remote.CredentialId,
conflicts, conflicts,
static id => id?.ToString() ?? "no credential"), static id => id?.ToString() ?? "no credential"),
};
return new HostMergeResult(merged, conflicts); GroupId = Field(
} nameof(HostSecret.GroupId),
ancestor.GroupId,
local.GroupId,
remote.GroupId,
conflicts,
static id => id?.ToString() ?? "ungrouped"),
};
private static string Text( private static string Text(
string name, string name,
+112
View File
@@ -0,0 +1,112 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged entry, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The entry to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record ConnectionLogMergeResult(
ConnectionLogSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <inheritdoc cref="ConnectionLogMergeResult" />
public sealed record ActivityLogMergeResult(
ActivityLogSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a connection log entry.
/// </summary>
/// <remarks>
/// <para>
/// <b>This exists because the item-kind pipeline requires it, and it should never run.</b> A log entry is
/// written once, at the moment a connection closes, and nothing updates one — so there is no second version
/// for a first to diverge from. Reaching this code means two clients wrote different records under one
/// entity id, and entity ids are v7 GUIDs minted independently on each machine.
/// </para>
/// <para>
/// It is still a real merge rather than a throw. The reconciler runs inside a sync pass, and an exception
/// there would strand every item queued behind this one — for a situation that is a bug in some client and
/// not an emergency. So the remote side wins, the difference is recorded like any other, and somebody reads
/// a conflict notice about a log entry, which is the loudest signal this could reasonably give.
/// </para>
/// <para>
/// Nothing is redacted. Every field is already an audit record of something that happened, and a notice that
/// hid which of two records was dropped would defeat the point of noticing.
/// </para>
/// </remarks>
public static class ConnectionLogSecretMerge
{
/// <summary>Produces the merged entry.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static ConnectionLogMergeResult Merge(
ConnectionLogSecret ancestor,
ConnectionLogSecret local,
ConnectionLogSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
// Whole-value, not field by field. The fields of one entry describe one event, and a merge that took
// the host from one side and the duration from the other would invent a connection nobody made —
// which is a worse outcome than losing the record this machine happened to hold.
if (local == remote)
{
return new ConnectionLogMergeResult(remote, []);
}
return new ConnectionLogMergeResult(
remote,
[
new HostFieldConflict(
"Entry",
MergeSide.Local,
remote.Label,
local.Label,
DiscardedWasRemoval: false),
]);
}
}
/// <summary>
/// Merges two divergent versions of an activity log entry.
/// </summary>
/// <inheritdoc cref="ConnectionLogSecretMerge" path="/remarks" />
public static class ActivityLogSecretMerge
{
/// <inheritdoc cref="ConnectionLogSecretMerge.Merge" />
public static ActivityLogMergeResult Merge(
ActivityLogSecret ancestor,
ActivityLogSecret local,
ActivityLogSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
if (local == remote)
{
return new ActivityLogMergeResult(remote, []);
}
return new ActivityLogMergeResult(
remote,
[
new HostFieldConflict(
"Entry",
MergeSide.Local,
remote.Label,
local.Label,
DiscardedWasRemoval: false),
]);
}
}
@@ -0,0 +1,120 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// An S3-compatible bucket and the credentials that reach it, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// Named for the protocol rather than for Amazon, because everything here works the same against MinIO, R2,
/// Backblaze or Ceph — and for those, <see cref="Endpoint"/> is an address on somebody's own network. The
/// interface says S3, which is what people call the protocol; the type says what it is.
/// </para>
/// <para>
/// <b><see cref="SecretAccessKey"/> is a password, and everything this codebase does about passwords applies
/// to it.</b> It is inside the encrypted payload, it never appears in a log line — the activity log records
/// the field's name and not its value — and the merge reports that it differed rather than what it was.
/// </para>
/// </remarks>
public sealed record ObjectStoreSecret : IVaultSecret
{
/// <summary>What the user calls this bucket. The only name it has anywhere.</summary>
public required string Label { get; init; }
/// <summary>The bucket.</summary>
public required string Bucket { get; init; }
/// <summary>The access key id.</summary>
public required string AccessKeyId { get; init; }
/// <summary>The secret access key.</summary>
public required string SecretAccessKey { get; init; }
/// <summary>
/// The region, or null to let the endpoint decide.
/// </summary>
/// <remarks>
/// Required by AWS and ignored by several S3-compatible services, which is why it is nullable rather than
/// defaulted to <c>us-east-1</c>. A default would be a guess presented as configuration, and the guess is
/// wrong for exactly the self-hosted case this field exists to support.
/// </remarks>
public string? Region { get; init; }
/// <summary>
/// The service endpoint, or null for Amazon's own.
/// </summary>
/// <remarks>
/// Null means AWS and the SDK resolves the host from <see cref="Region"/>. Anything else is a URL, and it
/// is the field that makes this work against a MinIO in a cupboard.
/// </remarks>
public string? Endpoint { get; init; }
/// <summary>
/// Whether to address the bucket as a path rather than as a subdomain.
/// </summary>
/// <remarks>
/// <c>https://endpoint/bucket/key</c> instead of <c>https://bucket.endpoint/key</c>. Off for AWS, on for
/// nearly every self-hosted service — MinIO in its default configuration has no wildcard DNS, so
/// virtual-host addressing simply does not resolve. It is a setting rather than a guess because getting
/// it wrong produces a name-resolution failure that says nothing about buckets.
/// </remarks>
public bool UsePathStyle { get; init; }
/// <summary>Free-text notes.</summary>
public string? Notes { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// The endpoint is checked for being a well-formed absolute URL when it is set at all. A relative one, or
/// a bare hostname, produces an SDK failure at the first request whose message names neither the field
/// nor this bucket — and the person reading it has typically just typed the value.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A bucket needs a name.";
return false;
}
if (string.IsNullOrWhiteSpace(Bucket))
{
reason = "A bucket needs the bucket it points at.";
return false;
}
if (string.IsNullOrWhiteSpace(AccessKeyId) || string.IsNullOrWhiteSpace(SecretAccessKey))
{
reason = "A bucket needs an access key id and a secret access key.";
return false;
}
if (Endpoint is not null)
{
if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpoint))
{
reason = "The endpoint has to be a full URL, like https://minio.internal:9000.";
return false;
}
if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
&& !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
{
reason = "The endpoint has to be http or https.";
return false;
}
}
if (Region is null && Endpoint is null)
{
// With neither, the SDK has nothing to resolve a host from and fails at the first request with
// a message about a missing region rather than about this bucket.
reason = "A bucket needs a region, an endpoint, or both.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,129 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded bucket payload, together with the schema version it was written at.</summary>
/// <param name="Store">The bucket.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record ObjectStoreSecretDocument(ObjectStoreSecret Store, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > ObjectStoreSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a bucket item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="CredentialSecretCodec"/>, for the same reasons and with the same guarantees.
/// </remarks>
public static class ObjectStoreSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a bucket to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The bucket is not valid for storage.</exception>
public static byte[] Encode(ObjectStoreSecret store)
{
ArgumentNullException.ThrowIfNull(store);
if (!store.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(store));
}
var document = new ObjectStorePayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = store.Label,
Bucket = store.Bucket,
AccessKeyId = store.AccessKeyId,
SecretAccessKey = store.SecretAccessKey,
Region = store.Region,
Endpoint = store.Endpoint,
UsePathStyle = store.UsePathStyle,
Notes = store.Notes,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out ObjectStoreSecretDocument? document)
{
document = null;
ObjectStorePayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new ObjectStoreSecret
{
Label = parsed.Label ?? string.Empty,
Bucket = parsed.Bucket ?? string.Empty,
AccessKeyId = parsed.AccessKeyId ?? string.Empty,
SecretAccessKey = parsed.SecretAccessKey ?? string.Empty,
Region = parsed.Region,
Endpoint = parsed.Endpoint,
UsePathStyle = parsed.UsePathStyle,
Notes = parsed.Notes,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new ObjectStoreSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class ObjectStorePayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? Bucket { get; set; }
public string? AccessKeyId { get; set; }
public string? SecretAccessKey { get; set; }
public string? Region { get; set; }
public string? Endpoint { get; set; }
public bool UsePathStyle { get; set; }
public string? Notes { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ObjectStorePayloadDocument))]
internal sealed partial class ObjectStorePayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,107 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged bucket, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The bucket to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record ObjectStoreMergeResult(
ObjectStoreSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a bucket against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Every field is a scalar, so this is <see cref="CredentialSecretMerge"/>'s shape and it reuses
/// <see cref="HostFieldConflict"/> for the same reason.
/// </para>
/// <para>
/// <b>The secret access key never reaches the conflict log</b>, exactly as a password does not: a discarded
/// one is very often still live on the service it belongs to. The access key <em>id</em> is shown, because it
/// is an identifier rather than a secret and knowing which of two key pairs the merge dropped is the whole
/// content of the notice.
/// </para>
/// </remarks>
public static class ObjectStoreSecretMerge
{
/// <summary>Produces the merged bucket.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static ObjectStoreMergeResult Merge(
ObjectStoreSecret ancestor,
ObjectStoreSecret local,
ObjectStoreSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merged = new ObjectStoreSecret
{
// Null-forgiving on the required fields, as the neighbouring merges do: the merge returns one of
// its three inputs, and all three are non-null by construction.
Label = Resolve(
nameof(ObjectStoreSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
Bucket = Resolve(
nameof(ObjectStoreSecret.Bucket), ancestor.Bucket, local.Bucket, remote.Bucket, conflicts)!,
AccessKeyId = Resolve(
nameof(ObjectStoreSecret.AccessKeyId),
ancestor.AccessKeyId,
local.AccessKeyId,
remote.AccessKeyId,
conflicts)!,
SecretAccessKey = Resolve(
nameof(ObjectStoreSecret.SecretAccessKey),
ancestor.SecretAccessKey,
local.SecretAccessKey,
remote.SecretAccessKey,
conflicts,
redact: true)!,
Region = Resolve(
nameof(ObjectStoreSecret.Region), ancestor.Region, local.Region, remote.Region, conflicts),
Endpoint = Resolve(
nameof(ObjectStoreSecret.Endpoint),
ancestor.Endpoint,
local.Endpoint,
remote.Endpoint,
conflicts),
UsePathStyle = ThreeWayMerge
.Scalar(ancestor.UsePathStyle, local.UsePathStyle, remote.UsePathStyle)
.Value,
Notes = Resolve(
nameof(ObjectStoreSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
};
return new ObjectStoreMergeResult(merged, conflicts);
}
private static string? Resolve(
string name,
string? ancestor,
string? local,
string? remote,
List<HostFieldConflict> conflicts,
bool redact = false)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
if (merge.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
redact ? "(kept the server's value)" : merge.Value ?? "(none)",
redact ? "(a different value was discarded)" : merge.Discarded ?? "(none)",
DiscardedWasRemoval: false));
}
return merge.Value;
}
}
@@ -0,0 +1,76 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A saved command, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b><see cref="RunsOnInsert"/> is the field this type exists to get right.</b> A terminal is one input
/// stream with no notion of "at a prompt": the remote may be inside <c>vi</c>, or at a <c>sudo</c> password
/// prompt with echo off, and without shell integration the client cannot tell. So inserting a snippet is
/// always "type this into whatever is there", never "run this command" — and whether a newline follows the
/// text is the difference between the user reading what appeared and deciding, and something happening.
/// It defaults to <see langword="false"/>, which makes that decision the user's Enter key.
/// </para>
/// <para>
/// <b><see cref="Command"/> is stored verbatim.</b> No trimming, no newline normalisation — the same rule
/// <see cref="SshKeySecret.PrivateKeyPem"/> follows, for a related reason: a heredoc's trailing newline is
/// load-bearing, and a shell that receives a here-document terminator with the whitespace tidied off it hangs
/// waiting for one that never comes.
/// </para>
/// <para>
/// Deliberately not in this version, each with a reason rather than an omission: <b>host scoping</b>, which
/// needs a set merge that <see cref="ThreeWayMerge"/> does not have; <b>tags</b>, which are their own reserved
/// item kind; and <b>parameter substitution</b>, which would make this a template language expanding into a
/// root shell — a second security surface for a feature whose first one is already the hard part.
/// </para>
/// </remarks>
public sealed record SnippetSecret : IVaultSecret
{
/// <summary>What the snippet is called. The only name it has anywhere.</summary>
public required string Label { get; init; }
/// <summary>The text to insert. May be several lines.</summary>
public required string Command { get; init; }
/// <summary>Free-text notes.</summary>
public string? Notes { get; init; }
/// <summary>
/// Whether inserting this also presses Enter.
/// </summary>
/// <remarks>
/// Off unless the user turns it on, per snippet. A vault-wide preference was the alternative and it is
/// worse: the setting belongs to the command, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c>
/// do not want the same answer, and a single switch would eventually be left on by whoever needed it for
/// the first of those.
/// </remarks>
public bool RunsOnInsert { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
/// <remarks>
/// <see cref="Command"/> is checked for being blank but for nothing else. What makes a valid command is
/// the remote shell's business, this client does not know which shell that is, and a validator guessing
/// at it would refuse the legitimate cases — a bare <c>\x03</c>, a partial line meant to be completed by
/// hand — while catching nothing that matters.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A snippet needs a name.";
return false;
}
if (string.IsNullOrEmpty(Command))
{
reason = "A snippet needs something to insert.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,121 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded snippet payload, together with the schema version it was written at.</summary>
/// <param name="Snippet">The snippet.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record SnippetSecretDocument(SnippetSecret Snippet, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > SnippetSecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a snippet item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="CredentialSecretCodec"/>. The one thing to be careful about here is
/// <see cref="SnippetSecret.RunsOnInsert"/>: it is a <see cref="bool"/>, so a payload that omits it decodes
/// as <see langword="false"/> — which is the safe direction, and deliberately the one a malformed or
/// truncated write falls in.
/// </remarks>
public static class SnippetSecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a snippet to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The snippet is not valid for storage.</exception>
public static byte[] Encode(SnippetSecret snippet)
{
ArgumentNullException.ThrowIfNull(snippet);
if (!snippet.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(snippet));
}
var document = new SnippetPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = snippet.Label,
Command = snippet.Command,
Notes = snippet.Notes,
RunsOnInsert = snippet.RunsOnInsert,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out SnippetSecretDocument? document)
{
document = null;
SnippetPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new SnippetSecret
{
Label = parsed.Label ?? string.Empty,
Command = parsed.Command ?? string.Empty,
Notes = parsed.Notes,
RunsOnInsert = parsed.RunsOnInsert,
};
if (!candidate.TryValidate(out _))
{
return false;
}
document = new SnippetSecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class SnippetPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? Command { get; set; }
public string? Notes { get; set; }
/// <remarks>
/// Not nullable, so its absence is <see langword="false"/> rather than a third state. The field decides
/// whether inserting a snippet also presses Enter, and "we could not tell" has to resolve to the answer
/// that does nothing.
/// </remarks>
public bool RunsOnInsert { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(SnippetPayloadDocument))]
internal sealed partial class SnippetPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,94 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged snippet, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The snippet to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record SnippetMergeResult(
SnippetSecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a snippet against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// Three strings and a flag, so the shape is <see cref="CredentialSecretMerge"/>'s and it reuses
/// <see cref="HostFieldConflict"/> for the same reason. Nothing is redacted: a snippet is a command somebody
/// wrote down on purpose, and a notice that hid the discarded version would leave the user unable to tell
/// whether the one that survived is the one they wanted to keep.
/// </para>
/// <para>
/// <b><see cref="SnippetSecret.RunsOnInsert"/> cannot conflict, and it is worth knowing why rather than
/// assuming it.</b> A three-way clash needs local and remote each to differ from the ancestor <em>and</em>
/// from one another; with only two possible values, the first two conditions force the third to fail. So this
/// field always resolves to whichever side actually changed it, and a merge can never turn a snippet into one
/// that runs on its own — the outcome the ordinary rule would have made possible if the field had a third
/// state. An earlier draft special-cased it to resolve to <see langword="false"/> on a clash; the branch was
/// unreachable, and unreachable safety code is worse than none, because it reads as protection.
/// </para>
/// </remarks>
public static class SnippetSecretMerge
{
/// <summary>Produces the merged snippet.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static SnippetMergeResult Merge(
SnippetSecret ancestor,
SnippetSecret local,
SnippetSecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var merged = new SnippetSecret
{
// Null-forgiving on the two required fields, as the neighbouring merges do for the same reason:
// the merge returns one of its three inputs, and all three are non-null by construction.
Label = Text(
nameof(SnippetSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
Command = Text(
nameof(SnippetSecret.Command),
ancestor.Command,
local.Command,
remote.Command,
conflicts)!,
Notes = Text(
nameof(SnippetSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
RunsOnInsert = ThreeWayMerge
.Scalar(ancestor.RunsOnInsert, local.RunsOnInsert, remote.RunsOnInsert)
.Value,
};
return new SnippetMergeResult(merged, conflicts);
}
private static string? Text(
string name,
string? ancestor,
string? local,
string? remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
if (merge.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
name,
MergeSide.Local,
merge.Value ?? "(none)",
merge.Discarded ?? "(none)",
DiscardedWasRemoval: false));
}
return merge.Value;
}
}
@@ -0,0 +1,57 @@
namespace DodoSSH.Client.Domain;
/// <summary>
/// Reads the moment a version 7 identifier was created back out of it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists.</b> No vault item carries a timestamp. <c>VaultItem</c> is an id, a secret, a version
/// and three sync flags, and the server's <c>created_at</c> is deliberately not handed back — so a screen
/// that wants to say when something was added has nothing to read. Every id this client mints goes through
/// <see cref="Guid.CreateVersion7()"/>, which is banned-symbol policy rather than preference (see
/// <c>BannedSymbols.txt</c>), and RFC 9562 puts 48 bits of Unix milliseconds in the first six bytes of one.
/// That is a real creation time, already stored, costing nothing.
/// </para>
/// <para>
/// <b>What it is not.</b> It is when the item was <em>created</em>, never when it was last changed — an
/// update keeps the id. A screen showing this has to say so, or it is quietly presenting a creation date as
/// a modification date. And an id minted anywhere else, by an older client or another implementation, is not
/// a v7 at all; that case answers null rather than a number derived from bytes that mean something else.
/// </para>
/// </remarks>
public static class Uuid7Timestamp
{
/// <summary>Where the version nibble lives in the RFC byte order.</summary>
private const int VersionByte = 6;
/// <summary>
/// The creation time recorded in a version 7 identifier, or null if it is not one.
/// </summary>
public static DateTimeOffset? Of(Guid id)
{
Span<byte> bytes = stackalloc byte[16];
// Big-endian, which is the whole reason this is not two lines of shifting. Guid's own layout stores
// its first three fields in the host's byte order, so the little-endian overload scrambles exactly
// the six bytes being read here — and does it silently, producing dates in the year 30000 rather
// than an error.
if (!id.TryWriteBytes(bytes, bigEndian: true, out _))
{
return null;
}
if ((bytes[VersionByte] & 0xF0) != 0x70)
{
return null;
}
long milliseconds = 0;
for (var i = 0; i < 6; i++)
{
milliseconds = (milliseconds << 8) | bytes[i];
}
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Reading an OpenSSH client configuration and turning it into hosts this application can store.
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
different concern with different dependencies, and keeping them apart is what lets the whole of the
parsing be tested with no Avalonia, no SQLite and no disk.
-->
<ItemGroup>
<ProjectReference Include="..\DodoSSH.Client.Domain\DodoSSH.Client.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.Import.Tests" />
</ItemGroup>
</Project>
+102
View File
@@ -0,0 +1,102 @@
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Import;
/// <summary>
/// One host an <c>ssh_config</c> describes, resolved and ready to be looked at.
/// </summary>
/// <remarks>
/// Deliberately not a <see cref="HostSecret"/>. This is a candidate somebody has not agreed to import yet,
/// and it carries things a stored host has no field for — the identity file's path, the jump alias by name,
/// and the warnings that go beside a row in the preview.
/// </remarks>
/// <param name="Alias">
/// The name from the <c>Host</c> line, which is what the user types after <c>ssh</c> and so the name they
/// will recognise.
/// </param>
/// <param name="Hostname">
/// What <c>HostName</c> said, or the alias when it said nothing — which is OpenSSH's own default and the
/// reason <c>Host db.internal</c> with no other directive works.
/// </param>
/// <param name="Username">What <c>User</c> said, if anything.</param>
/// <param name="Port">What <c>Port</c> said, defaulting to 22.</param>
/// <param name="IdentityFiles">Every <c>IdentityFile</c> path, in the order they were given.</param>
/// <param name="ProxyJump">The <c>ProxyJump</c> value verbatim, if any.</param>
/// <param name="Options">Everything else, as SSH directives.</param>
/// <param name="Warnings">What could not be represented, per host.</param>
public sealed record ImportedHost(
string Alias,
string Hostname,
string? Username,
int Port,
IReadOnlyList<string> IdentityFiles,
string? ProxyJump,
HostOptions Options,
IReadOnlyList<string> Warnings)
{
/// <summary>The address this would dial, for a preview row.</summary>
public string Address => Username is { Length: > 0 } user
? $"{user}@{Hostname}:{Port}"
: $"{Hostname}:{Port}";
/// <summary>Turns this into the host that would be stored.</summary>
/// <remarks>
/// <para>
/// <b>The identity file becomes a note and a directive, not a key.</b> Reading somebody's
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate,
/// and doing it as a side effect of "import my config" is the wrong default. The path is recorded so it
/// is not lost; importing the material is a separate, per-row choice.
/// </para>
/// <para>
/// <b>ProxyJump records intent and changes nothing about connecting.</b> The SSH layer has no jump
/// hosts — <c>ISshConnection</c> offers <c>OpenShellAsync</c> and nothing else, and
/// <c>SshConnectionRequest</c> has no route field. So it is kept as a directive and a note, and the
/// preview says so; a bastion topology that imported and quietly did not route would be worse than one
/// that was not imported.
/// </para>
/// </remarks>
public HostSecret ToSecret()
{
var options = new List<HostOption>(Options);
var notes = new List<string>();
if (IdentityFiles.Count > 0)
{
options.Add(new HostOption("IdentityFile", IdentityFiles[0]));
notes.Add(IdentityFiles.Count == 1
? $"ssh_config used the key at {IdentityFiles[0]}."
: $"ssh_config listed {IdentityFiles.Count} keys, the first being {IdentityFiles[0]}.");
}
if (ProxyJump is { Length: > 0 } jump)
{
options.Add(new HostOption("ProxyJump", jump));
notes.Add($"ssh_config reached this through {jump}. DodoSSH does not route through a jump host yet.");
}
return new HostSecret
{
Label = Alias,
Hostname = Hostname,
Port = Port,
Username = Username,
Notes = notes.Count == 0 ? null : string.Join(" ", notes),
Options = HostOptions.Create(options),
};
}
}
/// <summary>
/// Everything an <c>ssh_config</c> yielded: the hosts it can offer, and what it could not.
/// </summary>
/// <param name="Hosts">The importable candidates, in file order.</param>
/// <param name="SkippedPatterns">
/// <c>Host</c> patterns that are patterns rather than names. They contribute defaults and are not
/// importable: a bookmark called <c>*.internal</c> is one nothing can dial.
/// </param>
/// <param name="Warnings">Document-level notes, including the parser's own.</param>
public sealed record SshConfigImport(
IReadOnlyList<ImportedHost> Hosts,
IReadOnlyList<string> SkippedPatterns,
IReadOnlyList<string> Warnings);
@@ -0,0 +1,31 @@
namespace DodoSSH.Client.Import;
/// <summary>One <c>Keyword Value</c> line, with the keyword as written.</summary>
/// <param name="Keyword">The directive name. SSH keywords are case-insensitive; the case here is the file's.</param>
/// <param name="Value">Everything after the keyword, unquoted but otherwise verbatim.</param>
public sealed record SshConfigDirective(string Keyword, string Value);
/// <summary>
/// One <c>Host</c> block: the patterns it applies to and the directives under it.
/// </summary>
/// <param name="Patterns">
/// Every token on the <c>Host</c> line. One line can name several — <c>Host web1 web2 web3</c> — and any of
/// them may be a pattern rather than a name.
/// </param>
/// <param name="Directives">The directives under it, in file order.</param>
public sealed record SshConfigBlock(
IReadOnlyList<string> Patterns,
IReadOnlyList<SshConfigDirective> Directives);
/// <summary>
/// A parsed <c>ssh_config</c>, plus what could not be honoured.
/// </summary>
/// <param name="Blocks">Every <c>Host</c> block, in the order OpenSSH would read them.</param>
/// <param name="Warnings">
/// What was skipped or flattened, in the words the preview will show. Everything this parser cannot
/// represent ends up here rather than being dropped quietly — a config that half-imported without saying so
/// is worse than one that refused.
/// </param>
public sealed record SshConfigDocument(
IReadOnlyList<SshConfigBlock> Blocks,
IReadOnlyList<string> Warnings);
@@ -0,0 +1,80 @@
namespace DodoSSH.Client.Import;
/// <summary>
/// Finds and reads the user's OpenSSH client configuration.
/// </summary>
/// <remarks>
/// The only type here that touches a disk, which is what keeps <see cref="SshConfigParser"/> and
/// <see cref="SshConfigResolver"/> testable against strings.
/// </remarks>
public sealed class SshConfigLocator
{
private readonly string sshDirectory;
/// <param name="sshDirectory">
/// Where to look. Defaults to <c>~/.ssh</c>, which is the location on Windows as well as everywhere
/// else — OpenSSH on Windows uses the profile directory, not <c>%APPDATA%</c>.
/// </param>
public SshConfigLocator(string? sshDirectory = null) =>
this.sshDirectory = sshDirectory ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".ssh");
/// <summary>The file this would read.</summary>
public string ConfigPath => Path.Combine(sshDirectory, "config");
/// <summary>Whether there is anything to read.</summary>
public bool Exists => File.Exists(ConfigPath);
/// <summary>Reads and resolves the configuration.</summary>
/// <exception cref="FileNotFoundException">There is no configuration file.</exception>
public async Task<SshConfigImport> ReadAsync(CancellationToken cancellationToken)
{
var text = await File.ReadAllTextAsync(ConfigPath, cancellationToken).ConfigureAwait(false);
return SshConfigResolver.Resolve(SshConfigParser.Parse(text, ReadIncluded));
}
/// <summary>
/// Reads every file an <c>Include</c> pattern names.
/// </summary>
/// <remarks>
/// <para>
/// A relative pattern resolves against <c>~/.ssh</c>, which is OpenSSH's rule for the user file. Glob
/// characters are handled by enumerating the directory rather than by matching by hand — a pattern like
/// <c>conf.d/*.conf</c> is the common shape and is what the enumeration overload is for.
/// </para>
/// <para>
/// Everything here swallows its own failures and returns nothing. An <c>Include</c> naming a file that
/// does not exist is not an error to OpenSSH, and an unreadable one is a reason to import less rather
/// than a reason to import nothing — the parser records the shortfall in its warnings either way.
/// </para>
/// </remarks>
private IReadOnlyList<string> ReadIncluded(string pattern)
{
try
{
var rooted = Path.IsPathRooted(pattern) ? pattern : Path.Combine(sshDirectory, pattern);
var directory = Path.GetDirectoryName(rooted);
var mask = Path.GetFileName(rooted);
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(mask) || !Directory.Exists(directory))
{
return [];
}
return [.. Directory
.EnumerateFiles(directory, mask, SearchOption.TopDirectoryOnly)
.Order(StringComparer.Ordinal)
.Select(File.ReadAllText)];
}
catch (IOException)
{
return [];
}
catch (UnauthorizedAccessException)
{
return [];
}
}
}
@@ -0,0 +1,295 @@
using System.Globalization;
namespace DodoSSH.Client.Import;
/// <summary>
/// Reads an OpenSSH client configuration into blocks and directives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pure, and takes its include reader as a parameter.</b> That is what makes <c>Include</c> — the one
/// directive whose behaviour depends on the file system — testable without a file system, and it keeps the
/// recursion depth cap and the cycle detection here, next to the recursion, rather than in whatever happens
/// to be doing the reading.
/// </para>
/// <para>
/// <b>Deliberately not a complete implementation of ssh_config, and the gaps are reported rather than
/// hidden.</b> <c>Match</c> blocks are not evaluated: <c>Match exec</c> runs a command, <c>Match host</c>
/// depends on what is being connected to, and <c>Match final</c> depends on the result of everything else —
/// none of which is knowable while looking at a file. Token expansion beyond <c>~</c>,
/// <c>CanonicalizeHostname</c> and negated patterns are all out of scope for the same reason: this is an
/// importer producing bookmarks somebody will check, not a second SSH client.
/// </para>
/// </remarks>
public static class SshConfigParser
{
/// <summary>How deep <c>Include</c> may nest before this gives up.</summary>
/// <remarks>
/// OpenSSH's own limit is 16. Matching it means a config this refuses is one <c>ssh</c> refuses too,
/// which is a better answer than a different arbitrary number.
/// </remarks>
private const int MaximumIncludeDepth = 16;
/// <summary>
/// Parses configuration text.
/// </summary>
/// <param name="text">The file's contents.</param>
/// <param name="includeReader">
/// Resolves an <c>Include</c> pattern to the contents of every file it names, in order. Return an empty
/// sequence for a pattern that matches nothing, which is what OpenSSH does — an <c>Include</c> naming no
/// file is not an error.
/// </param>
public static SshConfigDocument Parse(string text, Func<string, IReadOnlyList<string>>? includeReader = null)
{
ArgumentNullException.ThrowIfNull(text);
var blocks = new List<SshConfigBlock>();
var warnings = new List<string>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
ParseInto(text, includeReader, blocks, warnings, visited, depth: 0);
return new SshConfigDocument(blocks, warnings);
}
private static void ParseInto(
string text,
Func<string, IReadOnlyList<string>>? includeReader,
List<SshConfigBlock> blocks,
List<string> warnings,
HashSet<string> visited,
int depth)
{
List<string>? patterns = null;
var directives = new List<SshConfigDirective>();
// A Match block is "everything until the next Host or Match", and while one is open its directives
// are dropped rather than attributed to whatever block came before — which is what a naive parser
// does, and it silently gives one host another host's settings.
var insideMatch = false;
var matchBlocks = 0;
foreach (var raw in text.Split('\n'))
{
var (keyword, value) = Tokenise(raw);
if (keyword is null)
{
continue;
}
if (Is(keyword, "Host"))
{
Flush(blocks, patterns, directives);
patterns = SplitPatterns(value);
directives = [];
insideMatch = false;
}
else if (Is(keyword, "Match"))
{
Flush(blocks, patterns, directives);
patterns = null;
directives = [];
insideMatch = true;
matchBlocks++;
}
else if (insideMatch)
{
continue;
}
else if (Is(keyword, "Include"))
{
// Flushed first, so the included file's blocks land between this block and the next — which
// is where OpenSSH puts them, and it matters because the first value seen for a keyword is
// the one that wins.
Flush(blocks, patterns, directives);
patterns = null;
directives = [];
Include(value, includeReader, blocks, warnings, visited, depth);
}
else
{
directives.Add(new SshConfigDirective(keyword, value));
}
}
Flush(blocks, patterns, directives);
WarnAboutMatchBlocks(matchBlocks, warnings);
}
/// <remarks>
/// Counted rather than listed. What a reader needs is that some of their file was not honoured and why;
/// naming each <c>Match</c> condition would be repeating the file back at them.
/// </remarks>
private static void WarnAboutMatchBlocks(int matchBlocks, List<string> warnings)
{
if (matchBlocks == 0)
{
return;
}
warnings.Add(string.Create(
CultureInfo.CurrentCulture,
$"{matchBlocks} Match block(s) were ignored. Whether one applies depends on what is being connected to, or on a command's output, so it cannot be decided from the file alone."));
}
private static bool Is(string keyword, string name) =>
string.Equals(keyword, name, StringComparison.OrdinalIgnoreCase);
private static void Include(
string pattern,
Func<string, IReadOnlyList<string>>? includeReader,
List<SshConfigBlock> blocks,
List<string> warnings,
HashSet<string> visited,
int depth)
{
if (includeReader is null)
{
warnings.Add($"Include {pattern} was skipped: nothing was supplied to read included files.");
return;
}
if (depth >= MaximumIncludeDepth)
{
warnings.Add($"Include {pattern} was skipped: includes are nested more than {MaximumIncludeDepth} deep.");
return;
}
// Cycles are the reason this is a set rather than a counter. A file that includes itself — directly
// or through a chain — would otherwise recurse until the depth cap, importing the same hosts sixteen
// times before stopping, which reads as a bug in the importer rather than in the config.
if (!visited.Add(pattern))
{
warnings.Add($"Include {pattern} was skipped: it is already being read further up.");
return;
}
try
{
foreach (var included in includeReader(pattern))
{
ParseInto(included, includeReader, blocks, warnings, visited, depth + 1);
}
}
finally
{
visited.Remove(pattern);
}
}
private static void Flush(
List<SshConfigBlock> blocks,
List<string>? patterns,
List<SshConfigDirective> directives)
{
if (patterns is { Count: > 0 })
{
blocks.Add(new SshConfigBlock(patterns, directives));
}
}
/// <summary>
/// Splits one line into a keyword and a value, or nothing.
/// </summary>
/// <remarks>
/// OpenSSH accepts <c>Keyword Value</c>, <c>Keyword=Value</c> and <c>Keyword = Value</c>, allows leading
/// whitespace, treats <c>#</c> as a comment, and lets a value be double-quoted. The quoting is what this
/// has to get right rather than approximately right: <c>IdentityFile "~/my keys/id_ed25519"</c> is one
/// path, and splitting it on whitespace produces two that do not exist.
/// </remarks>
private static (string? Keyword, string Value) Tokenise(string line)
{
// A BOM on the first line, and CR on every line of a CRLF file. Both are invisible and both would
// otherwise end up inside the first keyword, where nothing matches them.
var trimmed = line.Trim('', '\r').Trim();
if (trimmed.Length == 0 || trimmed[0] == '#')
{
return (null, string.Empty);
}
var separator = trimmed.AsSpan().IndexOfAny(" \t=");
if (separator < 0)
{
return (trimmed, string.Empty);
}
var keyword = trimmed[..separator];
var rest = trimmed[separator..].TrimStart(' ', '\t');
if (rest.StartsWith('='))
{
rest = rest[1..].TrimStart(' ', '\t');
}
return (keyword, Unquote(rest));
}
private static string Unquote(string value)
{
var trimmed = value.Trim();
return trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"'
? trimmed[1..^1]
: trimmed;
}
/// <remarks>
/// Each token unquoted separately, because <c>Host "my server" other</c> is two patterns and one of them
/// contains a space.
/// </remarks>
private static List<string> SplitPatterns(string value)
{
var patterns = new List<string>();
var span = value.AsSpan();
var index = 0;
while (index < span.Length)
{
while (index < span.Length && char.IsWhiteSpace(span[index]))
{
index++;
}
if (index >= span.Length)
{
break;
}
int end;
if (span[index] == '"')
{
index++;
end = index;
while (end < span.Length && span[end] != '"')
{
end++;
}
patterns.Add(span[index..end].ToString());
index = end + 1;
continue;
}
end = index;
while (end < span.Length && !char.IsWhiteSpace(span[end]))
{
end++;
}
patterns.Add(span[index..end].ToString());
index = end;
}
return patterns;
}
}
@@ -0,0 +1,238 @@
using System.Buffers;
using System.Globalization;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Import;
/// <summary>
/// Turns parsed blocks into the hosts an import can offer.
/// </summary>
/// <remarks>
/// <para>
/// <b>First value wins.</b> That is the actual OpenSSH rule and it is not the intuitive one — a later
/// <c>Host *</c> block supplies defaults for keywords nothing earlier set, and cannot override a keyword an
/// earlier block already set. Getting it backwards produces an import where every host has the wildcard
/// block's username.
/// </para>
/// <para>
/// <b>A block whose patterns are all wildcards contributes defaults and is not itself importable.</b>
/// <c>Host *.internal</c> is a rule about names, not a machine — a bookmark by that name could not be
/// dialled. Those are reported so the preview can say what was used and not imported, rather than leaving
/// somebody to wonder why six blocks produced four hosts.
/// </para>
/// </remarks>
public static class SshConfigResolver
{
private static readonly SearchValues<char> PatternCharacters = SearchValues.Create("*?!");
/// <summary>Resolves every importable host in a parsed configuration.</summary>
public static SshConfigImport Resolve(SshConfigDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var hosts = new List<ImportedHost>();
var skipped = new List<string>();
var warnings = new List<string>(document.Warnings);
foreach (var pattern in document.Blocks.SelectMany(block => block.Patterns).Where(IsPattern))
{
if (!skipped.Contains(pattern, StringComparer.Ordinal))
{
skipped.Add(pattern);
}
}
foreach (var alias in document.Blocks.SelectMany(block => block.Patterns).Where(name => !IsPattern(name)))
{
if (hosts.Any(host => string.Equals(host.Alias, alias, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
hosts.Add(Resolve(alias, document));
}
if (skipped.Count > 0)
{
var named = string.Join(", ", skipped);
warnings.Add(string.Create(
CultureInfo.CurrentCulture,
$"{skipped.Count} pattern block(s) — {named} — supplied defaults but were not imported as hosts. A pattern names a rule, not a machine."));
}
return new SshConfigImport(hosts, skipped, warnings);
}
private static ImportedHost Resolve(string alias, SshConfigDocument document)
{
// Case-insensitive, because SSH keywords are and HostOption.NameComparer already says so. Two
// spellings of ServerAliveInterval reaching HostOptions.Create would be a duplicate-name throw.
var settled = new Dictionary<string, string>(HostOption.NameComparer);
var identityFiles = new List<string>();
var warnings = new List<string>();
Settle(alias, document, settled, identityFiles, warnings);
var port = ResolvePort(settled, warnings);
var hostname = Take(settled, "HostName") ?? alias;
var username = Take(settled, "User");
var proxyJump = Take(settled, "ProxyJump");
if (Take(settled, "ProxyCommand") is { } proxyCommand)
{
// Not put into Options: it would look like a setting that does something. Nothing in this
// application runs a ProxyCommand, and a directive sitting in a host's editor implying otherwise
// is worse than a sentence saying it was dropped.
warnings.Add($"ProxyCommand was dropped: nothing here runs one. It was '{proxyCommand}'.");
}
return new ImportedHost(
alias,
hostname,
username,
port,
identityFiles,
proxyJump,
HostOptions.Create(settled.Select(entry => new HostOption(entry.Key, entry.Value))),
warnings);
}
/// <summary>Walks every block that applies to an alias, keeping the first value for each keyword.</summary>
private static void Settle(
string alias,
SshConfigDocument document,
Dictionary<string, string> settled,
List<string> identityFiles,
List<string> warnings)
{
var duplicates = new HashSet<string>(HostOption.NameComparer);
foreach (var block in document.Blocks.Where(block => block.Patterns.Any(pattern => Matches(pattern, alias))))
{
foreach (var directive in block.Directives)
{
// IdentityFile is the one keyword that legitimately repeats — ssh tries each in turn — so it
// accumulates instead of settling, and is not reported as a duplicate.
if (string.Equals(directive.Keyword, "IdentityFile", StringComparison.OrdinalIgnoreCase))
{
identityFiles.Add(ExpandHome(directive.Value));
continue;
}
if (!settled.TryAdd(directive.Keyword, directive.Value))
{
duplicates.Add(directive.Keyword);
}
}
}
foreach (var keyword in duplicates.Order(HostOption.NameComparer))
{
// HostOptions is unique by name and cannot hold a repeat, which is a stated M1 limitation whose
// own remarks require the import path to surface it rather than quietly keep one. The first is
// kept because that is what ssh would have used.
warnings.Add($"{keyword} was set more than once; the first value was kept.");
}
}
private static int ResolvePort(Dictionary<string, string> settled, List<string> warnings)
{
if (Take(settled, "Port") is not { } portText)
{
return HostSecret.DefaultPort;
}
if (int.TryParse(portText, CultureInfo.InvariantCulture, out var parsed) && parsed is > 0 and <= 65535)
{
return parsed;
}
warnings.Add($"Port '{portText}' is not a usable port number; 22 was used.");
return HostSecret.DefaultPort;
}
/// <remarks>
/// Removed as it is read, so a keyword that maps onto a first-class field does not <em>also</em> end up
/// in <c>Options</c>. A host carrying both a <c>Port</c> of 2222 and a <c>Port</c> directive saying 2222
/// has two places to change it and one of them will be forgotten.
/// </remarks>
private static string? Take(Dictionary<string, string> settled, string keyword)
{
if (!settled.Remove(keyword, out var value))
{
return null;
}
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static bool IsPattern(string name) => name.AsSpan().ContainsAny(PatternCharacters);
/// <summary>
/// Whether a <c>Host</c> pattern applies to an alias.
/// </summary>
/// <remarks>
/// <c>*</c> and <c>?</c> only. Negation is not implemented — a <c>!</c> pattern is treated as not
/// matching, which errs towards importing a host with fewer defaults rather than towards silently
/// applying a block the user had excluded.
/// </remarks>
private static bool Matches(string pattern, string alias)
{
if (pattern.StartsWith('!'))
{
return false;
}
return !pattern.AsSpan().ContainsAny(PatternCharacters)
? string.Equals(pattern, alias, StringComparison.OrdinalIgnoreCase)
: Glob(pattern.AsSpan(), alias.AsSpan());
}
private static bool Glob(ReadOnlySpan<char> pattern, ReadOnlySpan<char> value)
{
if (pattern.IsEmpty)
{
return value.IsEmpty;
}
if (pattern[0] == '*')
{
for (var skip = 0; skip <= value.Length; skip++)
{
if (Glob(pattern[1..], value[skip..]))
{
return true;
}
}
return false;
}
if (value.IsEmpty)
{
return false;
}
return (pattern[0] == '?' || char.ToUpperInvariant(pattern[0]) == char.ToUpperInvariant(value[0]))
&& Glob(pattern[1..], value[1..]);
}
/// <remarks>
/// Tilde only. <c>%h</c>, <c>%p</c> and the rest are left alone: they are expanded per connection
/// against values this importer does not have, and a path with a literal <c>%h</c> in it is at least
/// visibly unexpanded rather than wrong.
/// </remarks>
private static string ExpandHome(string path)
{
if (!path.StartsWith("~/", StringComparison.Ordinal) && !path.StartsWith("~\\", StringComparison.Ordinal))
{
return path;
}
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(home, path[2..]);
}
}
@@ -0,0 +1,22 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
"resolved": "3.0.137",
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"dodossh.client.domain": {
"type": "Project"
}
}
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
S3-compatible buckets as a remote in the file browser.
Its own project rather than more of DodoSSH.Client.Transfer, because the two answer different
questions — that one is about moving bytes and what to do when moving them stops halfway, this
one is about one protocol's idea of what a file is — and because the AWS SDK belongs to exactly
one project rather than to the whole client.
It references DodoSSH.Client.Ssh for two types: IRemoteFileStore and SftpEntry. That reads
oddly and is deliberate; the reasoning is on IRemoteFileStore itself, and the short version is
that moving them would rename a record the entire file browser is written against.
-->
<ItemGroup>
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="AWSSDK.Core" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DodoSSH.Client.ObjectStore.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,57 @@
namespace DodoSSH.Client.ObjectStore;
/// <summary>
/// Translating between the paths a file browser uses and the keys a bucket has.
/// </summary>
/// <remarks>
/// <para>
/// <b>A bucket has no directories.</b> It has keys, which are strings, and a convention that <c>/</c> in a
/// key means what it means in a path. Everything in this class is that convention written down in one place,
/// because the alternative is the same three lines of trimming repeated at every call site with one of them
/// subtly different.
/// </para>
/// <para>
/// The browser's side is an absolute POSIX path — <c>/reports/2026/q3.csv</c> — because that is what the
/// screen, the breadcrumb trail and the transfer queue already speak. The bucket's side is a key with no
/// leading slash: <c>reports/2026/q3.csv</c>. The root is <c>/</c> on one side and the empty string on the
/// other, which is the case every one of these methods is really about.
/// </para>
/// </remarks>
internal static class ObjectKeys
{
/// <summary>The path a file browser opens on.</summary>
internal const string Root = "/";
/// <summary>The object key for a browser path.</summary>
internal static string ToKey(string path) => path.TrimStart('/');
/// <summary>The browser path for an object key.</summary>
internal static string ToPath(string key) => Root + key.TrimStart('/');
/// <summary>
/// The prefix that lists one directory's immediate contents.
/// </summary>
/// <remarks>
/// Trailing slash, always, and empty for the root. Without it a listing of <c>/reports</c> would also
/// return <c>/reports-archive</c>, because a prefix match knows nothing about path segments.
/// </remarks>
internal static string ToPrefix(string path)
{
var key = ToKey(path);
return key.Length == 0 || key.EndsWith('/') ? key : key + "/";
}
/// <summary>The last segment of a key, which is what a row shows.</summary>
/// <remarks>
/// Trailing slashes are removed first, so the common prefix <c>reports/2026/</c> yields <c>2026</c>
/// rather than an empty string.
/// </remarks>
internal static string NameOf(string key)
{
var trimmed = key.TrimEnd('/');
var slash = trimmed.LastIndexOf('/');
return slash < 0 ? trimmed : trimmed[(slash + 1)..];
}
}
@@ -0,0 +1,69 @@
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.ObjectStore;
/// <summary>Opens a bucket as a place with files in it.</summary>
/// <remarks>
/// An interface so the file screen can be tested without a bucket, exactly as <c>ISftpSessionFactory</c> is
/// what lets it be tested without a host.
/// </remarks>
public interface IObjectStoreFactory
{
/// <summary>Builds a client for one bucket.</summary>
/// <param name="store">The bucket and its credentials, decrypted.</param>
/// <remarks>
/// Synchronous and cheap: nothing is contacted here. S3 is request-per-operation, so there is no
/// connect step to fail — the first thing that can fail is the first listing, which is where the
/// credentials and the endpoint are actually tested.
/// </remarks>
IRemoteFileStore Open(ObjectStoreSecret store);
}
/// <summary>Opens buckets with the AWS SDK.</summary>
public sealed class S3ObjectStoreFactory : IObjectStoreFactory
{
/// <inheritdoc />
public IRemoteFileStore Open(ObjectStoreSecret store)
{
ArgumentNullException.ThrowIfNull(store);
if (!store.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(store));
}
var config = new AmazonS3Config
{
// On for nearly every self-hosted service and off for AWS. It is a stored setting rather than
// something inferred from the endpoint, because inferring it wrongly produces a DNS failure that
// says nothing about buckets — see ObjectStoreSecret.UsePathStyle.
ForcePathStyle = store.UsePathStyle,
};
if (store.Endpoint is { } endpoint)
{
config.ServiceURL = endpoint;
// Still set when there is one, because SigV4 signs the region into every request and several
// S3-compatible services check it. The ones that do not, ignore it.
if (store.Region is { } named)
{
config.AuthenticationRegion = named;
}
}
else
{
// No endpoint means Amazon, and then the region is what resolves the host. Validation has
// already refused the case where neither is set.
config.RegionEndpoint = RegionEndpoint.GetBySystemName(store.Region!);
}
var credentials = new BasicAWSCredentials(store.AccessKeyId, store.SecretAccessKey);
return new S3FileStore(new AmazonS3Client(credentials, config), store.Bucket);
}
}
@@ -0,0 +1,449 @@
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.ObjectStore;
/// <summary>
/// One S3-compatible bucket, as a place with files in it.
/// </summary>
/// <remarks>
/// <para>
/// <b>A bucket is not a filesystem, and the three places that matter are documented on the members rather
/// than smoothed over.</b> There are no directories, only keys with slashes in them; an object cannot be
/// appended to, so an interrupted upload cannot resume; and there is no rename, only copy-then-delete. Each
/// is refused with a reason or implemented with its cost stated, because a file browser that quietly did
/// something adjacent would be worse than one that said no.
/// </para>
/// <para>
/// <b>Listings are one page.</b> <c>ListObjectsV2</c> returns up to a thousand keys and this asks for one
/// page, so a prefix with more than that in it is shown truncated — which the screen says out loud. Paging
/// the whole way through a bucket with a million objects under one prefix is a request storm behind a
/// scrollbar nobody asked for; the filter box is the answer, and a prefix that large is not a directory
/// anybody browses.
/// </para>
/// </remarks>
internal sealed class S3FileStore : IRemoteFileStore
{
/// <summary>
/// The most keys one listing asks for.
/// </summary>
/// <remarks>
/// The service's own maximum. Asking for less would page more often for no benefit; asking for more is
/// not possible.
/// </remarks>
private const int PageSize = 1000;
private readonly IAmazonS3 client;
private readonly string bucket;
private int disposed;
internal S3FileStore(IAmazonS3 client, string bucket)
{
this.client = client;
this.bucket = bucket;
}
/// <summary>
/// Always true, because there is no connection to be up.
/// </summary>
/// <remarks>
/// S3 is request-per-operation over HTTPS; there is no session to drop and nothing to poll. Answering
/// false when the network is down would be a claim this type cannot make without a request of its own,
/// and every operation already reports its own failure.
/// </remarks>
public bool IsConnected => Volatile.Read(ref disposed) == 0;
/// <inheritdoc />
public string HomeDirectory => ObjectKeys.Root;
/// <summary>
/// Lists one prefix: its immediate sub-prefixes as directories, its immediate keys as files.
/// </summary>
/// <remarks>
/// The delimiter is what makes this a directory listing rather than a recursive walk — without it, a
/// listing of the root returns every object in the bucket. Common prefixes come back as directories;
/// the marker object some tools write for a "folder" is dropped, because it is the directory itself and
/// showing it would put an empty-named row inside every one.
/// </remarks>
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var prefix = ObjectKeys.ToPrefix(path);
ListObjectsV2Response response;
try
{
response = await client.ListObjectsV2Async(
new ListObjectsV2Request
{
BucketName = bucket,
Prefix = prefix,
Delimiter = "/",
MaxKeys = PageSize,
},
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
return Project(response, prefix);
}
/// <summary>Turns one listing into rows a file browser can show.</summary>
/// <remarks>
/// Directories first and then by name, which is the order every caller of this interface expects and
/// what saves the screen sorting it again.
/// </remarks>
private static IReadOnlyList<SftpEntry> Project(ListObjectsV2Response response, string prefix)
{
var entries = new List<SftpEntry>();
foreach (var common in response.CommonPrefixes ?? [])
{
entries.Add(new SftpEntry(
ObjectKeys.NameOf(common),
ObjectKeys.ToPath(common),
SftpEntryKind.Directory,
Length: 0,
LastWriteTimeUtc: default,
// Blank rather than invented. A bucket has no POSIX mode, and printing drwxr-xr-x beside a
// prefix would be a fact this store made up.
Permissions: string.Empty));
}
foreach (var item in response.S3Objects ?? [])
{
// The marker object for this prefix itself, which several tools write to make a folder appear
// in a web console. It is this directory, not something in it.
if (string.Equals(item.Key, prefix, StringComparison.Ordinal))
{
continue;
}
entries.Add(new SftpEntry(
ObjectKeys.NameOf(item.Key),
ObjectKeys.ToPath(item.Key),
SftpEntryKind.File,
item.Size ?? 0,
Utc(item.LastModified),
Permissions: string.Empty));
}
return
[
.. entries
.OrderByDescending(entry => entry.Kind is SftpEntryKind.Directory)
.ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase),
];
}
/// <summary>
/// The SDK's timestamp as an unambiguous instant.
/// </summary>
/// <remarks>
/// Stated rather than converted implicitly. S3 returns <c>Last-Modified</c> in UTC and the SDK hands it
/// over as a <see cref="DateTime"/> whose <c>Kind</c> is not reliably set — so an implicit conversion
/// would read it as local time on some paths and shift every timestamp in the listing by the machine's
/// offset. The file browser shows this column beside an SFTP one.
/// </remarks>
private static DateTimeOffset Utc(DateTime? moment) =>
moment is { } value
? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
: default;
/// <summary>
/// What one path is, or null when nothing is there.
/// </summary>
/// <remarks>
/// Two requests in the worst case, because a bucket cannot answer "is this a directory" directly: a
/// HEAD tells us whether an object with that exact key exists, and only a listing can tell us whether
/// anything lives under it as a prefix. The order matters — a key can be both, and the object is the
/// more specific answer.
/// </remarks>
public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var key = ObjectKeys.ToKey(path);
if (key.Length == 0)
{
return new SftpEntry(
string.Empty, ObjectKeys.Root, SftpEntryKind.Directory, 0, default, string.Empty);
}
try
{
var head = await client.GetObjectMetadataAsync(
new GetObjectMetadataRequest { BucketName = bucket, Key = key },
cancellationToken).ConfigureAwait(false);
return new SftpEntry(
ObjectKeys.NameOf(key),
ObjectKeys.ToPath(key),
SftpEntryKind.File,
head.ContentLength,
Utc(head.LastModified),
Permissions: string.Empty);
}
catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Not an object. It may still be a prefix with things under it, which is what a browser means
// by a directory.
}
var listing = await client.ListObjectsV2Async(
new ListObjectsV2Request
{
BucketName = bucket,
Prefix = ObjectKeys.ToPrefix(path),
MaxKeys = 1,
},
cancellationToken).ConfigureAwait(false);
return listing.KeyCount > 0
? new SftpEntry(
ObjectKeys.NameOf(key),
ObjectKeys.ToPath(key),
SftpEntryKind.Directory,
0,
default,
string.Empty)
: null;
}
/// <inheritdoc />
public async Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
ArgumentOutOfRangeException.ThrowIfNegative(offset);
var request = new GetObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(path) };
if (offset > 0)
{
// A ranged GET, which is what makes an interrupted download resumable — and the one place where
// a bucket is better at this than SFTP, because the range is part of the protocol rather than a
// seek on an open handle.
request.ByteRange = new ByteRange(offset, long.MaxValue);
}
try
{
var response = await client.GetObjectAsync(request, cancellationToken).ConfigureAwait(false);
return response.ResponseStream;
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
}
/// <summary>
/// Opens an object for writing, from the beginning.
/// </summary>
/// <remarks>
/// <para>
/// <b>A non-zero offset is refused, and this is the one capability a bucket genuinely does not have.</b>
/// Objects are immutable: there is no append, and no way to write into the middle of one. Multipart
/// upload can rebuild an interrupted transfer, but only by keeping the upload id and every part's ETag
/// across the interruption — state this store would have to persist somewhere, on behalf of a queue that
/// already has its own idea of what resuming means. Refusing with a reason is the honest answer;
/// silently starting from zero would corrupt a resumed file.
/// </para>
/// <para>
/// The returned stream is the writing half of a pipe. A background upload reads the other half and
/// chunks it into parts, so a large file never lands on disk twice and memory stays bounded by the part
/// size — which is what the alternative, buffering to a temporary file and putting it afterwards, would
/// have cost.
/// </para>
/// </remarks>
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
if (offset != 0)
{
throw new SftpPathException(
path,
"An object cannot be written to from the middle, so an interrupted upload to a bucket "
+ "starts again rather than resuming.");
}
return Task.FromResult<Stream>(
new S3UploadStream(client, bucket, ObjectKeys.ToKey(path), cancellationToken));
}
/// <summary>
/// Creates the marker object that makes an empty prefix visible.
/// </summary>
/// <remarks>
/// A zero-byte object whose key ends in <c>/</c>, which is the convention every S3 console and most
/// tools use. It is not a directory — nothing in the service knows what one is — and it disappears by
/// itself once real objects live under the prefix, which is why the listing above drops it.
/// </remarks>
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var prefix = ObjectKeys.ToPrefix(path);
if (prefix.Length == 0)
{
throw new SftpPathException(path, "The root of a bucket already exists.");
}
try
{
await client.PutObjectAsync(
new PutObjectRequest
{
BucketName = bucket,
Key = prefix,
ContentBody = string.Empty,
},
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
}
/// <summary>
/// Deletes one object, or an empty prefix's marker.
/// </summary>
/// <remarks>
/// Deliberately not recursive, matching SFTP's own rule and for the same reason: a recursive delete
/// against a bucket is the one operation on this screen that can destroy something no undo reaches. A
/// prefix with anything under it is refused and says so.
/// </remarks>
public async Task DeleteAsync(string path, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(path);
var key = ObjectKeys.ToKey(path);
if (key.Length == 0)
{
throw new SftpPathException(path, "A bucket cannot delete its own root.");
}
if (await StatAsync(path, cancellationToken).ConfigureAwait(false) is { Kind: SftpEntryKind.Directory })
{
var listing = await client.ListObjectsV2Async(
new ListObjectsV2Request
{
BucketName = bucket,
Prefix = ObjectKeys.ToPrefix(path),
MaxKeys = 2,
},
cancellationToken).ConfigureAwait(false);
// One key is the marker object for this prefix itself; anything more is contents.
if (listing.KeyCount > 1)
{
throw new SftpPathException(
path, "There are still objects under this prefix, so it was not deleted.");
}
key = ObjectKeys.ToPrefix(path);
}
try
{
await client.DeleteObjectAsync(
new DeleteObjectRequest { BucketName = bucket, Key = key },
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(path, Describe(exception), exception);
}
}
/// <summary>
/// Copies to the new key and deletes the old one, which is what a bucket has instead of rename.
/// </summary>
/// <remarks>
/// <para>
/// Not atomic, and it cannot be. Between the two requests both keys exist; if the delete fails, both
/// still do. The copy is server-side — no bytes come to this machine — so the window is short, but it is
/// real and a failure leaves a duplicate rather than a loss, which is the safe direction.
/// </para>
/// <para>
/// Only objects. Renaming a prefix means copying every key under it, which is a bulk operation wearing
/// a rename's clothing, and the failure mode is a half-moved directory.
/// </para>
/// </remarks>
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fromPath);
ArgumentNullException.ThrowIfNull(toPath);
if (await StatAsync(fromPath, cancellationToken).ConfigureAwait(false)
is not { Kind: SftpEntryKind.File })
{
throw new SftpPathException(
fromPath,
"Only an object can be renamed in a bucket. A prefix would have to be copied key by key.");
}
try
{
await client.CopyObjectAsync(
new CopyObjectRequest
{
SourceBucket = bucket,
SourceKey = ObjectKeys.ToKey(fromPath),
DestinationBucket = bucket,
DestinationKey = ObjectKeys.ToKey(toPath),
},
cancellationToken).ConfigureAwait(false);
await client.DeleteObjectAsync(
new DeleteObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(fromPath) },
cancellationToken).ConfigureAwait(false);
}
catch (AmazonS3Exception exception)
{
throw new SftpPathException(fromPath, Describe(exception), exception);
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 0)
{
client.Dispose();
}
return ValueTask.CompletedTask;
}
/// <summary>
/// What went wrong, in words that name the bucket rather than the protocol.
/// </summary>
/// <remarks>
/// The SDK's own messages are accurate and unhelpful at a file browser: "The specified key does not
/// exist" is fine, and "Access Denied" against a bucket somebody has just typed the keys for is the
/// moment to say which of the two is more likely.
/// </remarks>
private static string Describe(AmazonS3Exception exception) => exception.StatusCode switch
{
System.Net.HttpStatusCode.NotFound => "There is nothing at that key.",
System.Net.HttpStatusCode.Forbidden =>
"The bucket refused that. Check the access key and what it is allowed to do.",
System.Net.HttpStatusCode.BadRequest when exception.ErrorCode is "AuthorizationHeaderMalformed" =>
"The bucket is in a different region to the one configured.",
_ => exception.Message,
};
}
@@ -0,0 +1,204 @@
using System.IO.Pipelines;
using Amazon.S3;
using Amazon.S3.Transfer;
namespace DodoSSH.Client.ObjectStore;
/// <summary>
/// A stream you write an object into.
/// </summary>
/// <remarks>
/// <para>
/// <b>The direction is the whole problem.</b> The transfer queue asks for somewhere to write and then copies
/// a local file into it; the S3 SDK wants a stream it can read from. Something has to bridge the two, and
/// there are only three ways to do it: buffer the whole object to a temporary file and upload afterwards
/// (correct, and doubles the disk a big upload costs), hold it in memory (correct until somebody uploads a
/// disc image), or run the upload concurrently and hand back the writing half of a pipe.
/// </para>
/// <para>
/// This is the third. <see cref="TransferUtility"/> reads the pipe and splits it into multipart chunks, so
/// memory stays bounded by the part size however large the object is, and nothing lands on disk twice.
/// </para>
/// <para>
/// <b>Completion is on <see cref="DisposeAsync"/>, and it is not optional.</b> The upload is only finished
/// when the pipe is completed and the background task has been awaited — so a caller that abandons this
/// stream without disposing it leaves an upload running against a bucket. That is the same contract every
/// stream has; it is written down because the consequence here is remote rather than local.
/// </para>
/// <para>
/// <b>A failed upload has to surface at the writer.</b> If the service refuses halfway, the reading half
/// stops and this stream's next <c>WriteAsync</c> would otherwise block for ever — so the background task's
/// completion also completes the pipe's reader with the exception, which is what makes the write throw with
/// the real reason rather than hang.
/// </para>
/// </remarks>
internal sealed class S3UploadStream : Stream
{
private readonly Pipe pipe = new();
private readonly Task upload;
private readonly CancellationToken cancellationToken;
private int disposed;
internal S3UploadStream(
IAmazonS3 client,
string bucket,
string key,
CancellationToken cancellationToken)
{
this.cancellationToken = cancellationToken;
upload = UploadAsync(client, bucket, key);
}
/// <inheritdoc />
public override bool CanRead => false;
/// <inheritdoc />
public override bool CanSeek => false;
/// <inheritdoc />
public override bool CanWrite => Volatile.Read(ref disposed) == 0;
/// <summary>Not answerable: an object's length is not known until it has all been written.</summary>
public override long Length => throw new NotSupportedException();
/// <inheritdoc cref="Length" />
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
/// <inheritdoc />
public override async ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default)
{
var result = await pipe.Writer.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
if (result.IsCompleted)
{
// The reader has stopped, which means the upload ended — almost always because the service
// refused it. Awaiting the task surfaces that exception here, at the write, instead of leaving
// the caller to discover it at disposal after copying a whole file into nothing.
await upload.ConfigureAwait(false);
}
}
/// <summary>
/// Refused: this stream is asynchronous all the way down.
/// </summary>
/// <remarks>
/// Blocking on the pipe from a synchronous write is a deadlock waiting for a thread-pool starvation to
/// find it — the other half of the pipe is being read by a task that needs a thread to run on. The only
/// caller is the transfer queue, which copies asynchronously, so this is unreachable rather than
/// inconvenient. Throwing says which; blocking would say nothing until a large upload hung.
/// </remarks>
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException(
"An upload to a bucket is written asynchronously; use WriteAsync.");
/// <summary>
/// Nothing, deliberately.
/// </summary>
/// <remarks>
/// A flush cannot mean what a caller would want it to here — the object does not exist until the upload
/// completes, so there is no partial state to make durable. The pipe's own writes are already handed to
/// the reader as they arrive.
/// </remarks>
public override void Flush()
{
}
/// <inheritdoc cref="Flush" />
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
/// <inheritdoc />
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
/// <inheritdoc />
public override void SetLength(long value) => throw new NotSupportedException();
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
// Completing the writer is what tells the upload there is no more, so it must happen before the
// await — and it must happen even when the caller is abandoning a failed transfer, or the background
// task never ends.
await pipe.Writer.CompleteAsync().ConfigureAwait(false);
try
{
await upload.ConfigureAwait(false);
}
finally
{
await base.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Refused when it would have to finish an upload.
/// </summary>
/// <remarks>
/// <para>
/// Completing this stream means completing the pipe and awaiting the upload, and doing that from a
/// synchronous <c>Dispose</c> is the deadlock the synchronous <c>Write</c> above avoids. The alternative
/// — completing the writer and abandoning the task — silently drops whatever the service was about to
/// say, including a refusal, and reports a transfer as finished that never landed.
/// </para>
/// <para>
/// So a <c>using</c> rather than an <c>await using</c> throws, which is loud, immediate and correct. The
/// only caller already uses <c>await using</c>; this is what stops a second one being written by
/// accident.
/// </para>
/// </remarks>
protected override void Dispose(bool disposing)
{
if (disposing && Volatile.Read(ref disposed) == 0)
{
throw new NotSupportedException(
"An upload to a bucket finishes asynchronously; use await using rather than using.");
}
base.Dispose(disposing);
}
private async Task UploadAsync(IAmazonS3 client, string bucket, string key)
{
using var transfer = new TransferUtility(client);
try
{
await transfer.UploadAsync(
new TransferUtilityUploadRequest
{
BucketName = bucket,
Key = key,
InputStream = pipe.Reader.AsStream(),
// The stream has no length, so the utility has to be told not to look for one. It reads
// until the pipe completes and splits what it read into parts.
AutoCloseStream = false,
},
cancellationToken).ConfigureAwait(false);
await pipe.Reader.CompleteAsync().ConfigureAwait(false);
}
catch (Exception exception)
{
// Completing the reader *with* the exception is what unblocks a writer that is still copying:
// its next write sees a completed pipe and awaits this task, which rethrows this.
await pipe.Reader.CompleteAsync(exception).ConfigureAwait(false);
throw;
}
}
}
@@ -0,0 +1,88 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"AWSSDK.Core": {
"type": "Direct",
"requested": "[4.0.100.9, )",
"resolved": "4.0.100.9",
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
},
"AWSSDK.S3": {
"type": "Direct",
"requested": "[4.0.101.6, )",
"resolved": "4.0.101.6",
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
"dependencies": {
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
"resolved": "3.0.137",
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.3",
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
},
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
}
}
}
@@ -0,0 +1,165 @@
using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Session;
/// <summary>
/// Records keychain changes into the vault they happened in, without making the save wait.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ConnectionRecorder"/>'s shape, and the reason is the same one stated a different way: the
/// caller is a Save the user is watching, and an encrypt-and-write on that path would put the log's cost
/// into every edit. So <see cref="Record"/> posts to a bounded channel and returns, and one background task
/// does the work.
/// </para>
/// <para>
/// <b>Session-scoped, unlike the connection recorder.</b> This one is created with the vault and dies with
/// it — there is no equivalent of a shell that outlives a lock, because an edit is finished by the time it
/// is recorded. That is why it is owned by <see cref="VaultSession"/> rather than by the shell.
/// </para>
/// <para>
/// <b>Every failure is swallowed.</b> A log write that failed and surfaced would fail a save, and the whole
/// premise of the outbox is that saving works offline and cannot be refused. What is lost when this drops
/// something is one advisory line.
/// </para>
/// </remarks>
internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable
{
/// <inheritdoc cref="ConnectionRecorder" path="/remarks/para[4]" />
private const int QueueDepth = 512;
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
private readonly Channel<ActivityLogSecret> pending = Channel.CreateBounded<ActivityLogSecret>(
new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly ActivityLogRepository log;
private readonly Guid vaultId;
private readonly Guid actorUserId;
private readonly string deviceName;
private readonly TimeProvider clock;
private readonly CancellationTokenSource lifetime = new();
private readonly Task drain;
private int disposed;
/// <param name="log">Where entries go.</param>
/// <param name="vaultId">The vault they belong to.</param>
/// <param name="actorUserId">Which account is making them.</param>
/// <param name="deviceName">What this machine calls itself.</param>
/// <param name="clock">Time source.</param>
internal ActivityRecorder(
ActivityLogRepository log,
Guid vaultId,
Guid actorUserId,
string deviceName,
TimeProvider clock)
{
this.log = log;
this.vaultId = vaultId;
this.actorUserId = actorUserId;
this.deviceName = deviceName;
this.clock = clock;
drain = DrainAsync(lifetime.Token);
}
/// <inheritdoc />
public void Record(
Guid vaultId,
SyncEntityType kind,
Guid entityId,
string label,
ActivityOperation operation,
IReadOnlyList<string> changedFields)
{
ArgumentNullException.ThrowIfNull(changedFields);
if (vaultId != this.vaultId)
{
// A write to a vault this recorder is not for. Not currently reachable — one session, one active
// vault — and refused rather than filed under the wrong one, because that is the failure that
// would be hardest to notice once shared vaults land.
return;
}
var entry = new ActivityLogSecret
{
// The name rather than the number, so a build that has never heard of a kind still shows
// something a person can read. See ActivityLogSecretCodec.
ItemKind = Enum.GetName(kind) ?? kind.ToString(),
ItemId = entityId,
ItemLabel = label,
Operation = operation,
ChangedFields = string.Join(", ", changedFields),
At = clock.GetUtcNow(),
DeviceName = deviceName,
ActorUserId = actorUserId,
};
pending.Writer.TryWrite(entry);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
pending.Writer.TryComplete();
try
{
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// Whatever is left goes unwritten, which is the same trade the queue's own DropOldest makes.
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await drain.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: cancelling is how the loop is asked to stop.
}
lifetime.Dispose();
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var entry in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await log.CreateAsync(vaultId, entry, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Swallowed. There is no caller left to tell, and the realistic failure is a cache that
// has gone away underneath a session being disposed.
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
}
@@ -0,0 +1,410 @@
using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Session;
/// <summary>A connection that has started and has no log entry yet, because it has not ended.</summary>
/// <param name="HostLabel">What the host is called.</param>
/// <param name="Address">The address as dialled.</param>
/// <param name="StartedAt">When it opened.</param>
public sealed record OpenConnection(string HostLabel, string Address, DateTimeOffset StartedAt);
/// <summary>
/// Records connections into whichever vault is open, without ever making the caller wait.
/// </summary>
/// <remarks>
/// <para>
/// <b>A process-lifetime object with session-scoped contents</b>, exactly like <see cref="VaultKnownHostStore"/>
/// and for the same reason: the workspace that calls it is composed once at startup and outlives every lock,
/// so a recorder created per session would have to be threaded through an object that must not know about
/// vaults at all. <see cref="Open"/> on unlock, <see cref="Close"/> on lock.
/// </para>
/// <para>
/// <b>Nothing on the calling thread does any work.</b> Both interface methods take a lock, touch a
/// dictionary, and post to a bounded channel; one background task drains it and does the encrypting and
/// writing. That is not tidiness — <c>Closed</c> is called from a <c>finally</c> unwinding on a thread-pool
/// thread while the application is shutting down, once per open tab, and an encrypt-and-write there is
/// exactly how closing an application comes to take four seconds.
/// </para>
/// <para>
/// <b>A shell can outlive the vault, so close-out has to as well.</b> A tab opened before a lock and closed
/// after it still deserves its entry — the connection genuinely happened — so the ticket keeps the repository
/// it was opened against rather than reading whichever one is current. The write then fails if the session
/// behind it has been disposed, which is swallowed like every other failure here: an advisory log line is
/// never worth surfacing an error over.
/// </para>
/// <para>
/// <b>The queue is bounded and drops the oldest when full.</b> An unbounded one would turn a stuck write into
/// unbounded memory, and blocking would turn it into a hung shutdown. Losing the oldest few entries of a
/// backlog that is already thousands deep is the least bad of the three, and it is the direction that keeps
/// the newest — which is what somebody reading a log actually wants.
/// </para>
/// </remarks>
public sealed class ConnectionRecorder : IConnectionLogSink, IAsyncDisposable
{
/// <summary>
/// How many close-outs may be waiting to be written.
/// </summary>
/// <remarks>
/// Far more than the tabs anybody has open, so the cap is only ever reached by a write path that has
/// stopped draining — which is the case it exists for.
/// </remarks>
private const int QueueDepth = 256;
/// <summary>How long <see cref="DisposeAsync"/> waits for the queue to be written.</summary>
/// <remarks>
/// Long enough for the handful of entries a normal exit produces — each is one encrypt and one local
/// write — and short enough that a stuck cache cannot become a window that will not close.
/// </remarks>
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
private readonly Channel<PendingEntry> pending = Channel.CreateBounded<PendingEntry>(
new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly Dictionary<uint, OpenTicket> tickets = [];
private readonly Lock gate = new();
private readonly TimeProvider clock;
private readonly string deviceName;
private readonly Task drain;
private readonly CancellationTokenSource lifetime = new();
private Binding? binding;
private int disposed;
/// <param name="clock">Time source. Used only for a duration this type did not receive.</param>
/// <param name="deviceName">What this machine calls itself, recorded on every entry.</param>
public ConnectionRecorder(TimeProvider clock, string deviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
this.clock = clock;
this.deviceName = deviceName;
drain = DrainAsync(lifetime.Token);
}
/// <summary>
/// The connections that have opened and not yet been recorded.
/// </summary>
/// <remarks>
/// For the logs screen, which shows these above the finished entries. It reads them from here rather
/// than from the tab strip because these are exactly the tickets the log is waiting to close — so a row
/// on that screen appears and disappears in step with the entry that will replace it, rather than in
/// step with a tab, which is a different thing that merely usually agrees.
/// </remarks>
public IReadOnlyList<OpenConnection> Open()
{
lock (gate)
{
return
[
.. tickets.Values
.Select(ticket => new OpenConnection(
ticket.HostLabel, ticket.Address, ticket.StartedAt))
.OrderByDescending(open => open.StartedAt),
];
}
}
/// <summary>Whether a vault is open behind this recorder.</summary>
public bool IsOpen
{
get
{
lock (gate)
{
return binding is not null;
}
}
}
/// <summary>Starts recording into an unlocked vault.</summary>
/// <param name="session">The unlocked session. Its active vault is the one written to.</param>
/// <param name="actorUserId">Which account this is, recorded on every entry.</param>
public void Open(VaultSession session, Guid actorUserId)
{
ArgumentNullException.ThrowIfNull(session);
lock (gate)
{
binding = new Binding(session.ConnectionLog, session.ActiveVaultId, actorUserId);
}
}
/// <summary>
/// Stops recording new connections.
/// </summary>
/// <remarks>
/// Open tickets are deliberately <em>not</em> discarded. Each already holds the repository it was opened
/// against, so a shell still running when the vault locks closes out into the vault it was made from —
/// which is the honest record. What is dropped is the ability to <em>start</em> a ticket, because a
/// connection made while locked has no vault to belong to.
/// </remarks>
public void Close()
{
lock (gate)
{
binding = null;
}
}
/// <inheritdoc />
public void Opened(uint sessionId, string address, DateTimeOffset startedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
lock (gate)
{
if (binding is not { } open)
{
return;
}
// The address stands in for the name until Identify supplies one, so a connection made by
// something that never calls it is still recorded — with a worse label, which beats no entry.
tickets[sessionId] = new OpenTicket(
open, address, address, HostId: null, ConnectionKind.Terminal, startedAt);
}
}
/// <summary>
/// Names the host an already-open session belongs to.
/// </summary>
/// <param name="sessionId">The session, as the workspace knows it.</param>
/// <param name="hostLabel">What the host is called in the keychain.</param>
/// <param name="hostId">The host item.</param>
/// <remarks>
/// <para>
/// The workspace takes an <c>SshConnectionRequest</c>, which has no notion of a keychain item, so it
/// knows an address and nothing else. The label and the id arrive here instead, from the view model that
/// does know — and as an amendment rather than a second ticket, so the start time stays the one the
/// workspace recorded rather than the slightly later one this call would carry.
/// </para>
/// <para>
/// A session id with no ticket is ignored, which is what a connection made while the vault was locked
/// looks like.
/// </para>
/// </remarks>
public void Identify(uint sessionId, string hostLabel, Guid? hostId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
lock (gate)
{
if (tickets.TryGetValue(sessionId, out var ticket))
{
tickets[sessionId] = ticket with { HostLabel = hostLabel, HostId = hostId };
}
}
}
/// <inheritdoc />
public void Closed(uint sessionId, DateTimeOffset endedAt)
{
OpenTicket ticket;
lock (gate)
{
if (!tickets.Remove(sessionId, out var found))
{
// Never opened, already closed, or opened while the vault was locked. All three mean there
// is nothing to record, and none of them is an error.
return;
}
ticket = found;
}
Queue(ticket, endedAt, ConnectionOutcome.Closed);
}
/// <summary>
/// Records a connection that was never a workspace session.
/// </summary>
/// <param name="address">The address that was dialled.</param>
/// <param name="hostLabel">What the host is called.</param>
/// <param name="hostId">The host item, if there was one.</param>
/// <param name="kind">Which sort of session it was.</param>
/// <param name="startedAt">When it began.</param>
/// <param name="endedAt">When it ended, which is the same instant for an attempt that failed.</param>
/// <param name="outcome">How it ended.</param>
/// <remarks>
/// <para>
/// Two callers, both outside the terminal workspace's id space, which is why this takes no session id:
/// a connection that never opened — the workspace throws out of <c>ConnectAsync</c> before an id exists,
/// so there is nothing to open a ticket for — and an SFTP session, which is a separate connection
/// entirely and would collide with a terminal's id if it borrowed one.
/// </para>
/// <para>
/// A run of refusals against one host is the single most interesting thing a connection log can show,
/// which is why the failures are recorded at all rather than only the sessions that worked.
/// </para>
/// </remarks>
public void Record(
string address,
string hostLabel,
Guid? hostId,
ConnectionKind kind,
DateTimeOffset startedAt,
DateTimeOffset endedAt,
ConnectionOutcome outcome)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
Binding open;
lock (gate)
{
if (binding is not { } current)
{
return;
}
open = current;
}
Queue(new OpenTicket(open, address, hostLabel, hostId, kind, startedAt), endedAt, outcome);
}
/// <summary>
/// Closes out every still-open connection and writes what is queued, within a bounded wait.
/// </summary>
/// <remarks>
/// <para>
/// <b>Closing the application is the ordinary way a session ends</b>, and without this every one of them
/// would be lost: the workspace's own close-outs happen while it tears its sessions down, which is after
/// the vault they would be written into has gone. So the tickets are closed here instead, while there is
/// still something to write to, and the durations run to the moment of exit — which is what actually
/// happened.
/// </para>
/// <para>
/// <b>The wait is bounded and the remainder is dropped.</b> An advisory log is never worth making a
/// process refuse to exit, so a queue that will not drain costs its entries rather than the user's
/// patience.
/// </para>
/// </remarks>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
OpenTicket[] remaining;
lock (gate)
{
remaining = [.. tickets.Values];
tickets.Clear();
binding = null;
}
var at = clock.GetUtcNow();
foreach (var ticket in remaining)
{
Queue(ticket, at, ConnectionOutcome.Closed);
}
pending.Writer.TryComplete();
try
{
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// Whatever is left goes unwritten. Stated rather than logged: there is nowhere left to log it.
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await drain.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: cancelling is how the loop is asked to stop.
}
lifetime.Dispose();
}
private void Queue(OpenTicket ticket, DateTimeOffset endedAt, ConnectionOutcome outcome)
{
// A duration rather than an end time, and clamped at zero: the two stamps come from the same clock,
// but a machine that resumed from sleep between them can still produce a negative one, and the
// payload refuses those outright.
var duration = endedAt > ticket.StartedAt ? endedAt - ticket.StartedAt : TimeSpan.Zero;
var entry = new ConnectionLogSecret
{
HostLabel = ticket.HostLabel,
Address = ticket.Address,
HostId = ticket.HostId,
Kind = ticket.Kind,
StartedAt = ticket.StartedAt,
Duration = duration,
Outcome = outcome,
DeviceName = deviceName,
ActorUserId = ticket.Binding.ActorUserId,
};
// TryWrite, never WriteAsync. The whole contract of this type is that the caller does not wait, and
// a bounded channel with DropOldest never refuses anyway.
pending.Writer.TryWrite(new PendingEntry(ticket.Binding, entry));
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var item in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await item.Binding.Log
.CreateAsync(item.Binding.VaultId, item.Entry, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Swallowed, and this is the rule rather than an omission: a log entry is advisory, and
// there is no caller left to tell. The realistic failures are a session disposed between
// the queue and the write — a shell closed after the vault locked — and a cache that has
// gone away underneath it. Neither is worth an unobserved exception on a background task.
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
/// <summary>Which vault entries go to, and who is making them.</summary>
private sealed record Binding(ConnectionLogRepository Log, Guid VaultId, Guid ActorUserId);
/// <summary>A connection that has started and not yet been recorded.</summary>
/// <remarks>
/// It carries its own <see cref="Binding"/> rather than reading the current one at close time, which is
/// what lets a session outlive the vault it was opened in without being filed into the next one.
/// </remarks>
private sealed record OpenTicket(
Binding Binding,
string Address,
string HostLabel,
Guid? HostId,
ConnectionKind Kind,
DateTimeOffset StartedAt);
private sealed record PendingEntry(Binding Binding, ConnectionLogSecret Entry);
}
@@ -24,6 +24,14 @@
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" /> <ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" /> <ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" /> <ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
<!--
The terminal layer, for one interface: IConnectionLogSink, which ConnectionRecorder implements. The
direction is the point. Client.Terminal references only Client.Ssh and must keep doing so — a workspace
that knew about vaults would be a workspace that could not keep a shell running through a lock — so the
hole is declared down there and filled up here, exactly as VaultKnownHostStore fills IKnownHostStore.
Nothing in Client.Terminal references this project, so the graph stays acyclic.
-->
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+123
View File
@@ -0,0 +1,123 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Session;
/// <summary>How much log a vault keeps.</summary>
/// <param name="MaxAge">How far back entries are kept.</param>
/// <param name="MaxEntries">How many entries of each kind are kept, whatever their age.</param>
/// <remarks>
/// <para>
/// Two limits rather than one, and whichever bites first wins. An age alone lets somebody who connects two
/// hundred times a day accumulate a log nobody wants to sync; a count alone means a quiet month of work
/// disappears the week somebody has a busy afternoon.
/// </para>
/// <para>
/// <b>Retention is not optional here the way it is for a local log file.</b> These entries sync, so keeping
/// them for ever costs every machine in the vault the bandwidth and the storage — which is the price of the
/// decision that made them auditable in the first place.
/// </para>
/// </remarks>
public sealed record LogRetention(TimeSpan MaxAge, int MaxEntries)
{
/// <summary>Ninety days, or five thousand entries of each kind.</summary>
public static LogRetention Default { get; } = new(TimeSpan.FromDays(90), 5_000);
}
/// <summary>What one pruning pass removed.</summary>
/// <param name="Connections">Connection entries deleted.</param>
/// <param name="Activity">Activity entries deleted.</param>
public sealed record LogPruneResult(int Connections, int Activity)
{
/// <summary>Whether anything went.</summary>
public bool RemovedAnything => Connections > 0 || Activity > 0;
}
/// <summary>
/// Removes log entries a vault has agreed to stop keeping.
/// </summary>
/// <remarks>
/// <para>
/// <b>A real tombstone delete that pushes</b>, because these are synced items — so pruning is not a local
/// tidy-up and cannot be run on a whim. It goes once when a vault opens and at most once per auto-sync tick
/// behind a last-pruned stamp; the alternative, a timer of its own, would be a second thing waking a laptop
/// up to write to a server.
/// </para>
/// <para>
/// <b>Age is read from the entry, not from the item.</b> A connection entry knows when the connection
/// started and an activity entry knows when the change happened, and both are the times a person means. The
/// item id's own v7 timestamp is close but not the same — it is when the entry was <em>written</em>, which
/// for a connection is when it ended.
/// </para>
/// </remarks>
public static class LogPruner
{
/// <summary>Deletes whatever falls outside the retention policy.</summary>
/// <param name="session">The open vault.</param>
/// <param name="retention">What to keep.</param>
/// <param name="now">The moment to measure age from.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// Reads both logs in full, which is what makes the count limit possible at all: neither the server nor
/// the local mirror can order encrypted entries, so the only place that can decide which five thousand
/// to keep is a client that has decrypted them.
/// </remarks>
public static async Task<LogPruneResult> PruneAsync(
VaultSession session,
LogRetention retention,
DateTimeOffset now,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(retention);
var connections = await session.ConnectionLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(false);
var activity = await session.ActivityLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(false);
var cutoff = now - retention.MaxAge;
var staleConnections = Stale(
connections.Items, retention, cutoff, entry => entry.Secret.StartedAt);
var staleActivity = Stale(activity.Items, retention, cutoff, entry => entry.Secret.At);
foreach (var entry in staleConnections)
{
await session.ConnectionLog
.DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
.ConfigureAwait(false);
}
foreach (var entry in staleActivity)
{
await session.ActivityLog
.DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
.ConfigureAwait(false);
}
return new LogPruneResult(staleConnections.Count, staleActivity.Count);
}
/// <summary>The ids of the entries that fall outside the policy, newest kept.</summary>
private static IReadOnlyList<Guid> Stale<TSecret>(
IReadOnlyList<VaultItem<TSecret>> entries,
LogRetention retention,
DateTimeOffset cutoff,
Func<VaultItem<TSecret>, DateTimeOffset> at)
where TSecret : class, IVaultSecret
{
var ordered = entries.OrderByDescending(at).ToArray();
return
[
.. ordered
.Where((entry, index) => index >= retention.MaxEntries || at(entry) < cutoff)
.Select(entry => entry.EntityId),
];
}
}
@@ -134,6 +134,22 @@ public interface IVaultServer : IDisposable
/// <summary>Pull and push.</summary> /// <summary>Pull and push.</summary>
ISyncApi Sync { get; } ISyncApi Sync { get; }
/// <summary>Teams, their members, and the vaults they own.</summary>
ITeamApi Teams { get; }
/// <summary>
/// The public-key directory, and the key log that makes an answer from it checkable.
/// </summary>
/// <remarks>
/// Exposed as one member because the two are only ever used together: a directory answer is a claim
/// the server makes about somebody else's key, and the log is what turns it into something a client
/// can verify. See <c>KeyLogAudit</c>.
/// </remarks>
IDirectoryApi Directory { get; }
/// <summary>Vault key grants: who can open a vault, and who let them.</summary>
IVaultGrantApi Grants { get; }
/// <summary>Obtains the identity provider's signature over a key statement.</summary> /// <summary>Obtains the identity provider's signature over a key statement.</summary>
IKeyBindingAuthorizer KeyBinding { get; } IKeyBindingAuthorizer KeyBinding { get; }
@@ -213,6 +229,15 @@ public sealed class ServerConnection : IVaultServer
/// <inheritdoc /> /// <inheritdoc />
public ISyncApi Sync => Api; public ISyncApi Sync => Api;
/// <inheritdoc />
public ITeamApi Teams => Api;
/// <inheritdoc />
public IDirectoryApi Directory => Api;
/// <inheritdoc />
public IVaultGrantApi Grants => Api;
/// <inheritdoc /> /// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => Oidc; public IKeyBindingAuthorizer KeyBinding => Oidc;
+175 -17
View File
@@ -26,6 +26,25 @@ public sealed record ConflictNotice(
IReadOnlyList<ConflictDetailEntry> Fields, IReadOnlyList<ConflictDetailEntry> Fields,
DateTimeOffset DetectedAt); DateTimeOffset DetectedAt);
/// <summary>One vault's outcome from a pass over all of them.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, so a message about it can name it.</param>
/// <param name="Report">What the pass did, when it completed.</param>
/// <param name="Failure">
/// Why it did not, when it failed. Carried rather than thrown so one unreachable team vault cannot
/// leave the others unsynced — and reported rather than swallowed, because a vault that silently
/// stopped syncing is the worst of the three outcomes.
/// </param>
public sealed record VaultSyncReport(
Guid VaultId,
string Name,
SyncReport? Report,
Exception? Failure)
{
/// <summary>Whether this vault synced.</summary>
public bool Succeeded => Report is not null;
}
/// <summary> /// <summary>
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable. /// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
/// </summary> /// </summary>
@@ -41,13 +60,23 @@ public sealed record ConflictNotice(
/// perfectly usable with no network at all and syncing is the occasional thing that needs one. /// perfectly usable with no network at all and syncing is the occasional thing that needs one.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class VaultSession : IAsyncDisposable public sealed partial class VaultSession : IAsyncDisposable
{ {
private readonly UserSecretBundle bundle; private readonly UserSecretBundle bundle;
private readonly LocalCacheProtector protector; private readonly LocalCacheProtector protector;
private readonly VaultKeyring keyring; private readonly VaultKeyring keyring;
private readonly TimeProvider clock; private readonly TimeProvider clock;
private readonly SyncOptions options; private readonly SyncOptions options;
/// <summary>
/// Records what is done to this vault's items, for as long as this session lasts.
/// </summary>
/// <remarks>
/// Owned here rather than by the shell, unlike the connection recorder beside it. An edit is finished by
/// the time it is recorded, so nothing about it can outlive the session — where a shell genuinely can.
/// </remarks>
private readonly ActivityRecorder activity;
private bool disposed; private bool disposed;
internal VaultSession( internal VaultSession(
@@ -78,21 +107,51 @@ public sealed class VaultSession : IAsyncDisposable
Vault = new VaultStore(caches, clock); Vault = new VaultStore(caches, clock);
Unlock = new UnlockStore(caches, clock); Unlock = new UnlockStore(caches, clock);
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock); SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
Hosts = new HostRepository(Items, Outbox, keyring); // The two log repositories first, and unaudited: the recorder writes through one of them, so a log
SshKeys = new SshKeyRepository(Items, Outbox, keyring); // that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
Credentials = new CredentialRepository(Items, Outbox, keyring); // actually stops it; building them first is what lets the recorder exist before the kinds that use
KnownHosts = new KnownHostRepository(Items, Outbox, keyring); // it. See ActivityRecorder.
ConnectionLog = new ConnectionLogRepository(Items, Outbox, keyring);
ActivityLog = new ActivityLogRepository(Items, Outbox, keyring);
activity = new ActivityRecorder(
ActivityLog, activeVaultId, profile.UserId, Environment.MachineName, clock);
Hosts = new HostRepository(Items, Outbox, keyring, activity);
SshKeys = new SshKeyRepository(Items, Outbox, keyring, activity);
Credentials = new CredentialRepository(Items, Outbox, keyring, activity);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity);
HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity);
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
} }
/// <summary>Who this session belongs to, and the material that unlocked it.</summary> /// <summary>Who this session belongs to, and the material that unlocked it.</summary>
public StoredUnlockMaterial Profile { get; } public StoredUnlockMaterial Profile { get; }
/// <summary>Every vault this user can reach, readable or not.</summary> /// <summary>Every vault this user can reach, readable or not.</summary>
public IReadOnlyList<StoredVault> Vaults { get; } /// <remarks>
/// Re-read rather than fixed at unlock: a vault a teammate shares arrives mid-session, and one
/// whose grant is withdrawn stops being readable mid-session too. <see cref="RefreshVaultsAsync"/>
/// is what moves it, and it is the only thing that does.
/// </remarks>
public IReadOnlyList<StoredVault> Vaults { get; private set; }
/// <summary>The vault the interface is showing. The personal one, for now.</summary> /// <summary>
/// The vault new items are created in.
/// </summary>
/// <remarks>
/// One vault is the write target, not the read set — reading spans every vault the keyring opened.
/// It stays the first readable one, which is the personal vault whenever there is one, because an
/// application that silently filed a new host into a team's vault because that was the last thing
/// selected would be the wrong default in the one direction that is hard to undo.
/// </remarks>
public Guid ActiveVaultId { get; } public Guid ActiveVaultId { get; }
/// <summary>Every vault this session actually holds a key for.</summary>
public IEnumerable<StoredVault> ReadableVaults =>
Vaults.Where(vault => keyring.CanRead(vault.VaultId));
/// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary> /// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
public HostRepository Hosts { get; } public HostRepository Hosts { get; }
@@ -114,6 +173,36 @@ public sealed class VaultSession : IAsyncDisposable
/// </remarks> /// </remarks>
public KnownHostRepository KnownHosts { get; } public KnownHostRepository KnownHosts { get; }
/// <summary>The groups hosts are filed under, decrypted, with unpushed local changes laid over them.</summary>
/// <remarks>
/// Membership is not in here. Each host carries its own <c>GroupId</c>, so a group is only ever a name —
/// which is what makes filing two hosts at once on two machines two independent writes rather than one
/// contested one.
/// </remarks>
public HostGroupRepository HostGroups { get; }
/// <summary>Saved commands, decrypted, with unpushed local changes laid over them.</summary>
public SnippetRepository Snippets { get; }
/// <summary>S3-compatible buckets and their credentials, decrypted.</summary>
/// <remarks>
/// Read when the file screen builds its picker, and the object-store client is constructed from the
/// result. Nothing here is on a transfer's data path.
/// </remarks>
public ObjectStoreRepository ObjectStores { get; }
/// <summary>The connections this vault has recorded, decrypted.</summary>
/// <remarks>
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
/// entry is created once, on the teardown path of a session, and encrypting on that thread is how
/// closing the application comes to take four seconds — see that type for the queue that keeps the two
/// apart.
/// </remarks>
public ConnectionLogRepository ConnectionLog { get; }
/// <summary>The keychain changes this vault has recorded, decrypted.</summary>
public ActivityLogRepository ActivityLog { get; }
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary> /// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened; public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
@@ -179,10 +268,14 @@ public sealed class VaultSession : IAsyncDisposable
return SignIn.ForgetAsync(cancellationToken); return SignIn.ForgetAsync(cancellationToken);
} }
/// <summary>Runs one synchronisation pass over the active vault.</summary> /// <summary>Runs one synchronisation pass over one vault.</summary>
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param> /// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
/// <param name="vaultId">The vault to sync.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
public Task<SyncReport> SyncAsync(ISyncApi api, CancellationToken cancellationToken) public Task<SyncReport> SyncAsync(
ISyncApi api,
Guid vaultId,
CancellationToken cancellationToken)
{ {
ObjectDisposedException.ThrowIf(disposed, this); ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api); ArgumentNullException.ThrowIfNull(api);
@@ -190,7 +283,51 @@ public sealed class VaultSession : IAsyncDisposable
var engine = new SyncEngine( var engine = new SyncEngine(
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options); api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
return engine.SyncAsync(ActiveVaultId, cancellationToken); return engine.SyncAsync(vaultId, cancellationToken);
}
/// <summary>
/// Runs one synchronisation pass over every vault this session can read.
/// </summary>
/// <returns>One report per vault, in the order they were synced.</returns>
/// <remarks>
/// <para>
/// Sequential rather than concurrent. Each vault has its own cursor and its own outbox, so nothing
/// forces the order — but a client that opened one connection per vault would multiply its request
/// rate by the number of teams somebody is in, against a server the same person is also using
/// interactively. Vaults are few and passes are cheap.
/// </para>
/// <para>
/// A vault that throws does not stop the rest. One team's vault being unreachable — a revoked grant
/// noticed mid-pass, a server-side fault — is not a reason to leave the personal vault unsynced,
/// and the failure is reported per vault rather than as one exception naming none of them.
/// </para>
/// </remarks>
public async Task<IReadOnlyList<VaultSyncReport>> SyncAllAsync(
ISyncApi api,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var reports = new List<VaultSyncReport>();
foreach (var vault in ReadableVaults.ToList())
{
try
{
var report = await SyncAsync(api, vault.VaultId, cancellationToken)
.ConfigureAwait(false);
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, report, null));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, null, exception));
}
}
return reports;
} }
/// <summary> /// <summary>
@@ -316,7 +453,8 @@ public sealed class VaultSession : IAsyncDisposable
ArgumentNullException.ThrowIfNull(deviceKeys); ArgumentNullException.ThrowIfNull(deviceKeys);
// Before any await that could yield, because on Windows this reaches a consent dialog and a dialog // Before any await that could yield, because on Windows this reaches a consent dialog and a dialog
// needs the thread it was called from to be one that pumps messages. See WindowsDeviceKeyStore. // needs the thread it was called from to be one that pumps messages. See the desktop head's
// WindowsDeviceKeyStore — this layer only knows it is handed an IDeviceKeyStore.
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false); await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false);
var stored = await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false); var stored = await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false);
@@ -369,33 +507,53 @@ public sealed class VaultSession : IAsyncDisposable
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken); return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
} }
/// <summary>How many local changes are waiting to be pushed.</summary> /// <summary>
/// How many local changes the <em>user</em> has made that are waiting to be pushed.
/// </summary>
/// <remarks>
/// <para>
/// <b>Log entries are excluded, and the exclusion is the honest reading rather than a convenience.</b>
/// This number is shown in the titlebar and it answers one question: how much of my work is not yet
/// safe anywhere else. A connection that was recorded is not somebody's work — nobody typed it, nobody
/// would re-enter it if this machine were lost, and an entry queued a moment after a save would leave
/// the titlebar claiming an unsynced change immediately after reporting a successful sync.
/// </para>
/// <para>
/// The entries are still pushed, on the next pass like anything else. What they are kept out of is a
/// count that means something narrower than "rows in the outbox".
/// </para>
/// </remarks>
public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken) public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
{ {
ObjectDisposedException.ThrowIf(disposed, this); ObjectDisposedException.ThrowIf(disposed, this);
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false); var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
return pending.Count;
return pending.Count(operation => operation.EntityType is not (
SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry));
} }
/// <inheritdoc /> /// <inheritdoc />
public ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (disposed) if (disposed)
{ {
return ValueTask.CompletedTask; return;
} }
disposed = true; disposed = true;
// Before the keys go, and it waits — briefly. Anything queued has to be encrypted under a vault key
// that is about to be zeroed, so a fire-and-forget here would silently lose the last few entries of
// every session. The wait is bounded inside the recorder; locking never stalls on it.
await activity.DisposeAsync().ConfigureAwait(false);
// Order is not important — none of these depend on another — but completeness is. Missing one // Order is not important — none of these depend on another — but completeness is. Missing one
// leaves key material in memory for the life of the process, which is the opposite of what // leaves key material in memory for the life of the process, which is the opposite of what
// locking is supposed to mean. // locking is supposed to mean.
keyring.Dispose(); keyring.Dispose();
protector.Dispose(); protector.Dispose();
bundle.Dispose(); bundle.Dispose();
return ValueTask.CompletedTask;
} }
private static ConflictNotice Describe(StoredConflict conflict) private static ConflictNotice Describe(StoredConflict conflict)
+293
View File
@@ -0,0 +1,293 @@
using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session;
/// <summary>What a share attempt did.</summary>
/// <param name="Shared">Whether a grant was recorded.</param>
/// <param name="Verification">
/// How the recipient's key was checked. Present whether or not the share went ahead, because a refusal
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
/// </param>
/// <param name="Message">One line for a person. Never contains key material.</param>
public sealed record ShareOutcome(
bool Shared,
RecipientVerification Verification,
string Message);
/// <summary>
/// Sharing, from the side that holds the keys.
/// </summary>
/// <remarks>
/// <para>
/// These live on <see cref="VaultSession"/> rather than in a service above it for the reason
/// registering a device does: wrapping a vault key is the one step only an unlocked session can
/// perform, and this type is the keyring's custodian. Everything else — the calls, the directory —
/// arrives as a parameter, so the session still knows nothing about how either is implemented.
/// </para>
/// <para>
/// <b>Nothing here trusts the server's answer about somebody else's key.</b> Every share reads the
/// whole key log, verifies its hash chain, and refuses unless the directory's answer appears in it
/// unchanged. That check is the difference between end-to-end encryption and a server that can read
/// everything by handing out a key of its own; see <see cref="KeyLogAudit"/> and ADR 0001.
/// </para>
/// </remarks>
public sealed partial class VaultSession
{
/// <summary>
/// Creates a vault owned by a team, generating its key here.
/// </summary>
/// <param name="api">The team calls.</param>
/// <param name="teamId">The owning team.</param>
/// <param name="name">Display name. Plaintext, as all vault names are.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The new vault, already readable by this session.</returns>
/// <remarks>
/// The key never leaves this process in the clear: it is generated here, sealed to this user's own
/// encryption key, and the seal is what the server stores. The creator's grant carries no key log
/// head, exactly as a personal vault's does not — there is no third party whose key could have been
/// substituted when you wrap something to yourself.
/// </remarks>
public async Task<StoredVault> CreateTeamVaultAsync(
ITeamApi api,
Guid teamId,
string name,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var vaultId = Guid.CreateVersion7();
var vaultKey = VaultKeys.Create();
var now = clock.GetUtcNow();
try
{
var request = BuildCreateRequest(vaultId, vaultKey, name, now);
var summary = await api.CreateTeamVaultAsync(teamId, request, cancellationToken)
.ConfigureAwait(false);
var stored = ToStored(summary);
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
// Adopted rather than unwrapped from the response: this process generated the key, so
// unwrapping the server's copy of our own seal would be a round trip to learn something we
// already know. The keyring takes ownership from here.
keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return stored;
}
catch
{
// Never reached the keyring, so this is the only thing that can release it.
CryptographicOperations.ZeroMemory(vaultKey);
throw;
}
}
/// <summary>
/// Wraps a vault's key to another member, after verifying their published key.
/// </summary>
/// <param name="grants">The grant calls.</param>
/// <param name="directory">The directory and the key log that makes it checkable.</param>
/// <param name="vaultId">The vault to share.</param>
/// <param name="recipientUserId">Who to share it with.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// <para>
/// The verification is not optional and is not a parameter. A caller that could pass
/// <c>skipChecks: true</c> is a caller that will, on the day the log is briefly unreachable, and the
/// resulting grant is indistinguishable from a correct one afterwards.
/// </para>
/// <para>
/// What this still cannot promise is that the key belongs to the person you meant. Compare
/// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
/// that is the only step that closes the gap, and the outcome message says so.
/// </para>
/// </remarks>
public async Task<ShareOutcome> ShareVaultAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
Guid vaultId,
Guid recipientUserId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
if (!keyring.TryGet(vaultId, out var vaultKey, out var keyGeneration))
{
throw new VaultUnreadableException(vaultId);
}
var entry = await directory.LookupByIdAsync(recipientUserId, cancellationToken)
.ConfigureAwait(false);
var log = await KeyLogAudit.ReadAsync(directory, cancellationToken).ConfigureAwait(false);
var verification = KeyLogAudit.Verify(log, entry);
if (!verification.IsVerified)
{
return new ShareOutcome(false, verification, verification.Message);
}
var recipient = verification.Recipient!;
await IssueAsync(grants, vaultId, vaultKey, keyGeneration, recipient, cancellationToken)
.ConfigureAwait(false);
return new ShareOutcome(
true,
verification,
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
+ "its own only proves this server has been consistent with itself.");
}
/// <summary>
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
/// </summary>
/// <returns>How many vaults this call made readable that were not before.</returns>
/// <remarks>
/// Called after a share and on a periodic pass. A vault somebody shared a minute ago arrives as a
/// new entry with a wrapped key attached; one whose grant was revoked arrives without one, and is
/// marked unreadable rather than quietly dropped so the interface can say what happened. Items
/// already pulled are deliberately left alone — see <see cref="VaultStore.ReplaceAllAsync"/>.
/// </remarks>
public async Task<int> RefreshVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
await Vault.ReplaceAllAsync([.. me.Vaults.Select(ToStored)], cancellationToken)
.ConfigureAwait(false);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
var admitted = 0;
foreach (var vault in Vaults)
{
if (keyring.CanRead(vault.VaultId))
{
continue;
}
if (keyring.TryAdmit(bundle, vault))
{
admitted++;
}
else
{
keyring.MarkUnreadable(vault.VaultId);
}
}
return admitted;
}
/// <summary>Signs and posts one grant.</summary>
private async Task IssueAsync(
IVaultGrantApi grants,
Guid vaultId,
ReadOnlyMemory<byte> vaultKey,
uint keyGeneration,
VerifiedRecipient recipient,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var entry = recipient.Entry;
var wrapped = VaultKeys.WrapTo(
vaultKey.Span, entry.EncryptionPublicKey, vaultId, keyGeneration);
var ownFingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
keyGeneration,
GrantPurpose.Member,
granteeUserId: entry.UserId,
granteeKeyFingerprint: recipient.Fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: ownFingerprint,
// Present, unlike a self-grant's. This is the third-party case the head exists for: it
// records which view of the key log this client held while wrapping, so a server showing
// two clients different logs has to keep both stories straight for ever after.
keyLogHead: recipient.KeyLogHead,
grantedAt: now);
await grants.IssueVaultGrantAsync(
vaultId,
new IssueVaultGrantRequest(
RecipientUserId: entry.UserId,
RecipientKeyFingerprint: recipient.Fingerprint,
KeyGeneration: keyGeneration,
WrappedVaultKey: wrapped,
KeyLogHead: recipient.KeyLogHead,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now),
cancellationToken)
.ConfigureAwait(false);
}
/// <remarks>
/// The signature covers the vault id, so the id has to be chosen before anything is wrapped — which
/// is also what makes a create whose response was lost safe to send again.
/// </remarks>
private CreateTeamVaultRequest BuildCreateRequest(
Guid vaultId,
byte[] vaultKey,
string name,
DateTimeOffset now)
{
var wrapped = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
keyGeneration: 1,
GrantPurpose.Member,
granteeUserId: Profile.UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: fingerprint,
keyLogHead: default,
grantedAt: now);
return new CreateTeamVaultRequest(
VaultId: vaultId,
Name: name,
WrappedVaultKey: wrapped,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now);
}
private static StoredVault ToStored(VaultSummary summary) =>
new(
summary.VaultId,
summary.Name,
summary.IsPersonal,
summary.TeamId,
summary.KeyGeneration,
summary.Permissions,
summary.WrappedVaultKey,
summary.RekeyRequired);
}
@@ -141,6 +141,7 @@
"dodossh.client.ssh": { "dodossh.client.ssh": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )" "SSH.NET": "[2025.1.0, )"
} }
}, },
@@ -163,6 +164,12 @@
"DodoSSH.Crypto": "[1.0.0, )" "DodoSSH.Crypto": "[1.0.0, )"
} }
}, },
"dodossh.client.terminal": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.contracts": { "dodossh.contracts": {
"type": "Project" "type": "Project"
}, },
@@ -27,6 +27,19 @@
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" /> <ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" /> <ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" /> <ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
<!--
Both arrived with the view models rather than being chosen here. ImportViewModel reads an
~/.ssh/config, and TransfersViewModel puts a bucket behind IRemoteFileStore beside an SFTP host.
Worth knowing for the Android head, which gets both transitively and will use neither at first:
scoped storage means there is no ~/.ssh/config to find, and file transfer is out of its first
scope by decision. Neither is a problem — they are managed assemblies that simply go unused — but
the day the phone grows a file screen, the bucket is the half that ports and the local pane is not.
See docs/android-port.md.
-->
<ProjectReference Include="../DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,229 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
/// <remarks>
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
/// rather than an action.
/// </remarks>
internal sealed partial class ImportRowViewModel : ObservableObject
{
private readonly ImportedHost host;
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
{
this.host = host;
AlreadyPresent = alreadyPresent;
// A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
// for one machine is a thing people genuinely want — but it should take a click rather than be the
// default.
IsSelected = !alreadyPresent;
}
internal ImportedHost Host => host;
internal string Alias => host.Alias;
internal string Address => host.Address;
/// <summary>Whether a host with this address is already in the keychain.</summary>
internal bool AlreadyPresent { get; }
internal string Badge => AlreadyPresent ? "already here" : string.Empty;
internal bool HasBadge => AlreadyPresent;
/// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
/// <remarks>
/// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
/// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
/// connection that does not work.
/// </remarks>
internal string Authentication => host.IdentityFiles.Count switch
{
0 => "password",
1 => $"a key on disk · {host.IdentityFiles[0]}",
var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
};
internal bool HasWarnings => host.Warnings.Count > 0;
internal string Warnings => string.Join(" ", host.Warnings);
[ObservableProperty]
private bool isSelected;
}
/// <summary>
/// Reading <c>~/.ssh/config</c> and offering what it found.
/// </summary>
/// <remarks>
/// <para>
/// <b>Two steps, and the first one writes nothing.</b> Scanning reads the file and shows what it means;
/// importing is a separate press. That split is the feature: an <c>ssh_config</c> is a file this
/// application did not write and may contain forty entries for machines that no longer exist, so the
/// interesting question is not "can it be parsed" but "which of these did you actually want".
/// </para>
/// <para>
/// <b>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
/// for making one deliberately, and pasting an existing one is a deliberate act too.
/// </para>
/// </remarks>
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
{
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
/// <summary>What was skipped or flattened, at document level.</summary>
internal ObservableCollection<string> Warnings { get; } = [];
/// <summary>The file this would read, shown so nobody has to guess which one it means.</summary>
internal string ConfigPath => locator.ConfigPath;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool hasScanned;
[ObservableProperty]
private bool isBusy;
internal bool HasRows => Rows.Count > 0;
internal bool HasWarnings => Warnings.Count > 0;
internal int SelectedCount => Rows.Count(row => row.IsSelected);
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
{
Rows.Clear();
Warnings.Clear();
HasScanned = false;
if (!locator.Exists)
{
Status = $"There is no {locator.ConfigPath} on this machine.";
RaiseListState();
return;
}
IsBusy = true;
try
{
var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);
foreach (var host in import.Hosts)
{
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
}
foreach (var warning in import.Warnings)
{
Warnings.Add(warning);
}
HasScanned = true;
Status = Rows.Count == 0
? "Nothing in that file could be imported as a host."
: $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
}
catch (IOException failure)
{
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
}
catch (UnauthorizedAccessException failure)
{
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
}
finally
{
IsBusy = false;
RaiseListState();
}
}
/// <summary>Stores the ticked hosts.</summary>
[RelayCommand]
private async Task ImportAsync(CancellationToken cancellationToken)
{
var chosen = Rows.Where(row => row.IsSelected).ToList();
if (chosen.Count == 0)
{
Status = "Nothing is ticked.";
return;
}
IsBusy = true;
try
{
var imported = await vault
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
.ConfigureAwait(true);
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
// pressing the button twice harmless and visible rather than harmless and confusing.
foreach (var row in Rows.ToList())
{
Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
}
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
}
finally
{
IsBusy = false;
RaiseListState();
}
}
/// <summary>Ticks or unticks everything at once.</summary>
[RelayCommand]
private void ToggleAll()
{
var target = SelectedCount < Rows.Count;
foreach (var row in Rows)
{
row.IsSelected = target;
}
RaiseListState();
}
internal void NoteSelectionChanged() => RaiseListState();
/// <remarks>
/// Matched on where a host points rather than on what it is called. Two entries with different aliases
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
/// to import a duplicate of something already stored under another name.
/// </remarks>
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
&& existing.Host.Port == host.Port
&& string.Equals(existing.Host.Username, host.Username, StringComparison.OrdinalIgnoreCase));
private void RaiseListState()
{
OnPropertyChanged(nameof(HasRows));
OnPropertyChanged(nameof(HasWarnings));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportLabel));
}
}
@@ -0,0 +1,245 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One pinned host key, as a row in the list.</summary>
/// <remarks>
/// <para>
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
/// What was wrong was that nothing ever showed them.
/// </para>
/// <para>
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
/// of pinning one is to compare it with what they published.
/// </para>
/// </remarks>
internal sealed class KnownHostRowViewModel(
VaultItem<KnownHostSecret> pin,
bool isDialledByAHost,
Guid vaultId,
string vaultName)
{
/// <summary>Which vault this pin lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => vaultName;
internal Guid EntityId => pin.EntityId;
internal KnownHostSecret Pin => pin.Secret;
internal string Host => pin.Secret.Host;
internal int Port => pin.Secret.Port;
internal string Algorithm => pin.Secret.Algorithm;
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
internal string Label => pin.Secret.Label;
/// <summary>The fingerprint, in full.</summary>
/// <remarks>
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
/// this whole mechanism exists to replace.
/// </remarks>
internal string Fingerprint => pin.Secret.Fingerprint;
/// <summary>
/// Whether any host in this vault actually dials the endpoint this pin is for.
/// </summary>
/// <remarks>
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
/// worth deleting on the user's behalf.
/// </remarks>
internal bool IsDialledByAHost { get; } = isDialledByAHost;
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
internal string Badge => IsDialledByAHost
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
: "no host uses this";
/// <summary>
/// When this pin was approved, as far as anything here can tell.
/// </summary>
/// <remarks>
/// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
/// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
/// renders as a dash rather than as a guess.
/// </remarks>
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
: "—";
}
/// <summary>
/// The host keys this keychain has approved, and how to withdraw one.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
/// it produces, neither of which the vault has any use for.
/// </para>
/// <para>
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
/// answer a question nobody is asking.
/// </para>
/// </remarks>
internal sealed partial class KnownHostsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
internal KnownHostsViewModel(VaultViewModel vault)
{
this.vault = vault;
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
// copy of a trust decision is the one kind of staleness that matters here.
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
Rebuild();
}
/// <summary>The pins this filter admits, in the order the vault produced them.</summary>
/// <remarks>
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
/// — host, then port, then algorithm — is the one worth keeping.
/// </remarks>
internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];
[ObservableProperty]
private string filter = string.Empty;
/// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
/// <remarks>
/// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
/// and there is no reason for it to learn about this screen.
/// </remarks>
[ObservableProperty]
private KnownHostRowViewModel? selected;
internal bool HasPins => vault.KnownHostPins.Count > 0;
internal bool HasVisiblePins => VisiblePins.Count > 0;
internal bool HasSelection => Selected is not null;
/// <summary>What the whole list amounts to, in one line.</summary>
/// <remarks>
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
/// might want to act on, and counting them is cheaper than reading a badge column.
/// </remarks>
internal string Summary
{
get
{
var total = vault.KnownHostPins.Count;
if (total == 0)
{
return string.Empty;
}
var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
return unused == 0
? pins
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
}
}
internal string EmptyMessage => HasPins
? "No approved host key matches that."
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
+ "check — approving it puts it here.";
/// <summary>Withdraws trust in the selected pin.</summary>
/// <remarks>
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
/// server are the other ones.
/// </remarks>
[RelayCommand]
private async Task ForgetSelectedAsync()
{
if (Selected is null)
{
return;
}
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
}
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
partial void OnFilterChanged(string value) => Rebuild();
partial void OnSelectedChanged(KnownHostRowViewModel? value)
{
vault.SelectedKnownHost = value;
OnPropertyChanged(nameof(HasSelection));
}
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
private void Rebuild()
{
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
// writes that null straight back before the refill can matter.
var selectedId = Selected?.EntityId;
VisiblePins.Clear();
foreach (var pin in vault.KnownHostPins.Where(Matches))
{
VisiblePins.Add(pin);
}
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
OnPropertyChanged(nameof(HasPins));
OnPropertyChanged(nameof(HasVisiblePins));
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(EmptyMessage));
}
private bool Matches(KnownHostRowViewModel pin)
{
if (string.IsNullOrWhiteSpace(Filter))
{
return true;
}
var needle = Filter.Trim();
return Contains(pin.Host, needle)
|| Contains(pin.Algorithm, needle)
|| Contains(pin.Fingerprint, needle)
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
}
private static bool Contains(string haystack, string needle) =>
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
@@ -0,0 +1,293 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Which log the screen is showing.</summary>
internal enum LogSection
{
/// <summary>Connections that were made.</summary>
Connections,
/// <summary>Changes made to keychain items.</summary>
Activity,
}
/// <summary>One connection, as a row.</summary>
internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> entry, bool isLive)
{
internal Guid EntityId => entry.EntityId;
internal string HostLabel => entry.Secret.HostLabel;
internal string Address => entry.Secret.Address;
/// <summary>When it started, in the reader's own conventions.</summary>
/// <remarks>
/// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
/// difference is the reason each is right. There, two panes are read against one another and a
/// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
/// machine", which is a question about the reader's own day. <c>InvariantGlobalization</c> is false in
/// the client csproj precisely so this works.
/// </remarks>
internal string Started =>
entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
/// <summary>
/// How long it lasted, or that it has not finished.
/// </summary>
/// <remarks>
/// <b>"still open" and not a dash.</b> A dash reads as "nothing was recorded", and the two are opposite
/// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
/// session has no entry at all until it closes, so this state comes from the workspace rather than from
/// the vault; see <see cref="LogsViewModel"/>.
/// </remarks>
internal string Duration => isLive
? "still open"
: Humanise(entry.Secret.Duration);
internal bool IsLive => isLive;
internal string Outcome => entry.Secret.Outcome switch
{
ConnectionOutcome.Failed => "failed",
ConnectionOutcome.Refused => "host key refused",
_ => string.Empty,
};
internal bool HasOutcome => Outcome.Length > 0;
/// <summary>Whether this was a terminal or the file browser.</summary>
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
internal string DeviceName => entry.Secret.DeviceName;
/// <remarks>
/// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
/// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
/// rounding themselves.
/// </remarks>
private static string Humanise(TimeSpan duration)
{
if (duration < TimeSpan.FromMinutes(1))
{
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
}
if (duration < TimeSpan.FromHours(1))
{
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
}
return string.Create(
CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
}
}
/// <summary>One keychain change, as a row.</summary>
internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry)
{
internal Guid EntityId => entry.EntityId;
internal string ItemLabel => entry.Secret.ItemLabel;
internal string ItemKind => entry.Secret.ItemKind;
internal string Operation => entry.Secret.Operation switch
{
ActivityOperation.Created => "created",
ActivityOperation.Deleted => "deleted",
_ => "changed",
};
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
/// <summary>Which fields changed. Never what they changed to.</summary>
internal string ChangedFields => entry.Secret.ChangedFields;
internal bool HasChangedFields => ChangedFields.Length > 0;
internal string DeviceName => entry.Secret.DeviceName;
}
/// <summary>
/// What has been connected to, and what has been changed.
/// </summary>
/// <remarks>
/// <para>
/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
/// section switch and one thing neither log knows: which connections are happening <em>now</em>. An entry is
/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
/// workspace, and this screen is where the two are put side by side.
/// </para>
/// <para>
/// <b>Read on demand rather than kept in step.</b> Unlike the host list, a log is not something a background
/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
/// nobody is looking at.
/// </para>
/// </remarks>
internal sealed partial class LogsViewModel : ObservableObject
{
private readonly VaultSession session;
private readonly Func<IReadOnlyList<LiveConnection>> live;
/// <param name="session">The open vault, which holds both logs.</param>
/// <param name="live">
/// The connections that are open right now. A function rather than a list, because tabs open and close
/// while this screen is showing and it is not told about either.
/// </param>
internal LogsViewModel(VaultSession session, Func<IReadOnlyList<LiveConnection>> live)
{
this.session = session;
this.live = live;
}
/// <summary>Connections, newest first, with anything still open at the top.</summary>
internal ObservableCollection<ConnectionLogRowViewModel> Connections { get; } = [];
/// <summary>Keychain changes, newest first.</summary>
internal ObservableCollection<ActivityLogRowViewModel> Activity { get; } = [];
/// <remarks>
/// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
/// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
/// a command can refuse it.
/// </remarks>
[ObservableProperty]
private LogSection section;
[ObservableProperty]
private bool isBusy;
[ObservableProperty]
private string status = string.Empty;
internal bool ShowsConnections => Section is LogSection.Connections;
internal bool ShowsActivity => Section is LogSection.Activity;
internal bool HasConnections => Connections.Count > 0;
internal bool HasActivity => Activity.Count > 0;
internal string EmptyMessage => Section is LogSection.Connections
? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
+ "top and gets its line when you close the tab."
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
+ "names of the fields that changed, never their contents.";
/// <summary>Shows one of the two logs.</summary>
[RelayCommand]
private void ShowSection(LogSection section) => Section = section;
/// <summary>Re-reads both logs.</summary>
[RelayCommand]
private async Task RefreshAsync(CancellationToken cancellationToken)
{
if (IsBusy)
{
return;
}
IsBusy = true;
try
{
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = string.Empty;
}
catch (OperationCanceledException)
{
// Leaving the screen.
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
/// <summary>Reads both logs into the lists.</summary>
internal async Task ReloadAsync(CancellationToken cancellationToken)
{
var connections = await session.ConnectionLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var activity = await session.ActivityLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
Connections.Clear();
// The live ones first and above everything, because they are the only rows in this list that are
// still changing. They carry no entity id — there is no vault item for them yet — which is why they
// are built from a different source and marked as live rather than merged into the same shape.
foreach (var open in live())
{
Connections.Add(new ConnectionLogRowViewModel(
new VaultItem<ConnectionLogSecret>(
Guid.Empty,
new ConnectionLogSecret
{
HostLabel = open.HostLabel,
Address = open.Address,
StartedAt = open.StartedAt,
DeviceName = open.DeviceName,
},
Version: 0,
HasUnsyncedChanges: false,
IsBlocked: false,
IsReadOnly: false),
isLive: true));
}
foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
{
Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
}
Activity.Clear();
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
{
Activity.Add(new ActivityLogRowViewModel(entry));
}
OnPropertyChanged(nameof(HasConnections));
OnPropertyChanged(nameof(HasActivity));
}
partial void OnSectionChanged(LogSection value)
{
OnPropertyChanged(nameof(ShowsConnections));
OnPropertyChanged(nameof(ShowsActivity));
OnPropertyChanged(nameof(EmptyMessage));
}
}
/// <summary>A connection that is open right now.</summary>
/// <param name="HostLabel">What the host is called.</param>
/// <param name="Address">The address as dialled.</param>
/// <param name="StartedAt">When it opened.</param>
/// <param name="DeviceName">This machine.</param>
/// <remarks>
/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
/// that is still running has no entry there, because an entry is written once and at close — which is what
/// keeps a synced log from needing a merge.
/// </remarks>
internal sealed record LiveConnection(
string HostLabel,
string Address,
DateTimeOffset StartedAt,
string DeviceName);
@@ -6,6 +6,8 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Auth; using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh; using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage; using DodoSSH.Client.Storage;
@@ -61,7 +63,7 @@ internal enum ShellState
/// </remarks> /// </remarks>
internal enum ShellScreen internal enum ShellScreen
{ {
/// <summary>The host list and the terminals, which is where the application opens.</summary> /// <summary>The host list, which is where the application opens.</summary>
Hosts = 0, Hosts = 0,
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary> /// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
@@ -75,6 +77,52 @@ internal enum ShellScreen
/// <summary>Preferences.</summary> /// <summary>Preferences.</summary>
Preferences = 4, Preferences = 4,
/// <summary>The host keys this keychain has approved.</summary>
/// <remarks>
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
/// silent change to what every one of those means.
/// </remarks>
KnownHosts = 5,
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
/// <remarks>
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
/// something almost nobody is looking at.
/// </remarks>
Import = 6,
/// <summary>The saved commands in this keychain.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Snippets = 7,
/// <summary>What has been connected to, and what has been changed.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Logs = 8,
}
/// <summary>
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
/// </summary>
/// <remarks>
/// <para>
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
/// </para>
/// </remarks>
internal enum ShellSurface
{
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
Page = 0,
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
Terminal = 1,
} }
/// <summary> /// <summary>
@@ -126,6 +174,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock; private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile; private readonly Argon2Profile? passphraseProfile;
/// <remarks>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
/// </remarks>
private readonly Func<string, Task>? copyToClipboard;
/// <remarks> /// <remarks>
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same /// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy /// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
@@ -134,6 +189,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks> /// </remarks>
private readonly TransfersViewModel transfers; private readonly TransfersViewModel transfers;
/// <summary>
/// Where connections are recorded, for as long as a vault is open to record them into.
/// </summary>
/// <remarks>
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
/// </remarks>
private readonly ConnectionRecorder connectionLog;
private readonly TeamsViewModel teams;
private IVaultServer? connection; private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary> /// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -188,7 +254,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TimeProvider clock, TimeProvider clock,
ISftpSessionFactory sftpSessions, ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null, Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null) ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null)
{ {
this.paths = paths; this.paths = paths;
this.caches = caches; this.caches = caches;
@@ -199,9 +266,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.resume = resume; this.resume = resume;
this.clock = clock; this.clock = clock;
this.passphraseProfile = passphraseProfile; this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard;
transfers = new TransfersViewModel(sftpSessions, clock); 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);
this.workspace.ConnectionLog = connectionLog;
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
// session after unlock, and both go away again on lock. Capturing either would give this screen a
// reference that outlives what it points at — which for a session means holding vault keys past the
// moment locking is supposed to have zeroed them.
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
// Subscribed for the life of the process, because the workspace lives that long and so does the tab // Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends. // list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded; this.workspace.SessionEnded += OnWorkspaceSessionEnded;
@@ -273,6 +353,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty] [ObservableProperty]
private VaultViewModel? vault; private VaultViewModel? vault;
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
/// <remarks>
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
/// vault — unlocking, locking and signing out — cannot get out of step with it.
/// </remarks>
[ObservableProperty]
private KnownHostsViewModel? knownHostsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private ImportViewModel? importScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private SnippetsViewModel? snippetsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private LogsViewModel? logsScreen;
/// <summary>
/// The teams screen, which the window binds to whether or not a vault is open.
/// </summary>
/// <remarks>
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
/// server rather than a vault, and both of its dependencies are fetched through a function at the
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
/// to rebuild it, and the list it is showing survives both.
/// </remarks>
internal TeamsViewModel Teams => teams;
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary> /// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
/// <remarks> /// <remarks>
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked — /// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
@@ -370,9 +481,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// ---- Which screen is showing ---- // ---- Which screen is showing ----
/// <summary>
/// Which of the nav rail's screens the page area holds.
/// </summary>
/// <remarks>
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
/// It is what dismissing a terminal returns to.
/// </remarks>
[ObservableProperty] [ObservableProperty]
private ShellScreen screen; private ShellScreen screen;
/// <summary>
/// Whether the page area is showing rather than a terminal.
/// </summary>
/// <remarks>
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
/// <c>IsHostsScreen &amp;&amp; IsShowingPages</c> in a binding, so the alternative is five compound
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
/// </remarks>
internal bool IsShowingPages => Surface is ShellSurface.Page;
internal bool IsHostsScreen => Screen is ShellScreen.Hosts; internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
/// <inheritdoc cref="IsHostsScreen" /> /// <inheritdoc cref="IsHostsScreen" />
@@ -387,6 +517,51 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="IsHostsScreen" /> /// <inheritdoc cref="IsHostsScreen" />
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences; internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsImportScreen => Screen is ShellScreen.Import;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
/// <summary>
/// Whether the nav rail should light its Hosts entry.
/// </summary>
/// <remarks>
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
/// at once is one too many.
/// </remarks>
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
/// <summary> /// <summary>
/// Whether the terminal's WebView may be on screen at this instant. /// Whether the terminal's WebView may be on screen at this instant.
/// </summary> /// </summary>
@@ -396,16 +571,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the /// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the /// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a /// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences /// locked vault (the unlock card), the page area (every screen uses the full width), and the
/// screens all use the full width), and the quick-connect palette. /// quick-connect palette.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a /// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the /// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to /// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control /// to the same value.
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The /// </para>
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes. /// <para>
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
/// Avalonia draws and nothing occludes.
/// </para>
/// <para>
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
/// answered here, and it cannot be: this property has no way to know when layout ran.
/// </para> /// </para>
/// <para> /// <para>
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather /// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
@@ -414,11 +601,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree. /// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching; internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
/// <inheritdoc cref="ShellSurface" />
[ObservableProperty]
private ShellSurface surface;
/// <summary>Points the nav rail at a screen.</summary> /// <summary>Points the nav rail at a screen.</summary>
/// <remarks>
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
/// </remarks>
[RelayCommand] [RelayCommand]
private void ShowScreen(ShellScreen target) => Screen = target; private void ShowScreen(ShellScreen target)
{
Screen = target;
Surface = ShellSurface.Page;
}
// ---- Open terminals ---- // ---- Open terminals ----
@@ -470,6 +670,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)]; : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
} }
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
// leave a visible WebView with no pane in it, which reads as the application having broken.
if (Tabs.Count == 0)
{
Surface = ShellSurface.Page;
}
RaiseTabState(); RaiseTabState();
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one // Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
@@ -566,7 +774,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CloseSearch(); CloseSearch();
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
// screen — so connecting from the files screen without this would put the question behind the screen
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
// is what moves the surface to the terminal, and only if there is one.
Screen = ShellScreen.Hosts; Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId); vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken // Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
@@ -746,7 +960,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
await RunAsync( await RunAsync(
"Creating your vault. This deliberately takes a moment…", "Creating your keychain. This deliberately takes a moment…",
async () => async () =>
{ {
var chosen = Passphrase; var chosen = Passphrase;
@@ -796,7 +1010,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{ {
if (Passphrase.Length == 0) if (Passphrase.Length == 0)
{ {
StatusMessage = "Enter your vault passphrase."; StatusMessage = "Enter your keychain passphrase.";
return; return;
} }
@@ -950,23 +1164,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks> /// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken) private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{ {
// Before the vault view model, so the first connection after an unlock already knows which host keys await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync); Vault = new VaultViewModel(
session,
workspace,
knownHosts,
() => connection,
ReconnectAsync,
copyToClipboard,
connectionLog);
State = ShellState.Unlocked; State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that // Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -984,7 +1191,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// After the load, because what the transfers screen takes from the vault is the host list and an // After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock. // empty one would leave its picker blank until the next unlock.
transfers.Attach(Vault, knownHosts); transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept // After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a // running while the vault was closed, so some of these hosts are connected before their rows are a
@@ -1007,6 +1214,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Vault.StartAutoSync(); Vault.StartAutoSync();
} }
/// <summary>
/// Points the two process-lifetime stores at the session that has just opened.
/// </summary>
/// <remarks>
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
/// called by the workspace — so both are attached here rather than constructed per session, and both are
/// released together on every path that closes a vault.
/// </remarks>
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
// The actor is the account that unlocked, which is what makes this an audit record rather than a
// list of events with nobody attached to them.
connectionLog.Open(session, session.Profile.UserId);
}
/// <summary> /// <summary>
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is. /// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
/// </summary> /// </summary>
@@ -1214,6 +1452,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// reappearing behind a lock screen. // reappearing behind a lock screen.
knownHosts.Close(); knownHosts.Close();
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
// about to be disposed. Tickets already open keep the repository they were opened against, so a
// shell still running closes out into the vault it was actually made in.
connectionLog.Close();
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is // Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
// holding references to them. What it does not give up is its connection or its queue — a transfer // holding references to them. What it does not give up is its connection or its queue — a transfer
// in flight is exactly the work this method exists not to destroy. // in flight is exactly the work this method exists not to destroy.
@@ -1264,7 +1507,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{ {
(false, _) => (false, _) =>
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be " "Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
+ "counted from here, because the vault is locked.", + "counted from here, because the keychain is locked.",
(true, 0) => (true, 0) =>
"Everything this machine has changed has reached the server, so nothing will be lost.", "Everything this machine has changed has reached the server, so nothing will be lost.",
(true, 1) => (true, 1) =>
@@ -1328,6 +1571,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// As Lock does, and before the session it reads from goes. // As Lock does, and before the session it reads from goes.
knownHosts.Close(); knownHosts.Close();
connectionLog.Close();
// The same detach locking does, and the same reasoning carried one step further: the host // The same detach locking does, and the same reasoning carried one step further: the host
// rows go because the vault behind them is about to be disposed, and the session and its // rows go because the vault behind them is about to be disposed, and the session and its
@@ -1364,8 +1608,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsOnline)); OnPropertyChanged(nameof(IsOnline));
RaiseSyncState(); RaiseSyncState();
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault " StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
+ "itself is untouched. Sign in to set this machine up again."; + "keychain itself is untouched. Sign in to set this machine up again.";
}).ConfigureAwait(true); }).ConfigureAwait(true);
} }
@@ -1412,6 +1656,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
knownHosts.Close(); knownHosts.Close();
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
workspace.ConnectionLog = null;
await connectionLog.DisposeAsync().ConfigureAwait(false);
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local // Before the vault, and it waits: a transfer still writing has an open remote file and an open local
// one, and a process that exits while those are in flight leaves a part file longer than the bytes // one, and a process that exits while those are in flight leaves a part file longer than the bytes
// that reached it. // that reached it.
@@ -1547,6 +1797,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
newValue.Hosts.CollectionChanged += OnVaultHostsChanged; newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
} }
// Built from the vault and thrown away with it, here rather than at each of the three places a
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
// would keep a disposed vault alive and repaint a screen nobody can reach.
KnownHostsScreen?.Detach();
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
RaiseSyncState(); RaiseSyncState();
} }
@@ -1579,13 +1843,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
/// <remarks> /// <remarks>
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard /// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// runs against a tab strip that already shows the session it is focusing. /// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
/// </remarks> /// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
{ AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
/// <summary>
/// Takes a newly opened session into the tab strip and shows it.
/// </summary>
/// <remarks>
/// One method rather than one per way of opening a session, so the order of these four steps is decided
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
/// </remarks>
private void AdoptTab(TerminalTabViewModel tab)
{
Tabs.Add(tab); Tabs.Add(tab);
RaiseTabState(); RaiseTabState();
@@ -1594,6 +1867,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// and load-bearing for every one after it. // and load-bearing for every one after it.
SelectedTab = tab; SelectedTab = tab;
// The surface, but deliberately not the screen. A session opened from the files screen shows its
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
// clicking away comes back to the transfer that is presumably still running.
Surface = ShellSurface.Terminal;
TerminalSessionOpened?.Invoke(this, EventArgs.Empty); TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
} }
@@ -1617,15 +1895,50 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RefreshConnectedHosts(); RefreshConnectedHosts();
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
// be a screen keeping the shell alive.
SnippetsScreen?.TargetChanged();
if (value is not null) if (value is not null)
{ {
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask(); _ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
} }
} }
/// <summary>Brings one terminal's pane to the front.</summary> /// <summary>Which terminal a snippet would go into right now.</summary>
/// <remarks>
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
/// say, the most recently opened would send a command somewhere the user is not looking.
/// </remarks>
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
/// <remarks>
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
/// </remarks>
private IReadOnlyList<LiveConnection> LiveConnections() =>
[
.. connectionLog.Open().Select(open => new LiveConnection(
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
];
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
/// <remarks>
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
/// would answer only one of those.
/// </remarks>
[RelayCommand] [RelayCommand]
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab; private void SelectTab(TerminalTabViewModel tab)
{
SelectedTab = tab;
Surface = ShellSurface.Terminal;
}
/// <summary> /// <summary>
/// Marks a tab dead when its shell ends on its own. /// Marks a tab dead when its shell ends on its own.
@@ -1689,10 +2002,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RaiseSyncState(); RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list. // Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
// The hosts screen is what this application is for. // The hosts screen is what this application is for. The surface as well as the screen: shells outlive
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
// the application would not be what "unlocked" looks like.
if (value is ShellState.Unlocked) if (value is ShellState.Unlocked)
{ {
Screen = ShellScreen.Hosts; Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
} }
} }
@@ -1702,12 +2018,57 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// directions, and raising only the one that became true leaves the old button lit. /// directions, and raising only the one that became true leaves the old button lit.
/// </remarks> /// </remarks>
partial void OnScreenChanged(ShellScreen value) partial void OnScreenChanged(ShellScreen value)
{
RaiseSurfaceState();
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
if (value is ShellScreen.Logs && LogsScreen is { } logs)
{
_ = logs.RefreshCommand.ExecuteAsync(null);
}
// Teams are read from the server rather than from the vault, so there is nothing to show until
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
// screen most people never open. Fire-and-forget because a property change cannot await, and
// because the view model turns every failure into its own status line rather than throwing.
if (value is ShellScreen.Team)
{
_ = teams.LoadAsync(CancellationToken.None);
}
}
/// <inheritdoc cref="OnScreenChanged" />
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
/// <remarks>
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
/// narrow what became stale.
/// </remarks>
private void RaiseSurfaceState()
{ {
OnPropertyChanged(nameof(IsHostsScreen)); OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen)); OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsVaultScreen)); OnPropertyChanged(nameof(IsVaultScreen));
OnPropertyChanged(nameof(IsTeamScreen)); OnPropertyChanged(nameof(IsTeamScreen));
OnPropertyChanged(nameof(IsPreferencesScreen)); OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsVaultShowing));
OnPropertyChanged(nameof(IsTeamShowing));
OnPropertyChanged(nameof(IsPreferencesShowing));
OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing));
OnPropertyChanged(nameof(IsLogsShowing));
OnPropertyChanged(nameof(IsTerminalShowing)); OnPropertyChanged(nameof(IsTerminalShowing));
} }
@@ -0,0 +1,338 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Where a snippet is about to be inserted, and whether it can be.</summary>
/// <param name="SessionId">The terminal, or null when there is none open.</param>
/// <param name="Label">What that terminal is called, for the button.</param>
internal sealed record InsertTarget(uint? SessionId, string Label)
{
/// <summary>The answer when no tab is open.</summary>
internal static InsertTarget None { get; } = new(null, string.Empty);
/// <summary>Whether there is somewhere to insert into.</summary>
internal bool IsAvailable => SessionId is not null;
}
/// <summary>
/// The saved commands in this keychain, and how to get one into a terminal.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault, as <c>KnownHostsViewModel</c> is</b>, and for the same reason: reading
/// snippets, storing one and pushing the change already live on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
/// the vault has any use for.
/// </para>
/// <para>
/// <b>The safety story is the copy, not the code.</b> A terminal is one input stream with no notion of being
/// at a prompt: the remote may be in <c>vi</c>, or at a <c>sudo</c> password prompt with echo off, and
/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
/// </para>
/// </remarks>
internal sealed partial class SnippetsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
private readonly Func<InsertTarget> target;
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
/// <param name="vault">The open keychain, which owns the list and the writing.</param>
/// <param name="target">
/// Which terminal is selected right now. A function rather than a value, because the answer changes every
/// time the user clicks a tab and this screen is not told about that.
/// </param>
/// <param name="insert">
/// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
/// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
/// neither of those is a property of the transport.
/// </param>
internal SnippetsViewModel(
VaultViewModel vault,
Func<InsertTarget> target,
Func<uint, string, bool, CancellationToken, Task<bool>> insert)
{
this.vault = vault;
this.target = target;
this.insert = insert;
vault.Snippets.CollectionChanged += OnSnippetsChanged;
Rebuild();
}
/// <summary>The snippets this filter admits, in the order the vault produced them.</summary>
internal ObservableCollection<SnippetRowViewModel> Visible { get; } = [];
[ObservableProperty]
private string filter = string.Empty;
[ObservableProperty]
private SnippetRowViewModel? selected;
[ObservableProperty]
private bool isEditing;
[ObservableProperty]
private string editorLabel = string.Empty;
[ObservableProperty]
private string editorCommand = string.Empty;
[ObservableProperty]
private string editorNotes = string.Empty;
/// <summary>Whether the snippet being edited is one that presses Enter for you.</summary>
/// <remarks>
/// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
/// per snippet rather than a preference, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c> do
/// not want the same answer and one switch would end up left on by whoever needed it for the first.
/// </remarks>
[ObservableProperty]
private bool editorRunsOnInsert;
/// <summary>The snippet being edited, or null when the editor would create one.</summary>
[ObservableProperty]
private Guid? editingId;
[ObservableProperty]
private string status = string.Empty;
internal bool HasSnippets => vault.Snippets.Count > 0;
internal bool HasVisible => Visible.Count > 0;
internal bool HasSelection => Selected is not null;
/// <summary>Whether there is a terminal to insert into at all.</summary>
internal bool CanInsert => HasSelection && target().IsAvailable;
/// <summary>
/// What the insert button says, naming the terminal it will type into.
/// </summary>
/// <remarks>
/// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
/// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
/// command, at the moment that is least convenient to be wrong about.
/// </remarks>
internal string InsertLabel => target() is { IsAvailable: true } open
? $"TYPE INTO {open.Label}"
: "NO TERMINAL OPEN";
/// <summary>What the run button says, or empty when the selected snippet does not run.</summary>
internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;
/// <summary>Whether the selected snippet is one marked as running on its own.</summary>
internal bool SelectionRuns => Selected?.RunsOnInsert is true;
internal string EmptyMessage => HasSnippets
? "No snippet matches that."
: "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
+ "typing it again.";
/// <summary>Starts a new snippet.</summary>
[RelayCommand]
private void New()
{
EditingId = null;
EditorLabel = string.Empty;
EditorCommand = string.Empty;
EditorNotes = string.Empty;
EditorRunsOnInsert = false;
IsEditing = true;
Status = "Adding a snippet.";
}
/// <summary>Opens the selected snippet for editing.</summary>
[RelayCommand]
private void Edit()
{
if (Selected is not { } row)
{
return;
}
if (row.IsReadOnly)
{
Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
return;
}
EditingId = row.EntityId;
EditorLabel = row.Snippet.Label;
EditorCommand = row.Snippet.Command;
EditorNotes = row.Snippet.Notes ?? string.Empty;
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
IsEditing = true;
Status = $"Editing {row.Label}.";
}
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void Cancel()
{
IsEditing = false;
EditingId = null;
Status = string.Empty;
}
/// <summary>Stores the editor's contents.</summary>
[RelayCommand]
private async Task SaveAsync(CancellationToken cancellationToken)
{
var snippet = new SnippetSecret
{
Label = EditorLabel.Trim(),
// Not trimmed, and this is the field where that matters most. A here-document's terminator has
// to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
// for one that never comes, which reads as the snippet having hung the terminal.
Command = EditorCommand,
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RunsOnInsert = EditorRunsOnInsert,
};
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
if (!saved)
{
Status = vault.Status;
return;
}
IsEditing = false;
EditingId = null;
Status = vault.Status;
}
/// <summary>Deletes the selected snippet.</summary>
[RelayCommand]
private async Task DeleteAsync(CancellationToken cancellationToken)
{
if (Selected is not { } row)
{
return;
}
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
Status = vault.Status;
}
/// <summary>
/// Types the selected snippet into the selected terminal, without pressing Enter.
/// </summary>
/// <remarks>
/// The button that does not run anything, and it is the one a user should reach for. What it inserts
/// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
/// at the prompt as text and waits for a person to look at it.
/// </remarks>
[RelayCommand]
private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);
/// <summary>
/// Types the selected snippet into the selected terminal and presses Enter.
/// </summary>
/// <remarks>
/// Only offered for a snippet whose own <see cref="SnippetSecret.RunsOnInsert"/> is set, so that "this
/// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
/// every one of them.
/// </remarks>
[RelayCommand]
private Task RunAsync(CancellationToken cancellationToken) =>
SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;
internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;
/// <summary>Re-reads which terminal is selected, after the shell says one has changed.</summary>
/// <remarks>
/// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
/// this screen — a session survives locking the keychain — so a subscription in this direction would be
/// a screen holding the shell alive.
/// </remarks>
internal void TargetChanged()
{
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(InsertLabel));
OnPropertyChanged(nameof(RunLabel));
}
private async Task SendAsync(bool execute, CancellationToken cancellationToken)
{
if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
{
Status = "Open a terminal first — a snippet has to go somewhere.";
return;
}
var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
.ConfigureAwait(true);
Status = delivered
? execute
? $"Ran '{row.Label}' in {open.Label}."
: $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
: $"{open.Label} is no longer connected, so nothing was sent.";
}
partial void OnFilterChanged(string value) => Rebuild();
partial void OnSelectedChanged(SnippetRowViewModel? value)
{
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns));
}
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
internal bool IsCreating => EditingId is null;
private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
private void Rebuild()
{
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
// writes that null straight back before the refill can matter.
var selectedId = Selected?.EntityId;
Visible.Clear();
foreach (var snippet in vault.Snippets.Where(Matches))
{
Visible.Add(snippet);
}
Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(HasSnippets));
OnPropertyChanged(nameof(HasVisible));
OnPropertyChanged(nameof(EmptyMessage));
}
/// <remarks>
/// The command is searched as well as the name and the notes, because half of what somebody remembers
/// about a saved command is a word that was in it.
/// </remarks>
private bool Matches(SnippetRowViewModel row)
{
var needle = Filter.Trim();
if (needle.Length == 0)
{
return true;
}
return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);
bool Contains(string? value) =>
value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
}
@@ -0,0 +1,523 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Session;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One team, as a row in the list.</summary>
internal sealed record TeamRowViewModel(TeamSummary Team)
{
internal Guid TeamId => Team.TeamId;
internal string Name => Team.Name;
internal string Slug => Team.Slug;
/// <summary>The caller's own role, as the chip the list shows.</summary>
internal string Role => Team.Role.ToString().ToUpperInvariant();
internal string Detail => string.Create(
CultureInfo.CurrentCulture,
$"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");
/// <summary>Whether this account may add members and create vaults here.</summary>
internal bool CanAdminister =>
Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
}
/// <summary>One member, as a row in the members table.</summary>
internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
{
internal Guid UserId => Member.UserId;
/// <summary>What to call them. The address, or the id when the account has neither.</summary>
/// <remarks>
/// Falling through to the id rather than to "Unknown": an account with no display name and no email is
/// rare and is exactly the row somebody needs to be able to identify in order to remove it.
/// </remarks>
internal string Name =>
Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();
internal string Email => Member.Email ?? "—";
internal string Role => Member.Role.ToString().ToUpperInvariant();
/// <summary>
/// What the account can be given, in one phrase.
/// </summary>
/// <remarks>
/// Not a two-factor column, not a last-active column. The server records neither: there is no
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
/// </remarks>
internal string KeyState => Member.IsEnrolled
? "key published"
: "no key yet — cannot be given a vault";
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
}
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
{
/// <summary>What the row says about itself.</summary>
/// <remarks>
/// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
/// somebody has been added to a team and nobody has wrapped the vault key to them yet.
/// </remarks>
internal string State => (IsReadable, RekeyRequired) switch
{
(false, _) => "waiting for a key — ask a member who has one to share it",
(true, true) => "readable · a rekey is owed after a membership change",
_ => "readable",
};
}
/// <summary>
/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
/// </summary>
/// <remarks>
/// <para>
/// <b>Two separate acts, and the screen is built around saying so.</b> Adding somebody to a team is a
/// server-side authorization change and takes effect immediately. Giving them a vault key is a
/// cryptographic act only a machine with that key can perform, and until somebody does it their vault
/// list shows an entry they cannot open. Every product that hides this ends up implying the server can
/// hand out access on its own — which, here, it cannot. See <c>TeamService</c> and ADR 0001.
/// </para>
/// <para>
/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
/// would be a second copy of something the server is authoritative for.
/// </para>
/// </remarks>
internal sealed partial class TeamsViewModel(
Func<IVaultServer?> connection,
Func<VaultSession?> session) : ObservableObject
{
/// <summary>Teams this account belongs to.</summary>
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
/// <summary>Members of the selected team.</summary>
internal ObservableCollection<TeamMemberRowViewModel> Members { get; } = [];
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
internal ObservableCollection<TeamVaultRowViewModel> Vaults { get; } = [];
[ObservableProperty]
private TeamRowViewModel? selectedTeam;
[ObservableProperty]
private TeamMemberRowViewModel? selectedMember;
[ObservableProperty]
private TeamVaultRowViewModel? selectedVault;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private bool isBusy;
// ---- Creating a team ----
[ObservableProperty]
private bool isCreatingTeam;
[ObservableProperty]
private string newTeamName = string.Empty;
[ObservableProperty]
private string newTeamSlug = string.Empty;
// ---- Adding a member ----
[ObservableProperty]
private string inviteEmail = string.Empty;
/// <summary>Whether there is a server to talk to at all.</summary>
internal bool IsOnline => connection() is not null;
/// <summary>Whether the selected team can be administered by this account.</summary>
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
/// <summary>Whether there is anything to show below the team list.</summary>
internal bool HasSelection => SelectedTeam is not null;
internal bool HasTeams => Teams.Count > 0;
/// <summary>Reads the teams this account belongs to, and the selected one's detail.</summary>
internal Task LoadAsync(CancellationToken cancellationToken) =>
RunAsync(() => ReloadAsync(cancellationToken));
/// <summary>
/// The reload itself, without the busy gate.
/// </summary>
/// <remarks>
/// Separate from <see cref="LoadAsync"/> because every command ends by reloading, and a command that
/// called the gated version would find the gate held by itself and skip the reload silently — leaving
/// a team that was created moments ago missing from the list it was just added to.
/// </remarks>
private async Task ReloadAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server)
{
Teams.Clear();
Members.Clear();
Vaults.Clear();
RaiseState();
Status = "Offline. Teams are read from the server, so this screen needs a connection.";
return;
}
var selectedId = SelectedTeam?.TeamId;
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
Teams.Clear();
foreach (var team in teams)
{
Teams.Add(new TeamRowViewModel(team));
}
SelectedTeam =
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
RaiseState();
await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);
Status = Teams.Count == 0
? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
: string.Empty;
}
/// <summary>Opens the create-a-team form.</summary>
[RelayCommand]
private void NewTeam()
{
NewTeamName = string.Empty;
NewTeamSlug = string.Empty;
IsCreatingTeam = true;
Status = string.Empty;
}
/// <summary>Abandons the create-a-team form.</summary>
[RelayCommand]
private void CancelNewTeam()
{
IsCreatingTeam = false;
Status = string.Empty;
}
/// <summary>Creates a team, with this account as its owner.</summary>
/// <remarks>
/// The id is generated here, which is what makes a create whose response was lost safe to send again —
/// the server treats an identical repeat as the same team rather than a second one.
/// </remarks>
[RelayCommand]
private async Task CreateTeamAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server)
{
Status = "Offline. Creating a team needs a connection.";
return;
}
var name = NewTeamName.Trim();
var slug = NewTeamSlug.Trim().ToLowerInvariant();
if (name.Length == 0 || slug.Length == 0)
{
Status = "A team needs a name and a slug.";
return;
}
await RunAsync(async () =>
{
var created = await server.Teams
.CreateTeamAsync(
new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
.ConfigureAwait(true);
IsCreatingTeam = false;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
+ "whoever needs it.";
}).ConfigureAwait(true);
}
/// <summary>
/// Adds a member, by looking their address up in the directory first.
/// </summary>
/// <remarks>
/// Two calls rather than one, and the order is the point: the directory is what turns an address into
/// an account and a public key, and the key that gets verified before any sharing is the one that
/// lookup returned. Letting the server resolve an address to an account inside the add would put an
/// unwitnessed step between the two.
/// </remarks>
[RelayCommand]
private async Task AddMemberAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server || SelectedTeam is not { } team)
{
return;
}
var email = InviteEmail.Trim();
if (email.Length == 0)
{
Status = "Type the email address of somebody who has signed in to this server.";
return;
}
await RunAsync(async () =>
{
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
.ConfigureAwait(true);
if (found.Count == 0)
{
Status = $"No account here has the address '{email}'. They have to sign in to this "
+ "server once before they can be added — that is what publishes the key a vault "
+ "would be shared with.";
return;
}
var member = await server.Teams
.AddTeamMemberAsync(
team.TeamId,
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
cancellationToken)
.ConfigureAwait(true);
InviteEmail = string.Empty;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// Said out loud, every time. The single most common misunderstanding this design invites is
// that adding somebody gave them the vault.
Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
+ "cannot read anything yet — select a vault below and share its key.";
}).ConfigureAwait(true);
}
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
[RelayCommand]
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
|| SelectedTeam is not { } team
|| SelectedMember is not { } member)
{
return;
}
await RunAsync(async () =>
{
await server.Teams
.RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
// and a message implying otherwise is the one thing this screen must not say.
Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything "
+ "they had already downloaded is still on their machine — rotate the credentials that "
+ "matter.";
}).ConfigureAwait(true);
}
/// <summary>Creates a vault owned by the selected team.</summary>
[RelayCommand]
private async Task CreateVaultAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
|| session() is not { } open
|| SelectedTeam is not { } team)
{
return;
}
await RunAsync(async () =>
{
var vault = await open
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
+ "hosts and credentials can be filed into it from the Vault screen.";
}).ConfigureAwait(true);
}
/// <summary>
/// Wraps the selected vault's key to the selected member.
/// </summary>
/// <remarks>
/// Everything that makes this safe happens inside <see cref="VaultSession.ShareVaultAsync"/>: the key
/// log is read and its chain verified, and the directory's answer has to appear in it unchanged before
/// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because
/// the reasons are not interchangeable — one of them means somebody is substituting keys.
/// </remarks>
[RelayCommand]
private async Task ShareVaultAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
|| session() is not { } open
|| SelectedVault is not { } vault
|| SelectedMember is not { } member)
{
return;
}
if (member.IsSelf)
{
Status = "You already hold this vault's key.";
return;
}
await RunAsync(async () =>
{
var outcome = await open
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
.ConfigureAwait(true);
Status = outcome.Shared
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
: $"Did not share '{vault.Name}': {outcome.Message}";
}).ConfigureAwait(true);
}
/// <summary>Withdraws the selected member's key to the selected vault.</summary>
[RelayCommand]
private async Task RevokeVaultAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server
|| SelectedVault is not { } vault
|| SelectedMember is not { } member)
{
return;
}
await RunAsync(async () =>
{
var revoked = await server.Grants
.RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = revoked
? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they "
+ "already have is unaffected."
: $"{member.Name} held no key to '{vault.Name}'.";
}).ConfigureAwait(true);
}
partial void OnSelectedTeamChanged(TeamRowViewModel? value)
{
RaiseState();
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
// from a list box, which has no cancellation token and no way to await. Failures land in Status
// through RunAsync exactly as a command's would.
_ = LoadSelectedAsync(CancellationToken.None);
}
/// <summary>Reads the selected team's members and vaults.</summary>
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
{
Members.Clear();
Vaults.Clear();
if (connection() is not { } server || SelectedTeam is not { } team)
{
return;
}
var open = session();
var selfId = open?.Profile.UserId;
var members = await server.Teams
.ListTeamMembersAsync(team.TeamId, cancellationToken)
.ConfigureAwait(true);
foreach (var member in members)
{
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
}
if (open is null)
{
return;
}
// Read from the session rather than from a team-vaults endpoint, because the interesting fact
// about a team vault here is whether *this* machine can open it — which is a property of the
// keyring and not something the server can answer.
var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
{
Vaults.Add(new TeamVaultRowViewModel(
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
}
SelectedVault = Vaults.FirstOrDefault();
}
private void RaiseState()
{
OnPropertyChanged(nameof(HasTeams));
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanAdministerSelected));
OnPropertyChanged(nameof(IsOnline));
}
/// <remarks>
/// One place that raises the busy flag and turns a failure into a sentence. An API exception's message
/// is the server's problem detail, which is written for a person to read — see <c>Problems</c> — so it
/// is shown rather than replaced with something vaguer.
/// </remarks>
private async Task RunAsync(Func<Task> work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
try
{
await work().ConfigureAwait(true);
}
catch (DodoSshApiException exception)
{
Status = exception.Message;
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
}

Some files were not shown because too many files have changed in this diff Show More