Merge branch 'main' into claude/host-management-ui-plan-7f20ab

Seven files needed a hand. Most were two branches adding something in the same
place, but three were one branch changing what the other had moved or renamed,
and those are the ones worth reading.

The shell keeps both new fields and both constructor lines: the connection
recorder this branch built and the teams view model main did. Where main put a
teams load inside OnScreenChanged, it now sits beside the logs refresh rather
than inside RaiseSurfaceState — this branch extracted that notification block
and it is called from two properties, so a screen-specific side effect in there
would fire on every terminal switch as well.

Main gave four row types a vault id and a vault name, and this branch had moved
one of them — KnownHostRowViewModel — into its own file when the pinned keys
became a screen. Git resolved that as "deleted here, modified there" and took
the delete, which compiles as long as nobody looks: the moved copy still had
the two-argument constructor and the call site had grown to four. Carried over
by hand, along with the ordering the pins list now does on them.

The status line's quiet rule was the subtle one. Main extracted it into
IsWorthReporting; this branch had changed the same condition to read item
counts rather than raw ones, because every user action queues a log entry a
moment later and this machine reads its own entries back on the next pull. Take
main's structure and the merge builds, passes, and silently restores a bug this
branch existed partly to fix — every save's message overwritten a second after
it appears. The method now reads PulledItems and PushedItems, with the reason
in its remarks.

Two conflicts were prose that had gone stale rather than code. The keychain
screen's comment said team vaults are refused by the server's access service,
which was true when it was written and is not now; main's replacement stands,
in this branch's vocabulary. The design-gaps row for groups was claimed by both
— real host groups here, per-vault headings there — and they are different
things, so both rows stay and the difference is stated: a group is a shelf the
user chose, a vault is who can read the item.

One defect the tests found and the compiler could not. Generating a key opens
the same editor as pasting one, but not through NewKey — so it never set the
target vault main added, and a generated key was filed into whatever vault was
edited last, or none. Both key-generation tests failed on it. Fixed where the
editor opens, with the reason recorded there.

One gap is left deliberately and is written down rather than half-built. Hosts,
keys, credentials and pins are read across every vault this session holds a key
for; groups are read from the active vault alone, so a host a teammate filed
shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar
already shows for a group that has been deleted — but closing it needs a vault
id on every group row for rename and delete, and a way to tell two vaults'
identically-named groups apart under a layout with one heading per group. Both
are worth doing and neither is a merge's business. It is in the remarks on
ReloadGroupsAsync and in docs/design-import-gaps.md.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1282 tests, including the end-to-end suite against real containers.
This commit is contained in:
2026-07-31 20:44:39 +02:00
49 changed files with 6889 additions and 149 deletions
+51 -1
View File
@@ -214,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
@@ -338,7 +374,21 @@ keychain plus a terminal — and the spike that gates all of it.
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from 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 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. ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
- **M3 — teams**, sharing, ACLs. - **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.
+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.
+44 -25
View File
@@ -26,11 +26,12 @@ 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
@@ -108,7 +109,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
| 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". |
@@ -132,7 +133,8 @@ caption buttons and window title drawn on top of the application's own — two s
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 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`. | **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. | | 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. |
@@ -191,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. |
@@ -204,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. |
@@ -213,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.
--- ---
@@ -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,31 +90,122 @@ 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 />
public async Task<IReadOnlyList<VaultAccess>> ListAsync( public async Task<IReadOnlyList<VaultAccess>> ListAsync(
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);
}
}
@@ -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>
+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);
@@ -22,8 +22,18 @@ namespace DodoSSH.Client.App.ViewModels;
/// of pinning one is to compare it with what they published. /// of pinning one is to compare it with what they published.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal sealed class KnownHostRowViewModel(VaultItem<KnownHostSecret> pin, bool isDialledByAHost) 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 Guid EntityId => pin.EntityId;
internal KnownHostSecret Pin => pin.Secret; internal KnownHostSecret Pin => pin.Secret;
@@ -198,6 +198,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks> /// </remarks>
private readonly ConnectionRecorder connectionLog; 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>
@@ -274,6 +276,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
connectionLog = new ConnectionRecorder(clock, Environment.MachineName); connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
this.workspace.ConnectionLog = connectionLog; 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;
@@ -365,6 +373,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty] [ObservableProperty]
private LogsViewModel? logsScreen; 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 —
@@ -2009,6 +2028,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{ {
_ = logs.RefreshCommand.ExecuteAsync(null); _ = 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" /> /// <inheritdoc cref="OnScreenChanged" />
@@ -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.App.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;
}
}
}
@@ -49,6 +49,12 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary> /// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
internal string Permissions => entry.Permissions; internal string Permissions => entry.Permissions;
/// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
internal bool IsExecutable => entry.IsExecutable;
/// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
internal bool IsWorldWritable => entry.IsWorldWritable;
} }
/// <summary>One local file or directory, as a row.</summary> /// <summary>One local file or directory, as a row.</summary>
@@ -124,10 +124,40 @@ internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and /// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
/// an item a newer client wrote that must not be re-encoded here. /// an item a newer client wrote that must not be re-encoded here.
/// </remarks> /// </remarks>
internal sealed partial class HostRowViewModel(VaultItem<HostSecret> host) : ObservableObject, ISidebarRow internal sealed partial class HostRowViewModel(
VaultItem<HostSecret> host,
Guid vaultId,
string vaultName) : ObservableObject, ISidebarRow
{ {
internal Guid EntityId => host.EntityId; internal Guid EntityId => host.EntityId;
/// <summary>
/// Which vault this host lives in.
/// </summary>
/// <remarks>
/// Carried on the row rather than read from the session, because a session now holds several and an
/// edit has to return to the vault the item came from. Writing it to the active vault instead would
/// create a second copy in the personal vault and leave the team's original untouched — a silent fork
/// that only shows up when somebody else wonders why their change never arrived.
/// </remarks>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name, for the heading the sidebar groups under.</summary>
internal string VaultName => vaultName;
/// <summary>
/// The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <remarks>
/// Decided by the list rather than by the row, because "is there more than one vault" is not
/// something a row can see — and the alternative, a binding that reaches out to the parent view
/// model from inside an item template, is the kind of thing that silently resolves to nothing.
/// </remarks>
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
internal HostSecret Host => host.Secret; internal HostSecret Host => host.Secret;
internal string Label => host.Secret.Label; internal string Label => host.Secret.Label;
@@ -270,10 +300,16 @@ internal sealed record AuthenticationChoice(
/// property. /// property.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key) internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vaultId, string vaultName)
{ {
internal Guid EntityId => key.EntityId; internal Guid EntityId => key.EntityId;
/// <summary>Which vault this key lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => vaultName;
internal SshKeySecret Key => key.Secret; internal SshKeySecret Key => key.Secret;
internal string Label => key.Secret.Label; internal string Label => key.Secret.Label;
@@ -335,10 +371,19 @@ internal sealed class ObjectStoreRowViewModel(VaultItem<ObjectStoreSecret> store
internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges); internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges);
} }
internal sealed class CredentialRowViewModel(VaultItem<CredentialSecret> credential) internal sealed class CredentialRowViewModel(
VaultItem<CredentialSecret> credential,
Guid vaultId,
string vaultName)
{ {
internal Guid EntityId => credential.EntityId; internal Guid EntityId => credential.EntityId;
/// <summary>Which vault this credential lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => vaultName;
internal CredentialSecret Credential => credential.Secret; internal CredentialSecret Credential => credential.Secret;
internal string Label => credential.Secret.Label; internal string Label => credential.Secret.Label;
@@ -504,6 +549,23 @@ internal enum VaultItemKind
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built /// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
/// by comparing it against the literal "not synced" would break the day that wording improves. /// by comparing it against the literal "not synced" would break the day that wording improves.
/// </param> /// </param>
/// <summary>One vault, as an option in the "file this into" picker.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal)
{
/// <summary>
/// What the picker shows.
/// </summary>
/// <remarks>
/// A team vault is marked as one. The whole risk this picker introduces is putting a credential
/// somewhere more people can read it, so the option that does that must not look like the option
/// that does not.
/// </remarks>
internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
}
internal sealed record VaultItemRowViewModel( internal sealed record VaultItemRowViewModel(
VaultItemKind Kind, VaultItemKind Kind,
Guid EntityId, Guid EntityId,
@@ -743,10 +805,16 @@ internal sealed partial class VaultViewModel(
/// <remarks> /// <remarks>
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no /// The vault's name, because the vault is the only grouping a host has — there are no tags and no
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess /// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
/// presented as structure. One heading, because one vault is reachable: the server denies access to /// presented as structure.
/// every vault that is not this user's own. See <c>docs/design-import-gaps.md</c>. /// <para>
/// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold
/// several, and then the heading stops naming one of them and each row names its own — a heading that
/// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this
/// interface is otherwise careful about.
/// </para>
/// </remarks> /// </remarks>
internal string HostsHeading => VaultName.ToUpperInvariant(); internal string HostsHeading =>
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
/// <summary>Whether the host list under the heading is folded away.</summary> /// <summary>Whether the host list under the heading is folded away.</summary>
[ObservableProperty] [ObservableProperty]
@@ -770,6 +838,36 @@ internal sealed partial class VaultViewModel(
internal string VaultName => internal string VaultName =>
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain"; session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";
/// <summary>
/// The vaults a new item may be filed into: readable, and writable by this account.
/// </summary>
/// <remarks>
/// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one
/// it can read but not write is a team vault this member is a viewer of — offering either would end
/// in a Save that fails, one of them locally and one at the server.
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> TargetVaults { get; } = [];
/// <summary>
/// Where the next new item goes.
/// </summary>
/// <remarks>
/// Falls back to the session's active vault, which is the personal one wherever there is one. Filing
/// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is
/// visible to people who should not have it, and moving it afterwards means deleting and retyping.
/// </remarks>
internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;
/// <summary>Whether there is more than one vault to choose between.</summary>
/// <remarks>
/// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is
/// a question with no answer, and for most people this stays at one for ever.
/// </remarks>
internal bool HasVaultChoice => TargetVaults.Count > 1;
[ObservableProperty]
private VaultChoiceViewModel? selectedTargetVault;
[ObservableProperty] [ObservableProperty]
private HostRowViewModel? selectedHost; private HostRowViewModel? selectedHost;
@@ -1036,6 +1134,24 @@ internal sealed partial class VaultViewModel(
/// <summary>The item being edited, or null when creating.</summary> /// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId; private Guid? editingEntityId;
/// <summary>
/// Which vault the editor will write to.
/// </summary>
/// <remarks>
/// Captured when the editor opens rather than read at save time, and there are two different reasons
/// for that depending on which way the editor was opened. Editing an existing item, it is the vault
/// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the
/// target picker said <em>at that moment</em>, so changing the picker afterwards cannot silently move
/// a half-typed host into a team's vault.
/// </remarks>
private Guid editingHostVaultId;
/// <summary>Which vault the key editor will write to. See <see cref="editingHostVaultId"/>.</summary>
private Guid editingKeyVaultId;
/// <summary>Which vault the credential editor will write to. See <see cref="editingHostVaultId"/>.</summary>
private Guid editingCredentialVaultId;
/// <summary> /// <summary>
/// Whether the editor is showing a host that could have a pinned key to forget. /// Whether the editor is showing a host that could have a pinned key to forget.
/// </summary> /// </summary>
@@ -1356,6 +1472,10 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private async Task ReloadAsync(CancellationToken cancellationToken) private async Task ReloadAsync(CancellationToken cancellationToken)
{ {
// First, because the four lists below are read across the same set and a vault admitted by the
// last refresh should appear in the picker on the same pass its items do.
RebuildTargetVaults();
// Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what // Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what
// gets counted under them — so the host reload is the pass that can put both together. // gets counted under them — so the host reload is the pass that can put both together.
var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true); var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);
@@ -1379,20 +1499,74 @@ internal sealed partial class VaultViewModel(
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true); await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
} }
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
/// <remarks>
/// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
/// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
/// vault rather than to nothing, so the next Save still has somewhere to go.
/// </remarks>
private void RebuildTargetVaults()
{
var selectedId = TargetVaultId;
TargetVaults.Clear();
foreach (var vault in session.ReadableVaults
.Where(vault => vault.CanWrite)
.OrderByDescending(vault => vault.IsPersonal)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
{
TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
}
SelectedTargetVault =
TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId)
?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId)
?? TargetVaults.FirstOrDefault();
OnPropertyChanged(nameof(HasVaultChoice));
}
/// <returns>How many hosts would not decrypt.</returns> /// <returns>How many hosts would not decrypt.</returns>
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken) private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedHost?.EntityId;
var unreadable = 0;
var rows = new List<HostRowViewModel>();
// Every vault this session holds a key for, not only the one new items are filed into. A team
// vault whose hosts never reached this list would make sharing look as though it had not worked.
var readable = session.ReadableVaults.ToList();
var several = readable.Count > 1;
foreach (var vault in readable)
{ {
var listing = await session.Hosts var listing = await session.Hosts
.ListAsync(session.ActiveVaultId, cancellationToken) .ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
var selectedId = SelectedHost?.EntityId; unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new HostRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart. A badge on every row of a
// single-vault list is noise that says the same thing on all of them.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Hosts.Clear(); Hosts.Clear();
foreach (var host in listing.Items.OrderBy(host => host.Secret.Label, StringComparer.CurrentCulture)) // Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating.
foreach (var host in rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{ {
Hosts.Add(new HostRowViewModel(host)); Hosts.Add(host);
} }
// Selection survives a reload. Losing it on every sync would move the terminal's target out from // Selection survives a reload. Losing it on every sync would move the terminal's target out from
@@ -1404,7 +1578,7 @@ internal sealed partial class VaultViewModel(
RebuildGroups(); RebuildGroups();
RebuildVisibleHosts(); RebuildVisibleHosts();
return listing.Unreadable; return unreadable;
} }
/// <returns>How many buckets would not decrypt.</returns> /// <returns>How many buckets would not decrypt.</returns>
@@ -1533,9 +1707,21 @@ internal sealed partial class VaultViewModel(
/// <returns>How many groups would not decrypt.</returns> /// <returns>How many groups would not decrypt.</returns>
/// <remarks> /// <remarks>
/// <para>
/// The listing is kept rather than projected straight into <see cref="Groups"/>, because a group row /// The listing is kept rather than projected straight into <see cref="Groups"/>, because a group row
/// carries how many hosts name it and the hosts have not been read yet when this runs. See /// carries how many hosts name it and the hosts have not been read yet when this runs. See
/// <see cref="RebuildGroups"/>, which is where the two meet. /// <see cref="RebuildGroups"/>, which is where the two meet.
/// </para>
/// <para>
/// <b>The active vault only, unlike every other list on this screen.</b> Hosts, keys, credentials and
/// pins are read across every vault this session holds a key for; groups are not, so a host in a team's
/// vault that a teammate filed appears under UNGROUPED. That is the same thing the sidebar already shows
/// for a group that has been deleted, and it is deliberate here rather than an oversight: reading them
/// across vaults means a group row has to carry the vault it lives in — rename and delete both need it —
/// and two vaults may hold groups with the same name, which the one-heading-per-group layout cannot tell
/// apart. Both are worth doing and neither is a merge's business. Recorded in
/// <c>docs/design-import-gaps.md</c>.
/// </para>
/// </remarks> /// </remarks>
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken) private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
{ {
@@ -1734,23 +1920,36 @@ internal sealed partial class VaultViewModel(
/// that button at a key nobody chose. /// that button at a key nobody chose.
/// </remarks> /// </remarks>
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken) private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedKey?.EntityId;
var unreadable = 0;
var rows = new List<SshKeyRowViewModel>();
foreach (var vault in session.ReadableVaults)
{ {
var listing = await session.SshKeys var listing = await session.SshKeys
.ListAsync(session.ActiveVaultId, cancellationToken) .ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
var selectedId = SelectedKey?.EntityId; unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
}
Keys.Clear(); Keys.Clear();
foreach (var key in listing.Items.OrderBy(key => key.Secret.Label, StringComparer.CurrentCulture)) foreach (var key in rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{ {
Keys.Add(new SshKeyRowViewModel(key)); Keys.Add(key);
} }
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId); SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);
return listing.Unreadable; return unreadable;
} }
/// <returns>How many credentials would not decrypt.</returns> /// <returns>How many credentials would not decrypt.</returns>
@@ -1761,24 +1960,36 @@ internal sealed partial class VaultViewModel(
/// password nobody chose. /// password nobody chose.
/// </remarks> /// </remarks>
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken) private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedCredential?.EntityId;
var unreadable = 0;
var rows = new List<CredentialRowViewModel>();
foreach (var vault in session.ReadableVaults)
{ {
var listing = await session.Credentials var listing = await session.Credentials
.ListAsync(session.ActiveVaultId, cancellationToken) .ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
var selectedId = SelectedCredential?.EntityId; unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
}
Credentials.Clear(); Credentials.Clear();
foreach (var credential in listing.Items foreach (var credential in rows
.OrderBy(credential => credential.Secret.Label, StringComparer.CurrentCulture)) .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{ {
Credentials.Add(new CredentialRowViewModel(credential)); Credentials.Add(credential);
} }
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId); SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId);
return listing.Unreadable; return unreadable;
} }
/// <returns>How many pins would not decrypt.</returns> /// <returns>How many pins would not decrypt.</returns>
@@ -1789,11 +2000,9 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken) private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken)
{ {
var listing = await session.KnownHosts
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var selectedId = SelectedKnownHost?.EntityId; var selectedId = SelectedKnownHost?.EntityId;
var unreadable = 0;
var rows = new List<KnownHostRowViewModel>();
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a // Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
// hundred scans of the host list on every background sync. // hundred scans of the host list on every background sync.
@@ -1801,20 +2010,39 @@ internal sealed partial class VaultViewModel(
.Select(host => Endpoint(host.Host.Hostname, host.Host.Port)) .Select(host => Endpoint(host.Host.Hostname, host.Host.Port))
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
// Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
// the active vault alone. The difference is deliberate and is stated in the README: a pin in a
// team vault is something a teammate can write, and letting it answer for a host in somebody's
// personal vault would let one member suppress another's first-contact prompt. Showing them is
// safe and is the only way somebody can see what their team has trusted.
foreach (var vault in session.ReadableVaults)
{
var listing = await session.KnownHosts
.ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true);
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel(
item,
dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)),
vault.VaultId,
vault.Name)));
}
KnownHostPins.Clear(); KnownHostPins.Clear();
foreach (var pin in listing.Items foreach (var pin in rows
.OrderBy(pin => pin.Secret.Host, StringComparer.CurrentCulture) .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(pin => pin.Secret.Port) .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(pin => pin.Secret.Algorithm, StringComparer.Ordinal)) .ThenBy(row => row.Label, StringComparer.CurrentCulture))
{ {
KnownHostPins.Add(new KnownHostRowViewModel( KnownHostPins.Add(pin);
pin, dialled.Contains(Endpoint(pin.Secret.Host, pin.Secret.Port))));
} }
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId); SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId);
return listing.Unreadable; return unreadable;
} }
/// <remarks> /// <remarks>
@@ -1938,16 +2166,17 @@ internal sealed partial class VaultViewModel(
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true); var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
// A pass that had to start over says so even when it pulled nothing, which is the one place if (report is null)
// this loop breaks its own rule about staying quiet. A machine that silently re-read the whole {
// vault has had something happen to it, and the alternative is that nobody ever finds out. return;
// The item counts rather than the raw ones: a pass that carried nothing but log entries stays }
// quiet. Every user action queues one a moment after the action's own status message, and this
// machine reads its own entries back on the next pull — so reporting on the raw numbers would // A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it
// overwrite that message after every single save. // gets the treatment the catch below gives a total failure, the fact kept and the message
if (report is not null // swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was
&& (report.PulledItems > 0 || report.PushedItems > 0 || report.NeedsAttention // reading, once a minute, with the name of a vault it could not reach. Pressing Sync still
|| report.ResyncedFromStart)) // names the vault and the reason, because somebody who pressed it is waiting for an answer.
if (IsWorthReporting(report))
{ {
Status = Describe(report); Status = Describe(report);
} }
@@ -1975,7 +2204,9 @@ internal sealed partial class VaultViewModel(
/// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by /// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
/// waiting for it, and queueing them would turn a slow server into a backlog of identical work. /// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
/// </remarks> /// </remarks>
private async Task<SyncReport?> SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken) private async Task<IReadOnlyList<VaultSyncReport>?> SyncOnceAsync(
ISyncApi api,
CancellationToken cancellationToken)
{ {
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true)) if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
{ {
@@ -1984,9 +2215,16 @@ internal sealed partial class VaultViewModel(
try try
{ {
var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true); // Every vault this session can read, not only the one new items are filed into. A team's
// vault that never synced would show its hosts exactly once — at the unlock that first
// pulled it — and then quietly stop, which reads as the feature not working.
var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true);
LastSyncFailed = false; // Not unconditionally false, which it was while a pass was one vault and a failure was an
// exception. A failure is now a report — one unreachable team vault must not stop the others
// syncing — so clearing the flag here regardless would light the titlebar green over a vault
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
LastSyncFailed = report.Any(vault => !vault.Succeeded);
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -2076,6 +2314,7 @@ internal sealed partial class VaultViewModel(
} }
editingEntityId = null; editingEntityId = null;
editingHostVaultId = TargetVaultId;
EditorLabel = string.Empty; EditorLabel = string.Empty;
EditorHostname = string.Empty; EditorHostname = string.Empty;
EditorPort = HostSecret.DefaultPort; EditorPort = HostSecret.DefaultPort;
@@ -2109,6 +2348,7 @@ internal sealed partial class VaultViewModel(
} }
editingEntityId = row.EntityId; editingEntityId = row.EntityId;
editingHostVaultId = row.VaultId;
EditorLabel = row.Host.Label; EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname; EditorHostname = row.Host.Hostname;
EditorPort = row.Host.Port; EditorPort = row.Host.Port;
@@ -2354,13 +2594,13 @@ internal sealed partial class VaultViewModel(
if (editingEntityId is { } entityId) if (editingEntityId is { } entityId)
{ {
await session.Hosts await session.Hosts
.UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken) .UpdateAsync(editingHostVaultId, entityId, host, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
else else
{ {
editingEntityId = await session.Hosts editingEntityId = await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken) .CreateAsync(editingHostVaultId, host, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
@@ -2483,7 +2723,7 @@ internal sealed partial class VaultViewModel(
async () => async () =>
{ {
await session.Hosts await session.Hosts
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -2506,6 +2746,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Keys; Section = VaultSection.Keys;
editingKeyId = null; editingKeyId = null;
editingKeyVaultId = TargetVaultId;
ClearKeyEditor(); ClearKeyEditor();
IsEditingKey = true; IsEditingKey = true;
Status = "Adding an SSH key."; Status = "Adding an SSH key.";
@@ -2532,6 +2773,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Keys; Section = VaultSection.Keys;
editingKeyId = row.EntityId; editingKeyId = row.EntityId;
editingKeyVaultId = row.VaultId;
KeyEditorLabel = row.Key.Label; KeyEditorLabel = row.Key.Label;
KeyEditorPrivateKey = row.Key.PrivateKeyPem; KeyEditorPrivateKey = row.Key.PrivateKeyPem;
KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty; KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
@@ -2615,6 +2857,12 @@ internal sealed partial class VaultViewModel(
IsGeneratingKey = false; IsGeneratingKey = false;
editingKeyId = null; editingKeyId = null;
// Filed where a pasted key would be, and set here rather than left over from whatever was
// edited last: this path opens the same editor without going through NewKey, so without
// this a key generated after editing a team's key would be saved into that team's vault.
editingKeyVaultId = TargetVaultId;
ClearKeyEditor(); ClearKeyEditor();
KeyEditorLabel = comment; KeyEditorLabel = comment;
@@ -2694,13 +2942,13 @@ internal sealed partial class VaultViewModel(
if (editingKeyId is { } entityId) if (editingKeyId is { } entityId)
{ {
await session.SshKeys await session.SshKeys
.UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken) .UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
else else
{ {
editingKeyId = await session.SshKeys editingKeyId = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken) .CreateAsync(editingKeyVaultId, key, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
@@ -2758,7 +3006,7 @@ internal sealed partial class VaultViewModel(
async () => async () =>
{ {
await session.SshKeys await session.SshKeys
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -2779,6 +3027,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Credentials; Section = VaultSection.Credentials;
editingCredentialId = null; editingCredentialId = null;
editingCredentialVaultId = TargetVaultId;
ClearCredentialEditor(); ClearCredentialEditor();
IsEditingCredential = true; IsEditingCredential = true;
Status = "Adding a credential."; Status = "Adding a credential.";
@@ -2805,6 +3054,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Credentials; Section = VaultSection.Credentials;
editingCredentialId = row.EntityId; editingCredentialId = row.EntityId;
editingCredentialVaultId = row.VaultId;
CredentialEditorLabel = row.Credential.Label; CredentialEditorLabel = row.Credential.Label;
CredentialEditorUsername = row.Credential.Username ?? string.Empty; CredentialEditorUsername = row.Credential.Username ?? string.Empty;
CredentialEditorPassword = row.Credential.Password; CredentialEditorPassword = row.Credential.Password;
@@ -3028,13 +3278,13 @@ internal sealed partial class VaultViewModel(
if (editingCredentialId is { } entityId) if (editingCredentialId is { } entityId)
{ {
await session.Credentials await session.Credentials
.UpdateAsync(session.ActiveVaultId, entityId, credential, cancellationToken) .UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
else else
{ {
editingCredentialId = await session.Credentials editingCredentialId = await session.Credentials
.CreateAsync(session.ActiveVaultId, credential, cancellationToken) .CreateAsync(editingCredentialVaultId, credential, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
@@ -3086,7 +3336,7 @@ internal sealed partial class VaultViewModel(
async () => async () =>
{ {
await session.Credentials await session.Credentials
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -3994,6 +4244,77 @@ internal sealed partial class VaultViewModel(
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point /// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
/// of recording those is that somebody sees them. /// of recording those is that somebody sees them.
/// </remarks> /// </remarks>
/// <remarks>
/// Movement and attention only — deliberately not failure. A background pass that announced every
/// unreachable vault would be a socket error on screen once a minute, which is the thing
/// <see cref="AutoSyncAsync"/>'s catch block exists to avoid; the caller records
/// <see cref="LastSyncFailed"/> instead, and the titlebar stops claiming to be up to date. Pressing
/// Sync reports the failure in full, because somebody who pressed it is waiting for an answer.
/// </remarks>
/// <remarks>
/// The item counts rather than the raw ones. Every user action queues a log entry a moment after the
/// action's own status message, and this machine reads its own entries back on the next pull — so a
/// rule written against the raw numbers would overwrite that message after every single save, which is
/// exactly what it did until the report learned to tell the two apart.
/// </remarks>
private static bool IsWorthReporting(IReadOnlyList<VaultSyncReport> reports) =>
reports.Any(vault => vault.Succeeded
&& (vault.Report!.PulledItems > 0
|| vault.Report.PushedItems > 0
|| vault.Report.NeedsAttention
// A pass that had to start over says so even when it pulled nothing, which is the one
// place this rule is broken deliberately. A machine that silently re-read a whole vault
// has had something happen to it, and the alternative is that nobody ever finds out.
|| vault.Report.ResyncedFromStart));
/// <remarks>
/// Counts are summed across vaults, and a failure is named <em>with its reason</em>. Both halves
/// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a
/// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them
/// costs nothing.
/// </remarks>
private static string Describe(IReadOnlyList<VaultSyncReport> reports)
{
var failed = reports
.Where(vault => !vault.Succeeded)
.Select(vault => $"{vault.Name} ({vault.Failure?.Message})")
.ToList();
var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList();
var line = succeeded.Count switch
{
0 => string.Empty,
1 => Describe(succeeded[0]),
_ => DescribeMany(succeeded),
};
if (failed.Count == 0)
{
return line.Length == 0 ? "Nothing to synchronise." : line;
}
var names = string.Join("; ", failed);
return line.Length == 0
? $"Could not synchronise {names}."
: $"{line} Could not synchronise {names}.";
}
private static string DescribeMany(List<SyncReport> reports)
{
var pulled = reports.Sum(report => report.Pulled);
var pushed = reports.Sum(report => report.Pushed);
var attention = reports.Count(report => report.NeedsAttention);
var line = pulled == 0 && pushed == 0
? $"Already up to date across {reports.Count} vaults."
: $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out.";
return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list.";
}
private static string Describe(SyncReport report) private static string Describe(SyncReport report)
{ {
// Said first, and in both branches, because it is the explanation for the numbers after it. A pass // Said first, and in both branches, because it is the explanation for the numbers after it. A pass
+12 -2
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">
@@ -129,6 +131,14 @@
--> -->
<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>
+8 -15
View File
@@ -138,21 +138,14 @@
</Panel> </Panel>
<!-- ============ TEAM ============ --> <!-- ============ TEAM ============ -->
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}" <!--
Title="TEAM" Wrapped, for the reason the vault and transfers screens are: the visibility is the shell's
Milestone="MILESTONE M3" business and the data context is the teams view model, and both on one element would resolve
Summary="The design shows members, roles, shared keychains 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 keychain that is not your own — so there is nobody to list and no shared keychain to open." IsTeamScreen against a type that does not have it.
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 Keychain screen. Sharing a credential means handing it over out of band, and rotating it afterwards."> -->
<views:NotBuiltScreen.Missing> <Panel IsVisible="{Binding IsTeamScreen}">
<sys:List x:TypeArguments="x:String"> <views:TeamsScreen DataContext="{Binding Teams}" />
<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> </Panel>
<x:String>Access to a keychain 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 keychain key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
</sys:List>
</views:NotBuiltScreen.Missing>
</views:NotBuiltScreen>
<!-- ============ PREFERENCES ============ --> <!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" /> <views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
@@ -0,0 +1,188 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.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();
}
@@ -23,6 +23,16 @@
A directory is marked by colour rather than by an icon: this application ships no icon set, and the A directory is marked by colour rather than by an icon: this application ships no icon set, and the
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
described as deliberately rare. This is the one place it is spent. described as deliberately rare. This is the one place it is spent.
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
characters that actually say so. The two never compete for one TextBlock, which is what lets a
world-writable executable show both facts instead of one winning an argument.
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
string is the compromise: the eye lands on the column, and the string itself is the detail.
--> -->
<Style Selector="TextBlock.entry"> <Style Selector="TextBlock.entry">
<Setter Property="Foreground" Value="{StaticResource Text}" /> <Setter Property="Foreground" Value="{StaticResource Text}" />
@@ -30,6 +40,25 @@
<Style Selector="TextBlock.entry.dir"> <Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Info}" /> <Setter Property="Foreground" Value="{StaticResource Info}" />
</Style> </Style>
<Style Selector="TextBlock.entry.exec">
<Setter Property="Foreground" Value="{StaticResource Accent}" />
</Style>
<!--
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
-->
<Style Selector="TextBlock.perms">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
Warn rather than WarnText, which is the muted amber a warning card writes its sentences in. At 9.5px
against TextFaint that one is a shade, not a signal, and a marker nobody notices is the same as no
marker at all.
-->
<Style Selector="TextBlock.perms.loose">
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
</UserControl.Styles> </UserControl.Styles>
<Grid RowDefinitions="44,*,Auto"> <Grid RowDefinitions="44,*,Auto">
@@ -342,14 +371,15 @@
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5"> <Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" /> <Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}" <TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="11" Text="{Binding Name}" FontSize="11"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" /> Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5" <TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" /> Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5" <TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" /> Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Permissions}" FontSize="9.5" <TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" /> Text="{Binding Permissions}" FontSize="9.5" VerticalAlignment="Center" />
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
+27 -9
View File
@@ -22,9 +22,10 @@
behind them, so listing them would be two headings that could never have anything under them. Recorded behind them, so listing them would be two headings that could never have anything under them. Recorded
in docs/design-import-gaps.md. in docs/design-import-gaps.md.
The SCOPES rail below the categories is the keychain list, which is real and today has one entry in it. The SCOPES rail below the categories is the keychain list. Since M3 it genuinely has more than one entry
The design shows three, two of them teams; team keychains exist as tables on the server and are refused when somebody is in a team — but it is still not a selector, because every table on this screen already
by its access service, so a rail with three entries would be showing two nothing can open. 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">
@@ -91,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 keychain, 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
@@ -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;
+90 -6
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,7 +60,7 @@ 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;
@@ -111,11 +130,28 @@ public sealed class VaultSession : IAsyncDisposable
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; }
@@ -232,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);
@@ -243,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>
+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);
}
+47
View File
@@ -51,6 +51,32 @@ public sealed record SftpEntry(
/// file fails the listing instead, which is the caller's cue that it was not a directory after all. /// file fails the listing instead, which is the caller's cue that it was not a directory after all.
/// </remarks> /// </remarks>
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink; public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
/// <summary>Whether this is a file somebody can run.</summary>
/// <remarks>
/// Files only. On a directory the execute bit means "may be searched", which is true of very nearly every
/// directory on a host — a listing that marked them all would be marking nothing.
/// </remarks>
public bool IsExecutable => Kind is SftpEntryKind.File && PosixMode.HasAnyExecuteBit(Permissions);
/// <summary>
/// Whether this is a file any account on the host may write to.
/// </summary>
/// <remarks>
/// <para>
/// Files only, and for two separate reasons. A symbolic link is <c>lrwxrwxrwx</c> by convention on every
/// system that has one, and its mode governs nothing: what may be written is the target, whose own mode
/// this listing did not fetch. And a directory that everyone may write to is the ordinary arrangement for
/// <c>/tmp</c>, made safe by the sticky bit — which <see cref="PosixMode"/> does not render, so flagging
/// the directory would be warning about the half of the mode that is on screen while the half that
/// answers the warning is not.
/// </para>
/// <para>
/// It is not a claim that writing is dangerous, only that the mode says something a reader of that column
/// would want to have noticed.
/// </para>
/// </remarks>
public bool IsWorldWritable => Kind is SftpEntryKind.File && PosixMode.IsWorldWritable(Permissions);
} }
/// <summary> /// <summary>
@@ -106,6 +132,27 @@ public static class PosixMode
triple[2] = execute ? 'x' : '-'; triple[2] = execute ? 'x' : '-';
} }
} }
/// <summary>Whether any of the three execute bits is set.</summary>
public static bool HasAnyExecuteBit(string mode) =>
At(mode, OwnerExecute) == 'x' || At(mode, GroupExecute) == 'x' || At(mode, OthersExecute) == 'x';
/// <summary>Whether the others triple carries the write bit.</summary>
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
private const int OwnerExecute = 3;
private const int GroupExecute = 6;
private const int OthersWrite = 8;
private const int OthersExecute = 9;
/// <remarks>
/// Reading back what <see cref="Format"/> wrote, rather than carrying the nine booleans through
/// <see cref="SftpEntry"/> as well. The alternative is a second representation of one fact, and the two
/// disagreeing is the failure this avoids — a row coloured for a bit the column beside it does not show.
/// Anything that is not a mode this type wrote answers false rather than throwing: these questions decide
/// a colour, and a listing is not worth failing over one.
/// </remarks>
private static char At(string mode, int index) => mode.Length == 10 ? mode[index] : '-';
} }
/// <summary> /// <summary>
+24 -1
View File
@@ -79,7 +79,30 @@ public sealed record StoredVault(
uint KeyGeneration, uint KeyGeneration,
int Permissions, int Permissions,
byte[]? WrappedVaultKey, byte[]? WrappedVaultKey,
bool RekeyRequired); bool RekeyRequired)
{
/// <summary>
/// The <c>Write</c> bit of <see cref="Permissions"/>.
/// </summary>
/// <remarks>
/// A literal rather than a reference to <c>DodoSSH.Domain.PermissionFlags</c>, because that enum is
/// the server's and no client project references the domain assembly. The value is part of the wire
/// contract — <c>VaultSummary.Permissions</c> is an opaque int by design — and a test pins the two
/// together so a renumbering cannot silently make a read-only vault look writable here.
/// </remarks>
private const int WriteFlag = 1 << 1;
/// <summary>
/// Whether the server would accept a change to this vault.
/// </summary>
/// <remarks>
/// A user-interface answer, not a boundary: the server checks the same bit on every push, and this
/// exists so somebody in a team as a viewer is not offered a Save button that ends in a 403. Its
/// counterpart — whether the items can be <em>read</em> — is not a permission at all but a question
/// of holding the vault key, and is answered by the keyring.
/// </remarks>
public bool CanWrite => (Permissions & WriteFlag) == WriteFlag;
}
/// <summary>The last item state the server confirmed.</summary> /// <summary>The last item state the server confirmed.</summary>
/// <param name="VaultId">Owning vault.</param> /// <param name="VaultId">Owning vault.</param>
+31
View File
@@ -88,6 +88,37 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// Records one vault, leaving the rest alone.
/// </summary>
/// <remarks>
/// For the vault this machine has just created, which exists here before the server's next
/// <c>/me</c> confirms it. <see cref="ReplaceAllAsync"/> would be wrong for that: it treats absence
/// as loss of access, and the one list that does not yet mention this vault is the one this client
/// last fetched.
/// </remarks>
public async Task UpsertAsync(StoredVault vault, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(vault);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<CachedVaultRow>()
.SingleOrDefaultAsync(r => r.VaultId == vault.VaultId, cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new CachedVaultRow { VaultId = vault.VaultId };
context.Add(row);
}
Apply(row, vault, clock.GetUtcNow());
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now) private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
{ {
row.Name = vault.Name; row.Name = vault.Name;
+87
View File
@@ -84,6 +84,93 @@ public sealed class VaultKeyring : IDisposable
} }
} }
/// <summary>
/// Takes a vault key this session has just generated.
/// </summary>
/// <param name="vaultId">The vault.</param>
/// <param name="vaultKey">
/// The plaintext key. <b>The keyring takes ownership</b> and zeroes it on disposal; the caller must
/// not keep a reference or zero it itself.
/// </param>
/// <param name="keyGeneration">The generation this key is for.</param>
/// <remarks>
/// Creating a team vault is the only case: the client generates the key, wraps it to itself and
/// sends the wrap, so the plaintext is already here and unwrapping the server's copy back would be
/// a round trip to learn something this process just chose. Adopting it also means the new vault is
/// usable immediately rather than at the next unlock, which is what somebody who just pressed
/// "create" expects.
/// </remarks>
public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(vaultKey);
if (keys.TryGetValue(vaultId, out var previous))
{
CryptographicOperations.ZeroMemory(previous);
}
keys[vaultId] = vaultKey;
generations[vaultId] = keyGeneration;
Unopened = [.. Unopened.Where(id => id != vaultId)];
}
/// <summary>
/// Tries to open a vault that has become readable since the session was unlocked.
/// </summary>
/// <returns>Whether the grant opened.</returns>
/// <remarks>
/// What a share looks like from the receiving end: the vault was in the list all along, listed and
/// unreadable, and a member holding Share has now wrapped its key. Re-opening it here rather than
/// waiting for a relock is the difference between "someone shared a vault with you" arriving and
/// arriving tomorrow.
/// </remarks>
public bool TryAdmit(UserSecretBundle bundle, StoredVault vault)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(vault);
if (vault.WrappedVaultKey is null)
{
return false;
}
if (keys.ContainsKey(vault.VaultId) && generations[vault.VaultId] == vault.KeyGeneration)
{
return true;
}
var key = VaultKeys.TryUnwrap(
bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, vault.KeyGeneration);
if (key is null)
{
return false;
}
Adopt(vault.VaultId, key, vault.KeyGeneration);
return true;
}
/// <summary>Records that a vault cannot be read, so the interface can say so.</summary>
/// <remarks>
/// The counterpart of <see cref="TryAdmit"/> for the case where the grant did not open. Kept
/// explicit rather than inferred from the absence of a key, because "no key" is also what a vault
/// this session has never heard of looks like.
/// </remarks>
public void MarkUnreadable(Guid vaultId)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (!Unopened.Contains(vaultId))
{
Unopened = [.. Unopened, vaultId];
}
}
/// <summary> /// <summary>
/// Borrows a vault's key. /// Borrows a vault's key.
/// </summary> /// </summary>
@@ -40,6 +40,17 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(DirectoryEntry))] [JsonSerializable(typeof(DirectoryEntry))]
[JsonSerializable(typeof(IReadOnlyList<DirectoryEntry>))] [JsonSerializable(typeof(IReadOnlyList<DirectoryEntry>))]
[JsonSerializable(typeof(VaultSummary))] [JsonSerializable(typeof(VaultSummary))]
[JsonSerializable(typeof(TeamSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamSummary>))]
[JsonSerializable(typeof(CreateTeamRequest))]
[JsonSerializable(typeof(TeamMemberSummary))]
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
[JsonSerializable(typeof(AddTeamMemberRequest))]
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
[JsonSerializable(typeof(KeyLogPage))]
[JsonSerializable(typeof(SyncPullRequest))] [JsonSerializable(typeof(SyncPullRequest))]
[JsonSerializable(typeof(SyncPullResponse))] [JsonSerializable(typeof(SyncPullResponse))]
[JsonSerializable(typeof(SyncPushRequest))] [JsonSerializable(typeof(SyncPushRequest))]
+64
View File
@@ -0,0 +1,64 @@
namespace DodoSSH.Contracts;
/// <summary>
/// One entry in the append-only log of every identity key statement ever published.
/// </summary>
/// <remarks>
/// <para>
/// Served so a client can recompute the chain for itself rather than taking the directory's word for a
/// public key. Every field the hash covers is here, in the order docs/crypto.md §7.2 hashes them, so
/// verification is <c>ComputeEntryHash(previous, …) == hash</c> and nothing else.
/// </para>
/// <para>
/// <see cref="Sequence"/> is deliberately <em>not</em> an input to the hash. It is assigned by the
/// database on insert, so a renumbered or gapped column cannot silently reorder history — order
/// follows the hash links. It is here to page with, and to compare against what a directory entry
/// claims.
/// </para>
/// </remarks>
/// <param name="Sequence">Monotonic position in the log.</param>
/// <param name="UserId">Whose key this is.</param>
/// <param name="Generation">Which generation of that user's key.</param>
/// <param name="EncryptionPublicKey">X25519 public key, 32 bytes.</param>
/// <param name="SigningPublicKey">Ed25519 public key, 32 bytes.</param>
/// <param name="StatementSignature">The self-signature over the key statement.</param>
/// <param name="PreviousHash">Hash of the preceding entry; all-zero for the first.</param>
/// <param name="Hash">This entry's hash, over the previous hash and its own contents.</param>
/// <param name="CreatedAt">When it was appended, truncated to milliseconds as the hash requires.</param>
public sealed record KeyLogRecord(
long Sequence,
Guid UserId,
int Generation,
byte[] EncryptionPublicKey,
byte[] SigningPublicKey,
byte[] StatementSignature,
byte[] PreviousHash,
byte[] Hash,
DateTimeOffset CreatedAt);
/// <summary>A page of the key log, with the head as of this response.</summary>
/// <remarks>
/// <para>
/// The head is what a client records in every grant it signs, which is what makes a forked view
/// detectable: for a server to show two clients different key logs it must keep both forks consistent
/// across every later shared operation, and any two clients touching one vault then disagree. It
/// converts an otherwise invisible key substitution into a visible one. It does not prevent it — see
/// ADR 0001.
/// </para>
/// <para>
/// <see cref="Head"/> describes the whole log, not this page: a client that pages from an old cursor
/// still learns where the end is, and can tell whether it has caught up without a second call.
/// </para>
/// </remarks>
/// <param name="Entries">Entries after the requested sequence, in ascending order.</param>
/// <param name="HeadSequence">Sequence of the log's last entry; 0 when the log is empty.</param>
/// <param name="Head">
/// Hash of the log's last entry. All-zero when the log is empty, which is the same value the first
/// entry records as its predecessor.
/// </param>
/// <param name="HasMore">Whether entries beyond this page are immediately available.</param>
public sealed record KeyLogPage(
IReadOnlyList<KeyLogRecord> Entries,
long HeadSequence,
byte[] Head,
bool HasMore);
+36
View File
@@ -80,4 +80,40 @@ public static class ProblemCodes
/// <summary>A push batch exceeded the operation count or payload size cap.</summary> /// <summary>A push batch exceeded the operation count or payload size cap.</summary>
public const string PushBatchTooLarge = "push-batch-too-large"; public const string PushBatchTooLarge = "push-batch-too-large";
/// <summary>
/// A team create or membership change was structurally invalid: a blank name, a slug that is
/// not URL-safe, an unknown role, or an account that does not exist here.
/// </summary>
public const string InvalidTeam = "invalid-team";
/// <summary>
/// The requested slug is already in use.
/// </summary>
/// <remarks>
/// Its own code rather than folded into <see cref="InvalidTeam"/>, because it is the one create
/// failure the caller could not have predicted from their own input and the only one whose
/// remedy is "pick a different one" rather than "fix what you typed".
/// </remarks>
public const string TeamSlugTaken = "team-slug-taken";
/// <summary>
/// The change would leave a team with no owner.
/// </summary>
/// <remarks>
/// Refused rather than allowed, because a team with no owner has nobody who can appoint one —
/// and the only route back would be an operator editing the database by hand.
/// </remarks>
public const string LastTeamOwner = "last-team-owner";
/// <summary>
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
/// </summary>
/// <remarks>
/// Never a statement about the wrapped key's <em>contents</em>. The server cannot open it, so a
/// grant containing garbage is accepted here and surfaces at the recipient as a tag failure,
/// with the signature naming who issued it. See docs/crypto.md §6.
/// </remarks>
public const string InvalidVaultGrant = "invalid-vault-grant";
} }
@@ -8,13 +8,61 @@ const DodoSSH.Contracts.ProblemCodes.IdentityBindingInvalid = "identity-binding-
const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string! const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string! const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string! const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string! const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string! const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string! const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string!
const DodoSSH.Contracts.ProblemCodes.TeamSlugTaken = "team-slug-taken" -> string!
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string! const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string! const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
DodoSSH.Contracts.AddTeamMemberRequest
DodoSSH.Contracts.AddTeamMemberRequest.<Clone>$() -> DodoSSH.Contracts.AddTeamMemberRequest!
DodoSSH.Contracts.AddTeamMemberRequest.AddTeamMemberRequest(System.Guid UserId, DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.AddTeamMemberRequest.Deconstruct(out System.Guid UserId, out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.AddTeamMemberRequest.Equals(DodoSSH.Contracts.AddTeamMemberRequest? other) -> bool
DodoSSH.Contracts.AddTeamMemberRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.AddTeamMemberRequest.Role.init -> void
DodoSSH.Contracts.AddTeamMemberRequest.UserId.get -> System.Guid
DodoSSH.Contracts.AddTeamMemberRequest.UserId.init -> void
DodoSSH.Contracts.ChangeTeamMemberRoleRequest
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.<Clone>$() -> DodoSSH.Contracts.ChangeTeamMemberRoleRequest!
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ChangeTeamMemberRoleRequest(DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.TeamMemberRole Role) -> void
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
DodoSSH.Contracts.CreateTeamRequest
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
DodoSSH.Contracts.CreateTeamRequest.Deconstruct(out System.Guid TeamId, out string! Name, out string! Slug, out string? Description) -> void
DodoSSH.Contracts.CreateTeamRequest.Description.get -> string?
DodoSSH.Contracts.CreateTeamRequest.Description.init -> void
DodoSSH.Contracts.CreateTeamRequest.Equals(DodoSSH.Contracts.CreateTeamRequest? other) -> bool
DodoSSH.Contracts.CreateTeamRequest.Name.get -> string!
DodoSSH.Contracts.CreateTeamRequest.Name.init -> void
DodoSSH.Contracts.CreateTeamRequest.Slug.get -> string!
DodoSSH.Contracts.CreateTeamRequest.Slug.init -> void
DodoSSH.Contracts.CreateTeamRequest.TeamId.get -> System.Guid
DodoSSH.Contracts.CreateTeamRequest.TeamId.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest
DodoSSH.Contracts.CreateTeamVaultRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamVaultRequest!
DodoSSH.Contracts.CreateTeamVaultRequest.CreateTeamVaultRequest(System.Guid VaultId, string! Name, byte[]! WrappedVaultKey, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.CreateTeamVaultRequest.Deconstruct(out System.Guid VaultId, out string! Name, out byte[]! WrappedVaultKey, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.CreateTeamVaultRequest.Equals(DodoSSH.Contracts.CreateTeamVaultRequest? other) -> bool
DodoSSH.Contracts.CreateTeamVaultRequest.GrantedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.CreateTeamVaultRequest.GrantedAt.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.GrantSignature.get -> byte[]!
DodoSSH.Contracts.CreateTeamVaultRequest.GrantSignature.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.Name.get -> string!
DodoSSH.Contracts.CreateTeamVaultRequest.Name.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.VaultId.get -> System.Guid
DodoSSH.Contracts.CreateTeamVaultRequest.VaultId.init -> void
DodoSSH.Contracts.CreateTeamVaultRequest.WrappedVaultKey.get -> byte[]!
DodoSSH.Contracts.CreateTeamVaultRequest.WrappedVaultKey.init -> void
DodoSSH.Contracts.DirectoryEntry DodoSSH.Contracts.DirectoryEntry
DodoSSH.Contracts.DirectoryEntry.<Clone>$() -> DodoSSH.Contracts.DirectoryEntry! DodoSSH.Contracts.DirectoryEntry.<Clone>$() -> DodoSSH.Contracts.DirectoryEntry!
DodoSSH.Contracts.DirectoryEntry.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! Fingerprint, out int KeyGeneration, out long KeyLogSequence) -> void DodoSSH.Contracts.DirectoryEntry.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! Fingerprint, out int KeyGeneration, out long KeyLogSequence) -> void
@@ -105,6 +153,25 @@ DodoSSH.Contracts.EnrollmentResponse.PersonalVaultId.get -> System.Guid
DodoSSH.Contracts.EnrollmentResponse.PersonalVaultId.init -> void DodoSSH.Contracts.EnrollmentResponse.PersonalVaultId.init -> void
DodoSSH.Contracts.EnrollmentResponse.UserId.get -> System.Guid DodoSSH.Contracts.EnrollmentResponse.UserId.get -> System.Guid
DodoSSH.Contracts.EnrollmentResponse.UserId.init -> void DodoSSH.Contracts.EnrollmentResponse.UserId.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest
DodoSSH.Contracts.IssueVaultGrantRequest.<Clone>$() -> DodoSSH.Contracts.IssueVaultGrantRequest!
DodoSSH.Contracts.IssueVaultGrantRequest.Deconstruct(out System.Guid RecipientUserId, out byte[]! RecipientKeyFingerprint, out uint KeyGeneration, out byte[]! WrappedVaultKey, out byte[]! KeyLogHead, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.IssueVaultGrantRequest.Equals(DodoSSH.Contracts.IssueVaultGrantRequest? other) -> bool
DodoSSH.Contracts.IssueVaultGrantRequest.GrantedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.IssueVaultGrantRequest.GrantedAt.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.GrantSignature.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.GrantSignature.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.IssueVaultGrantRequest(System.Guid RecipientUserId, byte[]! RecipientKeyFingerprint, uint KeyGeneration, byte[]! WrappedVaultKey, byte[]! KeyLogHead, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.IssueVaultGrantRequest.KeyGeneration.get -> uint
DodoSSH.Contracts.IssueVaultGrantRequest.KeyGeneration.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.KeyLogHead.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.KeyLogHead.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientKeyFingerprint.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientKeyFingerprint.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientUserId.get -> System.Guid
DodoSSH.Contracts.IssueVaultGrantRequest.RecipientUserId.init -> void
DodoSSH.Contracts.IssueVaultGrantRequest.WrappedVaultKey.get -> byte[]!
DodoSSH.Contracts.IssueVaultGrantRequest.WrappedVaultKey.init -> void
DodoSSH.Contracts.KdfParameters DodoSSH.Contracts.KdfParameters
DodoSSH.Contracts.KdfParameters.<Clone>$() -> DodoSSH.Contracts.KdfParameters! DodoSSH.Contracts.KdfParameters.<Clone>$() -> DodoSSH.Contracts.KdfParameters!
DodoSSH.Contracts.KdfParameters.Algorithm.get -> string! DodoSSH.Contracts.KdfParameters.Algorithm.get -> string!
@@ -120,6 +187,42 @@ DodoSSH.Contracts.KdfParameters.Passes.get -> int
DodoSSH.Contracts.KdfParameters.Passes.init -> void DodoSSH.Contracts.KdfParameters.Passes.init -> void
DodoSSH.Contracts.KdfParameters.Salt.get -> byte[]! DodoSSH.Contracts.KdfParameters.Salt.get -> byte[]!
DodoSSH.Contracts.KdfParameters.Salt.init -> void DodoSSH.Contracts.KdfParameters.Salt.init -> void
DodoSSH.Contracts.KeyLogPage
DodoSSH.Contracts.KeyLogPage.<Clone>$() -> DodoSSH.Contracts.KeyLogPage!
DodoSSH.Contracts.KeyLogPage.Deconstruct(out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>! Entries, out long HeadSequence, out byte[]! Head, out bool HasMore) -> void
DodoSSH.Contracts.KeyLogPage.Entries.get -> System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>!
DodoSSH.Contracts.KeyLogPage.Entries.init -> void
DodoSSH.Contracts.KeyLogPage.Equals(DodoSSH.Contracts.KeyLogPage? other) -> bool
DodoSSH.Contracts.KeyLogPage.HasMore.get -> bool
DodoSSH.Contracts.KeyLogPage.HasMore.init -> void
DodoSSH.Contracts.KeyLogPage.Head.get -> byte[]!
DodoSSH.Contracts.KeyLogPage.Head.init -> void
DodoSSH.Contracts.KeyLogPage.HeadSequence.get -> long
DodoSSH.Contracts.KeyLogPage.HeadSequence.init -> void
DodoSSH.Contracts.KeyLogPage.KeyLogPage(System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.KeyLogRecord!>! Entries, long HeadSequence, byte[]! Head, bool HasMore) -> void
DodoSSH.Contracts.KeyLogRecord
DodoSSH.Contracts.KeyLogRecord.<Clone>$() -> DodoSSH.Contracts.KeyLogRecord!
DodoSSH.Contracts.KeyLogRecord.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.KeyLogRecord.CreatedAt.init -> void
DodoSSH.Contracts.KeyLogRecord.Deconstruct(out long Sequence, out System.Guid UserId, out int Generation, out byte[]! EncryptionPublicKey, out byte[]! SigningPublicKey, out byte[]! StatementSignature, out byte[]! PreviousHash, out byte[]! Hash, out System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.KeyLogRecord.EncryptionPublicKey.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.EncryptionPublicKey.init -> void
DodoSSH.Contracts.KeyLogRecord.Equals(DodoSSH.Contracts.KeyLogRecord? other) -> bool
DodoSSH.Contracts.KeyLogRecord.Generation.get -> int
DodoSSH.Contracts.KeyLogRecord.Generation.init -> void
DodoSSH.Contracts.KeyLogRecord.Hash.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.Hash.init -> void
DodoSSH.Contracts.KeyLogRecord.KeyLogRecord(long Sequence, System.Guid UserId, int Generation, byte[]! EncryptionPublicKey, byte[]! SigningPublicKey, byte[]! StatementSignature, byte[]! PreviousHash, byte[]! Hash, System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.KeyLogRecord.PreviousHash.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.PreviousHash.init -> void
DodoSSH.Contracts.KeyLogRecord.Sequence.get -> long
DodoSSH.Contracts.KeyLogRecord.Sequence.init -> void
DodoSSH.Contracts.KeyLogRecord.SigningPublicKey.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.SigningPublicKey.init -> void
DodoSSH.Contracts.KeyLogRecord.StatementSignature.get -> byte[]!
DodoSSH.Contracts.KeyLogRecord.StatementSignature.init -> void
DodoSSH.Contracts.KeyLogRecord.UserId.get -> System.Guid
DodoSSH.Contracts.KeyLogRecord.UserId.init -> void
DodoSSH.Contracts.KeyStatement DodoSSH.Contracts.KeyStatement
DodoSSH.Contracts.KeyStatement.<Clone>$() -> DodoSSH.Contracts.KeyStatement! DodoSSH.Contracts.KeyStatement.<Clone>$() -> DodoSSH.Contracts.KeyStatement!
DodoSSH.Contracts.KeyStatement.CreatedAt.get -> System.DateTimeOffset DodoSSH.Contracts.KeyStatement.CreatedAt.get -> System.DateTimeOffset
@@ -451,6 +554,96 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
DodoSSH.Contracts.SyncPushResult.Version.get -> int? DodoSSH.Contracts.SyncPushResult.Version.get -> int?
DodoSSH.Contracts.SyncPushResult.Version.init -> void DodoSSH.Contracts.SyncPushResult.Version.init -> void
DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Owner = 40 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberRole.Viewer = 10 -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Active = 2 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Invited = 1 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Revoked = 3 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberStatus.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary
DodoSSH.Contracts.TeamMemberSummary.<Clone>$() -> DodoSSH.Contracts.TeamMemberSummary!
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.DisplayName.get -> string?
DodoSSH.Contracts.TeamMemberSummary.DisplayName.init -> void
DodoSSH.Contracts.TeamMemberSummary.Email.get -> string?
DodoSSH.Contracts.TeamMemberSummary.Email.init -> void
DodoSSH.Contracts.TeamMemberSummary.Equals(DodoSSH.Contracts.TeamMemberSummary? other) -> bool
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.get -> bool
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.init -> void
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.init -> void
DodoSSH.Contracts.TeamMemberSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamMemberSummary.Role.init -> void
DodoSSH.Contracts.TeamMemberSummary.Status.get -> DodoSSH.Contracts.TeamMemberStatus
DodoSSH.Contracts.TeamMemberSummary.Status.init -> void
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt) -> void
DodoSSH.Contracts.TeamMemberSummary.UserId.get -> System.Guid
DodoSSH.Contracts.TeamMemberSummary.UserId.init -> void
DodoSSH.Contracts.TeamSummary
DodoSSH.Contracts.TeamSummary.<Clone>$() -> DodoSSH.Contracts.TeamSummary!
DodoSSH.Contracts.TeamSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.TeamSummary.CreatedAt.init -> void
DodoSSH.Contracts.TeamSummary.Deconstruct(out System.Guid TeamId, out string! Name, out string! Slug, out string? Description, out DodoSSH.Contracts.TeamMemberRole Role, out int MemberCount, out int VaultCount, out System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.TeamSummary.Description.get -> string?
DodoSSH.Contracts.TeamSummary.Description.init -> void
DodoSSH.Contracts.TeamSummary.Equals(DodoSSH.Contracts.TeamSummary? other) -> bool
DodoSSH.Contracts.TeamSummary.MemberCount.get -> int
DodoSSH.Contracts.TeamSummary.MemberCount.init -> void
DodoSSH.Contracts.TeamSummary.Name.get -> string!
DodoSSH.Contracts.TeamSummary.Name.init -> void
DodoSSH.Contracts.TeamSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
DodoSSH.Contracts.TeamSummary.Role.init -> void
DodoSSH.Contracts.TeamSummary.Slug.get -> string!
DodoSSH.Contracts.TeamSummary.Slug.init -> void
DodoSSH.Contracts.TeamSummary.TeamId.get -> System.Guid
DodoSSH.Contracts.TeamSummary.TeamId.init -> void
DodoSSH.Contracts.TeamSummary.TeamSummary(System.Guid TeamId, string! Name, string! Slug, string? Description, DodoSSH.Contracts.TeamMemberRole Role, int MemberCount, int VaultCount, System.DateTimeOffset CreatedAt) -> void
DodoSSH.Contracts.TeamSummary.VaultCount.get -> int
DodoSSH.Contracts.TeamSummary.VaultCount.init -> void
DodoSSH.Contracts.VaultGrantsResponse
DodoSSH.Contracts.VaultGrantsResponse.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse!
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
DodoSSH.Contracts.VaultGrantsResponse.Equals(DodoSSH.Contracts.VaultGrantsResponse? other) -> bool
DodoSSH.Contracts.VaultGrantsResponse.Grants.get -> System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>!
DodoSSH.Contracts.VaultGrantsResponse.Grants.init -> void
DodoSSH.Contracts.VaultGrantsResponse.KeyGeneration.get -> uint
DodoSSH.Contracts.VaultGrantsResponse.KeyGeneration.init -> void
DodoSSH.Contracts.VaultGrantsResponse.RekeyRequired.get -> bool
DodoSSH.Contracts.VaultGrantsResponse.RekeyRequired.init -> void
DodoSSH.Contracts.VaultGrantsResponse.VaultGrantsResponse(System.Guid VaultId, uint KeyGeneration, bool RekeyRequired, System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
DodoSSH.Contracts.VaultGrantsResponse.VaultId.get -> System.Guid
DodoSSH.Contracts.VaultGrantsResponse.VaultId.init -> void
DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.Active = 1 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.AwaitingRewrap = 2 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.Revoked = 3 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantState.Unspecified = 0 -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantSummary
DodoSSH.Contracts.VaultGrantSummary.<Clone>$() -> DodoSSH.Contracts.VaultGrantSummary!
DodoSSH.Contracts.VaultGrantSummary.CreatedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.VaultGrantSummary.CreatedAt.init -> void
DodoSSH.Contracts.VaultGrantSummary.Deconstruct(out System.Guid RecipientUserId, out string? Email, out string? DisplayName, out uint KeyGeneration, out DodoSSH.Contracts.VaultGrantState State, out System.Guid GranterUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset? RevokedAt) -> void
DodoSSH.Contracts.VaultGrantSummary.DisplayName.get -> string?
DodoSSH.Contracts.VaultGrantSummary.DisplayName.init -> void
DodoSSH.Contracts.VaultGrantSummary.Email.get -> string?
DodoSSH.Contracts.VaultGrantSummary.Email.init -> void
DodoSSH.Contracts.VaultGrantSummary.Equals(DodoSSH.Contracts.VaultGrantSummary? other) -> bool
DodoSSH.Contracts.VaultGrantSummary.GranterUserId.get -> System.Guid
DodoSSH.Contracts.VaultGrantSummary.GranterUserId.init -> void
DodoSSH.Contracts.VaultGrantSummary.KeyGeneration.get -> uint
DodoSSH.Contracts.VaultGrantSummary.KeyGeneration.init -> void
DodoSSH.Contracts.VaultGrantSummary.RecipientUserId.get -> System.Guid
DodoSSH.Contracts.VaultGrantSummary.RecipientUserId.init -> void
DodoSSH.Contracts.VaultGrantSummary.RevokedAt.get -> System.DateTimeOffset?
DodoSSH.Contracts.VaultGrantSummary.RevokedAt.init -> void
DodoSSH.Contracts.VaultGrantSummary.State.get -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantSummary.State.init -> void
DodoSSH.Contracts.VaultGrantSummary.VaultGrantSummary(System.Guid RecipientUserId, string? Email, string? DisplayName, uint KeyGeneration, DodoSSH.Contracts.VaultGrantState State, System.Guid GranterUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset? RevokedAt) -> void
DodoSSH.Contracts.VaultSummary DodoSSH.Contracts.VaultSummary
DodoSSH.Contracts.VaultSummary.<Clone>$() -> DodoSSH.Contracts.VaultSummary! DodoSSH.Contracts.VaultSummary.<Clone>$() -> DodoSSH.Contracts.VaultSummary!
DodoSSH.Contracts.VaultSummary.Deconstruct(out System.Guid VaultId, out string! Name, out bool IsPersonal, out System.Guid? TeamId, out uint KeyGeneration, out int Permissions, out byte[]? WrappedVaultKey, out bool RekeyRequired) -> void DodoSSH.Contracts.VaultSummary.Deconstruct(out System.Guid VaultId, out string! Name, out bool IsPersonal, out System.Guid? TeamId, out uint KeyGeneration, out int Permissions, out byte[]? WrappedVaultKey, out bool RekeyRequired) -> void
@@ -472,6 +665,18 @@ DodoSSH.Contracts.VaultSummary.VaultId.init -> void
DodoSSH.Contracts.VaultSummary.VaultSummary(System.Guid VaultId, string! Name, bool IsPersonal, System.Guid? TeamId, uint KeyGeneration, int Permissions, byte[]? WrappedVaultKey, bool RekeyRequired) -> void DodoSSH.Contracts.VaultSummary.VaultSummary(System.Guid VaultId, string! Name, bool IsPersonal, System.Guid? TeamId, uint KeyGeneration, int Permissions, byte[]? WrappedVaultKey, bool RekeyRequired) -> void
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.get -> byte[]? DodoSSH.Contracts.VaultSummary.WrappedVaultKey.get -> byte[]?
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void
override DodoSSH.Contracts.AddTeamMemberRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.AddTeamMemberRequest.GetHashCode() -> int
override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
override DodoSSH.Contracts.CreateTeamVaultRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.CreateTeamVaultRequest.GetHashCode() -> int
override DodoSSH.Contracts.CreateTeamVaultRequest.ToString() -> string!
override DodoSSH.Contracts.DirectoryEntry.Equals(object? obj) -> bool override DodoSSH.Contracts.DirectoryEntry.Equals(object? obj) -> bool
override DodoSSH.Contracts.DirectoryEntry.GetHashCode() -> int override DodoSSH.Contracts.DirectoryEntry.GetHashCode() -> int
override DodoSSH.Contracts.DirectoryEntry.ToString() -> string! override DodoSSH.Contracts.DirectoryEntry.ToString() -> string!
@@ -487,9 +692,18 @@ override DodoSSH.Contracts.EnrollmentRequest.ToString() -> string!
override DodoSSH.Contracts.EnrollmentResponse.Equals(object? obj) -> bool override DodoSSH.Contracts.EnrollmentResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.EnrollmentResponse.GetHashCode() -> int override DodoSSH.Contracts.EnrollmentResponse.GetHashCode() -> int
override DodoSSH.Contracts.EnrollmentResponse.ToString() -> string! override DodoSSH.Contracts.EnrollmentResponse.ToString() -> string!
override DodoSSH.Contracts.IssueVaultGrantRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.IssueVaultGrantRequest.GetHashCode() -> int
override DodoSSH.Contracts.IssueVaultGrantRequest.ToString() -> string!
override DodoSSH.Contracts.KdfParameters.Equals(object? obj) -> bool override DodoSSH.Contracts.KdfParameters.Equals(object? obj) -> bool
override DodoSSH.Contracts.KdfParameters.GetHashCode() -> int override DodoSSH.Contracts.KdfParameters.GetHashCode() -> int
override DodoSSH.Contracts.KdfParameters.ToString() -> string! override DodoSSH.Contracts.KdfParameters.ToString() -> string!
override DodoSSH.Contracts.KeyLogPage.Equals(object? obj) -> bool
override DodoSSH.Contracts.KeyLogPage.GetHashCode() -> int
override DodoSSH.Contracts.KeyLogPage.ToString() -> string!
override DodoSSH.Contracts.KeyLogRecord.Equals(object? obj) -> bool
override DodoSSH.Contracts.KeyLogRecord.GetHashCode() -> int
override DodoSSH.Contracts.KeyLogRecord.ToString() -> string!
override DodoSSH.Contracts.KeyStatement.Equals(object? obj) -> bool override DodoSSH.Contracts.KeyStatement.Equals(object? obj) -> bool
override DodoSSH.Contracts.KeyStatement.GetHashCode() -> int override DodoSSH.Contracts.KeyStatement.GetHashCode() -> int
override DodoSSH.Contracts.KeyStatement.ToString() -> string! override DodoSSH.Contracts.KeyStatement.ToString() -> string!
@@ -547,9 +761,29 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
override DodoSSH.Contracts.SyncPushResult.ToString() -> string! override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
override DodoSSH.Contracts.TeamSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.TeamSummary.GetHashCode() -> int
override DodoSSH.Contracts.TeamSummary.ToString() -> string!
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
override DodoSSH.Contracts.VaultGrantSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantSummary.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantSummary.ToString() -> string!
override DodoSSH.Contracts.VaultSummary.Equals(object? obj) -> bool override DodoSSH.Contracts.VaultSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultSummary.GetHashCode() -> int override DodoSSH.Contracts.VaultSummary.GetHashCode() -> int
override DodoSSH.Contracts.VaultSummary.ToString() -> string! override DodoSSH.Contracts.VaultSummary.ToString() -> string!
static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
static DodoSSH.Contracts.CreateTeamVaultRequest.operator ==(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
static DodoSSH.Contracts.DirectoryEntry.operator !=(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool static DodoSSH.Contracts.DirectoryEntry.operator !=(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool
static DodoSSH.Contracts.DirectoryEntry.operator ==(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool static DodoSSH.Contracts.DirectoryEntry.operator ==(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool
static DodoSSH.Contracts.DodoSshConfiguration.operator !=(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool static DodoSSH.Contracts.DodoSshConfiguration.operator !=(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool
@@ -563,8 +797,14 @@ static DodoSSH.Contracts.EnrollmentRequest.operator !=(DodoSSH.Contracts.Enrollm
static DodoSSH.Contracts.EnrollmentRequest.operator ==(DodoSSH.Contracts.EnrollmentRequest? left, DodoSSH.Contracts.EnrollmentRequest? right) -> bool static DodoSSH.Contracts.EnrollmentRequest.operator ==(DodoSSH.Contracts.EnrollmentRequest? left, DodoSSH.Contracts.EnrollmentRequest? right) -> bool
static DodoSSH.Contracts.EnrollmentResponse.operator !=(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool static DodoSSH.Contracts.EnrollmentResponse.operator !=(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool
static DodoSSH.Contracts.EnrollmentResponse.operator ==(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool static DodoSSH.Contracts.EnrollmentResponse.operator ==(DodoSSH.Contracts.EnrollmentResponse? left, DodoSSH.Contracts.EnrollmentResponse? right) -> bool
static DodoSSH.Contracts.IssueVaultGrantRequest.operator !=(DodoSSH.Contracts.IssueVaultGrantRequest? left, DodoSSH.Contracts.IssueVaultGrantRequest? right) -> bool
static DodoSSH.Contracts.IssueVaultGrantRequest.operator ==(DodoSSH.Contracts.IssueVaultGrantRequest? left, DodoSSH.Contracts.IssueVaultGrantRequest? right) -> bool
static DodoSSH.Contracts.KdfParameters.operator !=(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool static DodoSSH.Contracts.KdfParameters.operator !=(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool
static DodoSSH.Contracts.KdfParameters.operator ==(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool static DodoSSH.Contracts.KdfParameters.operator ==(DodoSSH.Contracts.KdfParameters? left, DodoSSH.Contracts.KdfParameters? right) -> bool
static DodoSSH.Contracts.KeyLogPage.operator !=(DodoSSH.Contracts.KeyLogPage? left, DodoSSH.Contracts.KeyLogPage? right) -> bool
static DodoSSH.Contracts.KeyLogPage.operator ==(DodoSSH.Contracts.KeyLogPage? left, DodoSSH.Contracts.KeyLogPage? right) -> bool
static DodoSSH.Contracts.KeyLogRecord.operator !=(DodoSSH.Contracts.KeyLogRecord? left, DodoSSH.Contracts.KeyLogRecord? right) -> bool
static DodoSSH.Contracts.KeyLogRecord.operator ==(DodoSSH.Contracts.KeyLogRecord? left, DodoSSH.Contracts.KeyLogRecord? right) -> bool
static DodoSSH.Contracts.KeyStatement.operator !=(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool static DodoSSH.Contracts.KeyStatement.operator !=(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool
static DodoSSH.Contracts.KeyStatement.operator ==(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool static DodoSSH.Contracts.KeyStatement.operator ==(DodoSSH.Contracts.KeyStatement? left, DodoSSH.Contracts.KeyStatement? right) -> bool
static DodoSSH.Contracts.MeResponse.operator !=(DodoSSH.Contracts.MeResponse? left, DodoSSH.Contracts.MeResponse? right) -> bool static DodoSSH.Contracts.MeResponse.operator !=(DodoSSH.Contracts.MeResponse? left, DodoSSH.Contracts.MeResponse? right) -> bool
@@ -603,5 +843,13 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
static DodoSSH.Contracts.TeamSummary.operator ==(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator ==(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
+266
View File
@@ -0,0 +1,266 @@
namespace DodoSSH.Contracts;
/// <summary>
/// A member's role within a team, as it travels on the wire.
/// </summary>
/// <remarks>
/// <para>
/// A separate type from <c>DodoSSH.Domain.TeamRole</c> only because both are visible inside the
/// server, exactly as <c>GrantPurpose</c> is separate from <c>GrantKind</c>. The <b>numeric values
/// must match</b> that enum, and a test pins them: the two are converted by cast, so a renumbering
/// here silently promotes or demotes every member on the next deployment.
/// </para>
/// <para>
/// There is no <c>ConnectOnly</c> role, and there will not be one built this way. Connect is a
/// user-interface hint rather than a boundary — SSH terminates on the client, so opening a session
/// needs the credential's plaintext on that machine, and "may connect but may not read the key" is
/// unenforceable in this architecture. See <c>docs/adr/0001-e2ee-trust-model.md</c>.
/// </para>
/// </remarks>
public enum TeamMemberRole
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>May read the team's vaults and nothing else.</summary>
Viewer = 10,
/// <summary>May read and change the team's vaults.</summary>
Member = 20,
/// <summary>May also manage members, create vaults, and share vault keys.</summary>
Admin = 30,
/// <summary>Sole owner. Everything an admin may do, and cannot be removed while sole.</summary>
Owner = 40,
}
/// <summary>State of a team membership, as it travels on the wire.</summary>
/// <remarks>
/// Values match <c>DodoSSH.Domain.MembershipStatus</c>, for the reason
/// <see cref="TeamMemberRole"/> gives.
/// </remarks>
public enum TeamMemberStatus
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>
/// Invited but not yet accepted.
/// </summary>
/// <remarks>
/// Nothing writes this today. An invitation needs a token with a lifetime and an outbound mail
/// path, and this server has neither — so a member is added by looking their account up in the
/// directory, which requires that they have signed in here at least once. Retained because the
/// column exists and a client must not fail on a value a later server may send.
/// </remarks>
Invited = 1,
/// <summary>Active member.</summary>
Active = 2,
/// <summary>Removed. Retained so audit history stays resolvable to a person.</summary>
Revoked = 3,
}
/// <summary>State of a vault key grant, as it travels on the wire.</summary>
/// <remarks>Values match <c>DodoSSH.Domain.GrantState</c>.</remarks>
public enum VaultGrantState
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Usable.</summary>
Active = 1,
/// <summary>
/// The recipient's identity key changed or the vault was rekeyed, so a member holding Share
/// must wrap the key afresh before the recipient can read anything again.
/// </summary>
AwaitingRewrap = 2,
/// <summary>
/// Revoked. Blocks future reads only — anything already downloaded is already gone, and the
/// remediation for a departed member is rotating the SSH credential itself. See ADR 0001.
/// </summary>
Revoked = 3,
}
/// <summary>A team the caller belongs to.</summary>
/// <param name="TeamId">The team.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">URL-safe unique identifier.</param>
/// <param name="Description">Optional description.</param>
/// <param name="Role">The caller's own role.</param>
/// <param name="MemberCount">Active members, including the caller.</param>
/// <param name="VaultCount">Vaults the team owns.</param>
/// <param name="CreatedAt">When the team was created.</param>
public sealed record TeamSummary(
Guid TeamId,
string Name,
string Slug,
string? Description,
TeamMemberRole Role,
int MemberCount,
int VaultCount,
DateTimeOffset CreatedAt);
/// <summary>A request to create a team.</summary>
/// <remarks>
/// <see cref="TeamId"/> is chosen by the client for the same reason a vault id is: a request whose
/// response was lost can be re-sent verbatim and returns the identical team rather than creating a
/// second one under a name the user only meant to type once.
/// </remarks>
/// <param name="TeamId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">
/// URL-safe unique identifier, lowercase. Unique across the deployment, so this is the one field a
/// create can fail on for a reason the caller cannot see coming.
/// </param>
/// <param name="Description">Optional description.</param>
public sealed record CreateTeamRequest(
Guid TeamId,
string Name,
string Slug,
string? Description);
/// <summary>One member of a team.</summary>
/// <remarks>
/// Carries no last-active time and no avatar. <c>UserAccount.LastSeenAtUtc</c> is written at
/// provisioning and at enrollment and at no other point, so a column labelled "last active" would
/// be reporting something else entirely; and no picture is stored anywhere.
/// </remarks>
/// <param name="UserId">The member.</param>
/// <param name="Email">Email, for display.</param>
/// <param name="DisplayName">Display name.</param>
/// <param name="Role">Role within the team.</param>
/// <param name="Status">Membership state.</param>
/// <param name="IsEnrolled">
/// Whether this member has published an identity key. A member who has not cannot be granted a
/// vault key at all — there is nothing to wrap one to — so the interface has to be able to say so
/// rather than offering a share that would fail.
/// </param>
/// <param name="JoinedAt">When the membership became active.</param>
public sealed record TeamMemberSummary(
Guid UserId,
string? Email,
string? DisplayName,
TeamMemberRole Role,
TeamMemberStatus Status,
bool IsEnrolled,
DateTimeOffset? JoinedAt);
/// <summary>Adds a member to a team.</summary>
/// <remarks>
/// By user id rather than by email, and the id comes from a directory lookup the caller has already
/// made. That ordering is not incidental: whoever adds a member is usually about to wrap a vault key
/// to their public key, and the key they must verify is the one the directory returned. Adding by
/// email here would put an account resolution the client never saw between those two steps.
/// </remarks>
/// <param name="UserId">The account to add, as returned by the directory.</param>
/// <param name="Role">Role to grant.</param>
public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
/// <summary>Changes a member's role.</summary>
/// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
/// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary>
/// <remarks>
/// Shaped like <see cref="PersonalVaultRequest"/> and for the same reasons: the vault key is
/// generated on the client and sealed to the creator's own X25519 key, so the server cannot produce
/// this and cannot check that <see cref="WrappedVaultKey"/> contains anything in particular. A vault
/// created with no grant would be a container nobody could ever open, so the two arrive together.
/// <para>
/// 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. Every <em>other</em> member's grant does carry
/// one — see <see cref="IssueVaultGrantRequest"/>.
/// </para>
/// </remarks>
/// <param name="VaultId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name. Plaintext, as all vault names are.</param>
/// <param name="WrappedVaultKey">The vault key sealed to the creator's encryption key.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record CreateTeamVaultRequest(
Guid VaultId,
string Name,
byte[] WrappedVaultKey,
byte[] GrantSignature,
DateTimeOffset GrantedAt);
/// <summary>Issues a vault key grant to another member.</summary>
/// <remarks>
/// <para>
/// The wrap is made by a client that holds the vault key, to a public key it has verified. The
/// server stores both the ciphertext and the signature and can check neither — which is the property
/// that makes it a zero-knowledge server rather than a key-holding one.
/// </para>
/// <para>
/// <see cref="KeyLogHead"/> is required here and absent for a self-grant. A third party's key could
/// have been substituted by the server; recording the log head the granter observed while wrapping
/// is what converts that from an undetectable attack into a detectable one. See docs/crypto.md §7.2.
/// </para>
/// </remarks>
/// <param name="RecipientUserId">Who the key was wrapped to.</param>
/// <param name="RecipientKeyFingerprint">
/// The exact identity key it was wrapped to. Stored so a later rotation invalidates this grant
/// explicitly rather than leaving a row that no longer opens.
/// </param>
/// <param name="KeyGeneration">
/// The generation wrapped. Rejected when it is not the vault's current one, because a grant for a
/// superseded generation opens nothing and would read as corruption at the far end.
/// </param>
/// <param name="WrappedVaultKey">The vault key sealed to the recipient. Opaque to the server.</param>
/// <param name="KeyLogHead">The key log head the granter observed while wrapping.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record IssueVaultGrantRequest(
Guid RecipientUserId,
byte[] RecipientKeyFingerprint,
uint KeyGeneration,
byte[] WrappedVaultKey,
byte[] KeyLogHead,
byte[] GrantSignature,
DateTimeOffset GrantedAt);
/// <summary>One vault key grant, as the sharing interface sees it.</summary>
/// <remarks>
/// The wrapped key itself is deliberately not here. A member reads their own through
/// <see cref="VaultSummary.WrappedVaultKey"/>; this listing exists so somebody holding Share can see
/// <em>who has one</em>, and serving every member's sealed key to every member would be a pointless
/// widening of what a stolen access token yields.
/// </remarks>
/// <param name="RecipientUserId">Who holds it.</param>
/// <param name="Email">Their email, for display.</param>
/// <param name="DisplayName">Their display name.</param>
/// <param name="KeyGeneration">Generation this grant is for.</param>
/// <param name="State">Grant state.</param>
/// <param name="GranterUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="RevokedAt">When it was revoked, if it was.</param>
public sealed record VaultGrantSummary(
Guid RecipientUserId,
string? Email,
string? DisplayName,
uint KeyGeneration,
VaultGrantState State,
Guid GranterUserId,
DateTimeOffset CreatedAt,
DateTimeOffset? RevokedAt);
/// <summary>Who can open a vault, and at which generation.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="KeyGeneration">
/// The vault's current generation. A grant listed at anything lower is stale, which is what a client
/// compares against rather than inferring from <see cref="VaultGrantSummary.State"/> alone.
/// </param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
/// <param name="Grants">Every grant, including revoked ones.</param>
public sealed record VaultGrantsResponse(
Guid VaultId,
uint KeyGeneration,
bool RekeyRequired,
IReadOnlyList<VaultGrantSummary> Grants);
@@ -56,6 +56,36 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False", "POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False", "POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
// nothing to wrap and no signature to attribute it with. There is no search here — see
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
"GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False",
// The other half of the same decision: the directory says what a key is, this is how a client
// checks that claim against a chain the server cannot rewrite without every other client
// noticing. Nothing in it is secret.
"GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False",
// Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before
// they have set a vault up must still be able to see the team they are now in.
"GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False",
"GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False",
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
// in it, and neither is reachable without a key of one's own.
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
"POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False",
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
// sharing graph — and the two writes are gated on Share inside the handler, which this table
// cannot see. See VaultGrantEndpoints.
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any // Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
// token exists, and an orchestrator probe that needs credentials reports the wrong thing. // token exists, and an orchestrator probe that needs credentials reports the wrong thing.
// MapHealthChecks constrains no verb, hence ANY. // MapHealthChecks constrains no verb, hence ANY.
@@ -0,0 +1,498 @@
using System.Net;
using System.Net.Http.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Teams, membership and the vault key grants that make a team vault readable.
/// </summary>
/// <remarks>
/// <para>
/// The half of M3 that runs on the server, which is the half that decides <em>what will be served</em>.
/// Whether a member can decrypt what they are served is decided by holding a key, and no test here can
/// assert it — that lives in the client suite, where a key exists. The two are separate on purpose and
/// these tests are written to keep them separate: none of them checks that a wrapped key is right,
/// because the server cannot.
/// </para>
/// <para>
/// The cases worth having are the ones where a mistake would be invisible. A member removed but still
/// served; a viewer allowed to push; a vault visible to a team it does not belong to; a grant accepted
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
/// outside.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class TeamEndpointTests(ApiFixture fixture)
{
private const string TeamsUrl = "/api/v1/teams";
private const string EnrollUrl = "/api/v1/me/enrollment";
[Fact]
public async Task CreatingATeam_MakesTheCallerItsOwner()
{
var client = await EnrolledClientAsync("team-owner");
var team = await CreateTeamAsync(client, "Platform");
team.Role.ShouldBe(TeamMemberRole.Owner);
team.MemberCount.ShouldBe(1);
team.VaultCount.ShouldBe(0);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
listed.ShouldContain(row => row.TeamId == team.TeamId);
}
/// <remarks>
/// The same body twice, as a client whose response was lost would send it. Enrollment behaves this way
/// and a team create has the same shape — a client-chosen id — so it has to behave the same or a lost
/// response leaves somebody with two teams under one name.
/// </remarks>
[Fact]
public async Task RepeatingACreate_ReturnsTheSameTeamRatherThanASecondOne()
{
var client = await EnrolledClientAsync("team-retry");
var request = new CreateTeamRequest(
Guid.CreateVersion7(), "Retry", $"retry-{Guid.CreateVersion7():N}", null);
var first = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
var second = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
second.TeamId.ShouldBe(first.TeamId);
var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);
listed.Count(row => row.TeamId == first.TeamId).ShouldBe(1);
}
[Fact]
public async Task ASlugAlreadyInUse_IsRefusedWithItsOwnCode()
{
var client = await EnrolledClientAsync("team-slug");
var slug = $"taken-{Guid.CreateVersion7():N}";
await PostAsync<CreateTeamRequest, TeamSummary>(
client, TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "First", slug, null));
var response = await client.PostContractAsync(
TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.TeamSlugTaken);
}
/// <remarks>
/// A team somebody is not in answers 404, not 403. Distinguishing them would let a caller confirm
/// which team ids exist, and team ids travel in URLs.
/// </remarks>
[Fact]
public async Task ATeamTheCallerIsNotIn_IsIndistinguishableFromOneThatDoesNotExist()
{
var owner = await EnrolledClientAsync("team-private-owner");
var stranger = await EnrolledClientAsync("team-private-stranger");
var team = await CreateTeamAsync(owner, "Private");
var real = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
var invented = await stranger.GetAsync(
new Uri($"{TeamsUrl}/{Guid.CreateVersion7()}/members", UriKind.Relative),
TestContext.Current.CancellationToken);
real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
invented.StatusCode.ShouldBe(real.StatusCode);
}
/// <remarks>
/// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
/// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
/// design — the server can grant access to the ciphertext and cannot grant the ability to read it.
/// </remarks>
[Fact]
public async Task AnAddedMember_SeesTheTeamVaultWithNoKeyUntilSomebodyWrapsOne()
{
var owner = await EnrolledClientAsync("grant-owner", "owner@example.com");
var member = await EnrolledClientAsync("grant-member", "member@example.com");
var team = await CreateTeamAsync(owner, "Sharing");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "member@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var me = await ReadAsync<MeResponse>(member, "/api/v1/me");
var vault = me.Vaults.SingleOrDefault(summary => summary.VaultId == vaultId);
vault.ShouldNotBeNull("membership is what makes a team vault visible");
vault.WrappedVaultKey.ShouldBeNull("and it is not what makes it readable");
vault.IsPersonal.ShouldBeFalse();
vault.TeamId.ShouldBe(team.TeamId);
}
/// <remarks>
/// A viewer may read the vault and may not write to it. The failure this guards is the quiet one: a
/// role that resolved to the wrong flags would let somebody who was added to look at a vault change
/// what everybody else connects with.
/// </remarks>
[Fact]
public async Task AViewer_MayPullAndMayNotPush()
{
var owner = await EnrolledClientAsync("viewer-owner", "vowner@example.com");
var viewer = await EnrolledClientAsync("viewer-member", "viewer@example.com");
var team = await CreateTeamAsync(owner, "Read only");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "viewer@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Viewer);
var pull = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
pull.StatusCode.ShouldBe(HttpStatusCode.OK);
var push = await viewer.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));
push.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
var problem = await push.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.Forbidden);
}
/// <remarks>
/// And the reverse, which is what removal has to mean: the vault stops being served at all. Note what
/// is <em>not</em> asserted — that they have forgotten anything. They have not, and ADR 0001 says so.
/// </remarks>
[Fact]
public async Task ARemovedMember_StopsBeingServedTheTeamsVault()
{
var owner = await EnrolledClientAsync("removal-owner", "rowner@example.com");
var member = await EnrolledClientAsync("removal-member", "rmember@example.com");
var team = await CreateTeamAsync(owner, "Departures");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "rmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var before = await ReadAsync<MeResponse>(member, "/api/v1/me");
before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);
var removed = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var after = await ReadAsync<MeResponse>(member, "/api/v1/me");
after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);
var pull = await member.PostContractAsync(
$"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));
pull.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
/// <remarks>
/// Removing a member leaves the vault flagged for rekey, which is a promise the server records and
/// cannot keep on its own: rekeying re-wraps every item's data key and only a client holding the
/// current one can do that. The flag is what the interface reads to say so; M5 is what acts on it.
/// </remarks>
[Fact]
public async Task RemovingAMember_FlagsTheTeamsVaultsForRekey()
{
var owner = await EnrolledClientAsync("rekey-owner", "kowner@example.com");
await EnrolledClientAsync("rekey-member", "kmember@example.com");
var team = await CreateTeamAsync(owner, "Rekeys");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "kmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{entry.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
grants.RekeyRequired.ShouldBeTrue();
}
/// <remarks>
/// The owner cannot be removed or demoted, because nothing can appoint a replacement yet. Refused
/// with a code the client can act on rather than a bare 400, since "you cannot do that" and "you did
/// that wrong" lead somewhere different.
/// </remarks>
[Fact]
public async Task TheOwner_CannotBeRemoved()
{
var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");
var team = await CreateTeamAsync(owner, "Sole");
var me = await ReadAsync<MeResponse>(owner, "/api/v1/me");
var response = await owner.DeleteAsync(
new Uri($"{TeamsUrl}/{team.TeamId}/members/{me.UserId}", UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.LastTeamOwner);
}
/// <remarks>
/// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
/// does nothing, because the access check will go on refusing them the vault — and a sharing screen
/// listing a grant whose holder cannot fetch anything is worse than an error.
/// </remarks>
[Fact]
public async Task AGrantToANonMember_IsRefused()
{
var owner = await EnrolledClientAsync("outsider-owner", "oowner@example.com");
await EnrolledClientAsync("outsider", "outsider@example.com");
var team = await CreateTeamAsync(owner, "Closed");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "outsider@example.com");
var response = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants",
new IssueVaultGrantRequest(
entry.UserId,
entry.Fingerprint,
KeyGeneration: 1,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadProblemAsync();
problem.Code.ShouldBe(ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
/// A fingerprint that is not the recipient's current key is refused. The server cannot tell whether
/// the wrap contains the right key — nothing on that machine can — but it can tell that this grant
/// was made for a key nobody holds, which would otherwise surface at the far end days later as a tag
/// failure indistinguishable from corruption.
/// </remarks>
[Fact]
public async Task AGrantForAKeyTheRecipientDoesNotHold_IsRefused()
{
var owner = await EnrolledClientAsync("stale-owner", "sowner@example.com");
await EnrolledClientAsync("stale-member", "smember@example.com");
var team = await CreateTeamAsync(owner, "Stale");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "smember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var response = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants",
new IssueVaultGrantRequest(
entry.UserId,
RecipientKeyFingerprint: new byte[32],
KeyGeneration: 1,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
/// <remarks>
/// The directory has no search. Asserting it rather than trusting the implementation, because a
/// prefix match added later for convenience turns a server that stores addresses in plaintext into a
/// way to enumerate an organisation's staff.
/// </remarks>
[Fact]
public async Task TheDirectory_MatchesAnExactAddressAndNothingElse()
{
var client = await EnrolledClientAsync("directory-self", "findme@example.com");
var address = addresses["findme@example.com"];
var exact = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
exact.Count.ShouldBe(1);
// Case-insensitive, because the column is citext and two addresses differing only in case are
// one account. That is a match, not a search.
var cased = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address.ToUpperInvariant())}");
cased.Count.ShouldBe(1);
// The address with its last character removed. A directory that answered this would be a way to
// walk an organisation's staff list out of a server that stores addresses in plaintext.
var prefix = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address[..^1])}");
prefix.ShouldBeEmpty();
}
/// <remarks>
/// The key log has to verify from genesis with the hashes the server publishes, because that is the
/// whole of what a client can check. A chain that only the server could reproduce would make key
/// transparency a claim rather than a mechanism.
/// </remarks>
[Fact]
public async Task TheKeyLog_ChainsFromGenesisWithTheHashesItPublishes()
{
var client = await EnrolledClientAsync("keylog-reader", "keylog@example.com");
var page = await ReadAsync<KeyLogPage>(client, "/api/v1/keylog?after=0");
page.Entries.ShouldNotBeEmpty();
var previous = Crypto.KeyLogChain.CreateGenesisPreviousHash();
foreach (var entry in page.Entries)
{
entry.PreviousHash.ShouldBe(previous);
Crypto.KeyLogChain.ComputeEntryHash(
entry.PreviousHash,
entry.UserId,
entry.Generation,
entry.EncryptionPublicKey,
entry.SigningPublicKey,
entry.StatementSignature,
entry.CreatedAt).ShouldBe(entry.Hash);
previous = entry.Hash;
}
// And the head the page reports is the last link, or a client that paged to the end could not
// tell whether it had seen the whole log.
if (!page.HasMore)
{
page.Head.ShouldBe(previous);
}
}
private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
{
var unique = $"{subject}-{Guid.CreateVersion7():N}";
var address = email is null ? null : $"{Guid.CreateVersion7():N}-{email}";
using var enrollment = new TestEnrollment(fixture.IdentityProvider, unique, address);
var client = fixture.CreateClientFor(unique, address);
var response = await client.PostContractAsync(EnrollUrl, enrollment.Build());
response.EnsureSuccessStatusCode();
// The address is remembered on the client so a later directory lookup can name it: the tests
// uniquify addresses so that runs against a shared container cannot collide.
if (address is not null)
{
addresses[email!] = address;
}
return client;
}
/// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);
/// <remarks>
/// The slug is generated rather than derived from the name, because a slug is lowercase letters,
/// digits and hyphens and a display name is not — deriving one would make these tests depend on a
/// transformation the product does not perform. It is uniquified because the container is shared
/// across every class in this assembly and the slug is unique deployment-wide.
/// </remarks>
private Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
PostAsync<CreateTeamRequest, TeamSummary>(
client,
TeamsUrl,
new CreateTeamRequest(
Guid.CreateVersion7(), name, $"team-{Guid.CreateVersion7():N}", null));
/// <remarks>
/// The wrapped key and the signature are the right shape and nothing more. The server stores both
/// opaquely and verifies neither — see docs/crypto.md §6 — so a real seal here would be testing the
/// crypto library rather than the endpoint.
/// </remarks>
private async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
{
var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
client,
$"{TeamsUrl}/{teamId}/vaults",
new CreateTeamVaultRequest(
Guid.CreateVersion7(),
"Team vault",
WrappedVaultKey: new byte[110],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch));
return vault.VaultId;
}
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
{
var address = addresses.GetValueOrDefault(email, email);
var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");
return found.ShouldHaveSingleItem();
}
private static async Task AddMemberAsync(
HttpClient client,
Guid teamId,
Guid userId,
TeamMemberRole role)
{
var response = await client.PostContractAsync(
$"{TeamsUrl}/{teamId}/members", new AddTeamMemberRequest(userId, role));
response.EnsureSuccessStatusCode();
}
private static async Task<TResponse> PostAsync<TRequest, TResponse>(
HttpClient client,
string url,
TRequest body)
{
var response = await client.PostContractAsync(url, body);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<TResponse>())!;
}
private static async Task<T> ReadAsync<T>(HttpClient client, string url)
{
var response = await client.GetAsync(
new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<T>())!;
}
}
@@ -0,0 +1,76 @@
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Domain.Authorization;
namespace DodoSSH.Api.Tests;
/// <summary>
/// The team enums on the wire and the ones in the domain have to agree, and nothing but this makes them.
/// </summary>
/// <remarks>
/// <para>
/// The same hazard <c>EntityTypeAlignmentTests</c> exists for, one feature along and with a worse failure.
/// <c>TeamService</c> maps <see cref="TeamMemberRole"/> to <see cref="TeamRole"/> member by member, so a
/// renumbering on one side does not fail to compile — it silently changes what a role means. Someone
/// added as a viewer would come back as an admin, or the reverse, on the next deployment.
/// </para>
/// <para>
/// Mapped by <em>name</em> in the service and asserted by <em>value</em> here, which is the pairing that
/// catches the mistake: the service would go on compiling after a renumbering, and this would not go on
/// passing.
/// </para>
/// </remarks>
public sealed class TeamEnumAlignmentTests
{
[Fact]
public void EveryWireRole_HasADomainRoleWithTheSameValue()
{
((int)TeamMemberRole.Unspecified).ShouldBe((int)TeamRole.Unspecified);
((int)TeamMemberRole.Viewer).ShouldBe((int)TeamRole.Viewer);
((int)TeamMemberRole.Member).ShouldBe((int)TeamRole.Member);
((int)TeamMemberRole.Admin).ShouldBe((int)TeamRole.Admin);
((int)TeamMemberRole.Owner).ShouldBe((int)TeamRole.Owner);
}
[Fact]
public void TheTwoRoleEnums_HaveTheSameNumberOfMembers() =>
Enum.GetValues<TeamMemberRole>().Length.ShouldBe(Enum.GetValues<TeamRole>().Length);
[Fact]
public void EveryWireMembershipStatus_HasADomainStatusWithTheSameValue()
{
((int)TeamMemberStatus.Unspecified).ShouldBe((int)MembershipStatus.Unspecified);
((int)TeamMemberStatus.Invited).ShouldBe((int)MembershipStatus.Invited);
((int)TeamMemberStatus.Active).ShouldBe((int)MembershipStatus.Active);
((int)TeamMemberStatus.Revoked).ShouldBe((int)MembershipStatus.Revoked);
}
[Fact]
public void EveryWireGrantState_HasADomainStateWithTheSameValue()
{
((int)VaultGrantState.Unspecified).ShouldBe((int)GrantState.Unspecified);
((int)VaultGrantState.Active).ShouldBe((int)GrantState.Active);
((int)VaultGrantState.AwaitingRewrap).ShouldBe((int)GrantState.AwaitingRewrap);
((int)VaultGrantState.Revoked).ShouldBe((int)GrantState.Revoked);
}
/// <remarks>
/// <para>
/// <c>VaultSummary.Permissions</c> is an opaque int on the wire, on purpose — the flags live in
/// <c>DodoSSH.Domain</c> and no client project references that assembly. So the client repeats the one
/// bit it needs as a literal in <c>StoredVault.CanWrite</c>, and this pins the value it copied.
/// </para>
/// <para>
/// Asserted here rather than against <c>StoredVault</c> itself, which would mean a server test project
/// taking a reference on a client assembly to check a constant. The consequence of drift is worth the
/// literal either way: a Save button offered to somebody who is only a viewer of a team vault, ending
/// in a 403 they can do nothing about — or no Save button for somebody who may write.
/// </para>
/// </remarks>
[Fact]
public void TheWriteFlag_IsTheBitTheClientCopied()
{
((int)PermissionFlags.Read).ShouldBe(1 << 0);
((int)PermissionFlags.Write).ShouldBe(1 << 1);
}
}
@@ -0,0 +1,366 @@
using DodoSSH.Client.Api;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The team, directory and grant half of the fake server.
/// </summary>
/// <remarks>
/// <para>
/// <b>The key log is real.</b> Entries are chained with <see cref="KeyLogChain.ComputeEntryHash"/> exactly
/// as the server chains them, because the client refuses to wrap a vault key to a directory answer that
/// does not appear in a log whose chain verifies — so a fake that returned a plausible-looking log would
/// make every sharing test pass against a check that was never exercised. It also means a test can break
/// the chain deliberately and watch the client refuse.
/// </para>
/// <para>
/// Everything else is deliberately thin. Roles, slugs and idempotency are the server's rules and are
/// tested against the real one in <c>DodoSSH.Api.Tests</c>; what the shell needs from here is that a team
/// can be created, a member added, and a vault key wrapped and recorded.
/// </para>
/// </remarks>
internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultGrantApi
{
private readonly List<TeamSummary> teams = [];
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = [];
/// <inheritdoc />
public ITeamApi Teams => this;
/// <inheritdoc />
public IDirectoryApi Directory => this;
/// <inheritdoc />
public IVaultGrantApi Grants => this;
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
/// <summary>
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
/// </summary>
/// <remarks>
/// The switch a test flips to prove the client refuses rather than shares. A fake with no way to be
/// wrong can only ever confirm the happy path.
/// </remarks>
internal bool CorruptKeyLog { get; set; }
/// <summary>Registers another account, as though they had signed in and enrolled here.</summary>
/// <returns>Their user id.</returns>
internal Guid AddAccount(string email, string displayName)
{
var userId = Guid.CreateVersion7();
// Real keys rather than filler: the client recomputes the fingerprint over both halves and refuses
// an entry whose fingerprint does not match, so random bytes would fail for the wrong reason.
using var bundle = UserSecretBundle.Create(DateTimeOffset.UnixEpoch);
var sequence = AppendKeyLog(
userId, bundle.EncryptionPublicKey, bundle.SigningPublicKey, new byte[64]);
directory.Add(new DirectoryEntry(
userId,
email,
displayName,
bundle.EncryptionPublicKey,
bundle.SigningPublicKey,
DshCrypto.ComputeFingerprint(bundle.EncryptionPublicKey, bundle.SigningPublicKey),
KeyGeneration: 1,
KeyLogSequence: sequence));
return userId;
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamSummary>>([.. teams]);
/// <inheritdoc />
public Task<TeamSummary> CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken)
{
var team = new TeamSummary(
request.TeamId,
request.Name,
request.Slug,
request.Description,
TeamMemberRole.Owner,
MemberCount: 1,
VaultCount: 0,
DateTimeOffset.UnixEpoch);
teams.Add(team);
members[team.TeamId] =
[
new TeamMemberSummary(
UserId,
"alice@example.com",
"Alice Example",
TeamMemberRole.Owner,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch),
];
return Task.FromResult(team);
}
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
members.TryGetValue(teamId, out var list) ? [.. list] : []);
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken)
{
var entry = directory.Find(candidate => candidate.UserId == request.UserId)
?? throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
"No such account on this server.");
var member = new TeamMemberSummary(
entry.UserId,
entry.Email,
entry.DisplayName,
request.Role,
TeamMemberStatus.Active,
IsEnrolled: true,
DateTimeOffset.UnixEpoch);
members[teamId] = [.. members.GetValueOrDefault(teamId, []), member];
Recount(teamId);
return Task.FromResult(member);
}
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken)
{
var list = members.GetValueOrDefault(teamId, []);
var index = list.FindIndex(member => member.UserId == userId);
if (index < 0)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidTeam,
"That account is not an active member of this team.");
}
list[index] = list[index] with { Role = request.Role };
return Task.FromResult(list[index]);
}
/// <inheritdoc />
public Task<bool> RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken)
{
var list = members.GetValueOrDefault(teamId, []);
var removed = list.RemoveAll(member => member.UserId == userId) > 0;
// Every grant they held from this team goes with them, as the real service revokes them in the
// same transaction. A fake that removed the membership and left the grants would let a test
// "prove" a revocation that had not happened.
foreach (var vaultId in teamVaults.Values
.Where(vault => vault.TeamId == teamId)
.Select(vault => vault.VaultId))
{
grants.Remove((vaultId, userId));
}
Recount(teamId);
return Task.FromResult(removed);
}
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken)
{
var vault = new VaultSummary(
request.VaultId,
request.Name,
IsPersonal: false,
TeamId: teamId,
KeyGeneration: 1,
Permissions: 31,
request.WrappedVaultKey,
RekeyRequired: false);
teamVaults[vault.VaultId] = vault;
Recount(teamId);
return Task.FromResult(vault);
}
/// <inheritdoc />
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
string email,
CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<DirectoryEntry>>(
[
.. directory.Where(entry =>
string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase)),
]);
/// <inheritdoc />
public Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken) =>
Task.FromResult(directory.Find(entry => entry.UserId == userId));
/// <inheritdoc />
public Task<KeyLogPage> ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken)
{
var page = keyLog.Where(entry => entry.Sequence > afterSequence).ToList();
if (CorruptKeyLog && page.Count > 0)
{
// One byte, in the field the chain is built from. Enough to break the link and nothing else,
// which is what a tampered log would look like.
var last = page[^1];
page[^1] = last with { EncryptionPublicKey = [.. last.EncryptionPublicKey.Reverse()] };
}
var head = keyLog.Count == 0
? KeyLogChain.CreateGenesisPreviousHash()
: keyLog[^1].Hash;
return Task.FromResult(new KeyLogPage(page, keyLog.Count, head, HasMore: false));
}
/// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
Task.FromResult(new VaultGrantsResponse(
vaultId,
KeyGeneration: 1,
RekeyRequired: false,
Grants:
[
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
new VaultGrantSummary(
entry.Key.UserId,
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
null,
KeyGeneration: 1,
VaultGrantState.Active,
UserId,
DateTimeOffset.UnixEpoch,
null)),
]));
/// <inheritdoc />
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
grants[(vaultId, request.RecipientUserId)] = request;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) =>
Task.FromResult(grants.Remove((vaultId, userId)));
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
{
if (directory.Exists(entry => entry.UserId == UserId))
{
return;
}
var sequence = AppendKeyLog(
UserId, statement.EncryptionPublicKey, statement.SigningPublicKey, statementSignature);
directory.Add(new DirectoryEntry(
UserId,
"alice@example.com",
"Alice Example",
statement.EncryptionPublicKey,
statement.SigningPublicKey,
DshCrypto.ComputeFingerprint(statement.EncryptionPublicKey, statement.SigningPublicKey),
statement.KeyGeneration,
sequence));
}
/// <summary>Appends a key log entry, chained as the real log chains it.</summary>
private long AppendKeyLog(
Guid userId,
byte[] encryptionPublicKey,
byte[] signingPublicKey,
byte[] statementSignature)
{
var previous = keyLog.Count == 0
? KeyLogChain.CreateGenesisPreviousHash()
: keyLog[^1].Hash;
var createdAt = KeyLogChain.TruncateTimestamp(DateTimeOffset.UnixEpoch);
var sequence = keyLog.Count + 1;
var hash = KeyLogChain.ComputeEntryHash(
previous, userId, 1, encryptionPublicKey, signingPublicKey, statementSignature, createdAt);
keyLog.Add(new KeyLogRecord(
sequence,
userId,
Generation: 1,
encryptionPublicKey,
signingPublicKey,
statementSignature,
previous,
hash,
createdAt));
return sequence;
}
private void Recount(Guid teamId)
{
var index = teams.FindIndex(team => team.TeamId == teamId);
if (index < 0)
{
return;
}
teams[index] = teams[index] with
{
MemberCount = members.GetValueOrDefault(teamId, []).Count,
VaultCount = teamVaults.Values.Count(vault => vault.TeamId == teamId),
};
}
}
@@ -17,7 +17,7 @@ namespace DodoSSH.Client.App.Tests;
/// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces /// conflict behaviour is covered in <c>DodoSSH.Client.Sync.Tests</c> against a server that enforces
/// version checks. /// version checks.
/// </remarks> /// </remarks>
internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKeyBindingAuthorizer
{ {
private readonly List<SyncChange> log = []; private readonly List<SyncChange> log = [];
@@ -131,7 +131,11 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
KeyGeneration: statement?.KeyGeneration, KeyGeneration: statement?.KeyGeneration,
WrappedPrivateKey: wrappedPrivateKey, WrappedPrivateKey: wrappedPrivateKey,
KdfParameters: kdfParameters, KdfParameters: kdfParameters,
Vaults: personalVault is null ? [] : [personalVault]));
// Team vaults alongside the personal one, in the order the real /me returns them: this is
// where a vault somebody shared arrives, and a fake that only ever reported the personal one
// would make a refresh that admits a new vault untestable.
Vaults: personalVault is null ? [] : [personalVault, .. teamVaults.Values]));
/// <inheritdoc /> /// <inheritdoc />
public Task<EnrollmentResponse> EnrollAsync( public Task<EnrollmentResponse> EnrollAsync(
@@ -144,6 +148,10 @@ internal sealed class FakeVaultServer : IVaultServer, IAccountApi, ISyncApi, IKe
wrappedPrivateKey = request.WrappedPrivateKey; wrappedPrivateKey = request.WrappedPrivateKey;
kdfParameters = request.KdfParameters; kdfParameters = request.KdfParameters;
// The enrolling account joins the directory and the key log, as it does on the real server. Both
// are what a later share reads: this client verifies its own entry as part of verifying anyone's.
RegisterSelf(request.Statement, request.StatementSignature);
personalVault = new VaultSummary( personalVault = new VaultSummary(
request.PersonalVault.VaultId, request.PersonalVault.VaultId,
request.PersonalVault.Name, request.PersonalVault.Name,
@@ -0,0 +1,277 @@
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Teams, from the side that holds the keys: create one, add somebody, and wrap a vault key to them.
/// </summary>
/// <remarks>
/// <para>
/// The reason this suite exists rather than leaving teams to the server's own tests is that the
/// interesting half is not on the server. Adding a member is a row; <b>sharing is a decision the client
/// makes about whether to trust a public key the server just handed it</b>, and that decision is what
/// stands between an end-to-end encrypted vault and one the operator can read by answering a directory
/// lookup with a key of their own.
/// </para>
/// <para>
/// So the fake server keeps a real key log — chained with the same <c>KeyLogChain</c> the server uses —
/// and can be told to corrupt it. A test that only ever saw a well-formed log would be checking that
/// sharing works, not that verification does.
/// </para>
/// </remarks>
public sealed class TeamSharingTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeVaultServer server = new();
private readonly FakeSshConnectionFactory ssh = new();
private string directory = null!;
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultKnownHostStore knownHosts = null!;
private FakeDeviceKeyStore deviceKeys = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
directory = Path.Combine(Path.GetTempPath(), $"dodossh-teams-{Guid.CreateVersion7():N}");
var paths = new ClientPaths(directory);
caches = ClientCacheFactory.ForFile(paths.CacheFile);
knownHosts = new VaultKnownHostStore();
deviceKeys = new FakeDeviceKeyStore();
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
ssh,
TimeProvider.System);
shell = new MainWindowViewModel(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
(_, _) => Task.FromResult<IVaultServer>(server),
TimeProvider.System,
NSubstitute.Substitute.For<ISftpSessionFactory>(),
CheapProfile);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
caches.Dispose();
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// A cache file the process has not finished releasing. The directory is under the temp path
// and named per run, so leaving it costs a few kilobytes and never collides.
}
}
/// <remarks>
/// The whole point of a team, in one test. Note what the status line says after the add and before
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
/// than let a user believe the credential is already with their colleague.
/// </remarks>
[Fact]
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.Vaults.Count.ShouldBe(1, teams.Status);
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.Count.ShouldBe(2, teams.Status);
teams.Status.ShouldContain("cannot read anything yet");
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
await teams.ShareVaultCommand.ExecuteAsync(null);
var vaultId = teams.Vaults[0].VaultId;
server.IssuedGrants.ShouldContainKey((vaultId, colleague));
teams.Status.ShouldContain("Shared");
// The one thing verification cannot promise, said in the same breath as the success.
teams.Status.ShouldContain("fingerprint", Case.Insensitive);
}
/// <remarks>
/// <para>
/// The test this whole design exists for. A server that wants to read a team's vault only has to
/// answer one directory lookup with a key it holds the private half of — so the client reads the
/// append-only key log, verifies its chain, and refuses to wrap anything unless the key it was
/// offered is in there unchanged.
/// </para>
/// <para>
/// Nothing may be sent. A refusal that still issued the grant, or that issued it on a retry, would be
/// worse than no check at all, because the interface would have said it was verified.
/// </para>
/// </remarks>
[Fact]
public async Task ATamperedKeyLog_StopsTheShareRatherThanWarningAboutIt()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("mallory@example.com", "Mallory Example");
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
teams.InviteEmail = "mallory@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
server.CorruptKeyLog = true;
await teams.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldBeEmpty();
teams.Status.ShouldContain("Did not share");
teams.Status.ShouldContain("key log");
}
/// <remarks>
/// A vault created here is usable here, without a relock. The key was generated in this process, so
/// making the user lock and unlock to reach the vault they just made would be asking them to work
/// around bookkeeping.
/// </remarks>
[Fact]
public async Task ATeamVaultCreatedHere_IsImmediatelyReadableAndWritable()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
var vaultId = teams.Vaults[0].VaultId;
var session = shell.Vault!.Session;
session.ReadableVaults.Select(vault => vault.VaultId).ShouldContain(vaultId);
// And it is offered as somewhere to file a new item, which is what makes it worth having.
await shell.Vault.LoadAsync(Token);
shell.Vault.TargetVaults.Select(choice => choice.VaultId).ShouldContain(vaultId);
shell.Vault.HasVaultChoice.ShouldBeTrue();
}
/// <remarks>
/// Filing into a team vault has to be chosen and has to stick. The bug this guards is the obvious
/// one: an editor that read the picker at save time rather than at open time, so changing the picker
/// with a half-typed host on screen would move it.
/// </remarks>
[Fact]
public async Task AHostFiledIntoATeamVault_StaysThere()
{
await UnlockedAsync();
var teams = shell.Teams;
await CreateTeamAsync(teams, "Platform", "platform");
await teams.CreateVaultCommand.ExecuteAsync(null);
var vault = shell.Vault!;
var teamVaultId = teams.Vaults[0].VaultId;
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
vault.TargetVaults.Single(choice => choice.VaultId == teamVaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
// Moved back after the editor opened. The host must still land in the team's vault.
vault.SelectedTargetVault =
vault.TargetVaults.First(choice => choice.VaultId != teamVaultId);
await vault.SaveHostCommand.ExecuteAsync(null);
var row = vault.Hosts.Single(
host => string.Equals(host.Label, "prod-db", StringComparison.Ordinal));
row.VaultId.ShouldBe(teamVaultId);
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);
teams.NewTeamCommand.Execute(null);
teams.NewTeamName = name;
teams.NewTeamSlug = slug;
await teams.CreateTeamCommand.ExecuteAsync(null);
teams.SelectedTeam.ShouldNotBeNull(teams.Status);
}
/// <remarks>
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and
/// that entry is what the client verifies its own directory answer against.
/// </remarks>
private async Task UnlockedAsync()
{
await shell.StartAsync(Token);
await shell.SignInCommand.ExecuteAsync(null);
shell.Passphrase = Passphrase;
shell.ConfirmPassphrase = Passphrase;
await shell.EnrollCommand.ExecuteAsync(null);
shell.RecoveryCodeWrittenDown = true;
shell.ConfirmRecoveryCodeCommand.Execute(null);
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
}
@@ -239,7 +239,7 @@ public sealed class SessionLifecycleTests : IAsyncLifetime
await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token); await session.Hosts.CreateAsync(session.ActiveVaultId, Host("prod-db"), Token);
var transport = new EmptySyncApi(); var transport = new EmptySyncApi();
var report = await session.SyncAsync(transport, Token); var report = await session.SyncAsync(transport, session.ActiveVaultId, Token);
// Two operations for one host: the host, and the activity log entry recording that somebody created // Two operations for one host: the host, and the activity log entry recording that somebody created
// it. PushedItems is the number that means "the user's own work", and it is one — see // it. PushedItems is the number that means "the user's own work", and it is one — see
@@ -83,6 +83,69 @@ public sealed class RemotePathTests
.ShouldBe("lrwxrwxrwx"); .ShouldBe("lrwxrwxrwx");
} }
[Theory]
[InlineData("-rwxr-xr-x", true)]
[InlineData("-rw-r-xr--", true)]
[InlineData("-rw-r--r-x", true)]
[InlineData("-rw-r--r--", false)]
[InlineData("----------", false)]
public void PosixMode_ReadsAnExecuteBitInAnyTriple(string mode, bool expected)
{
// Any of the three, not the owner's: the account browsing is not necessarily the owner, and a file
// that only the group may run is still a file somebody runs.
PosixMode.HasAnyExecuteBit(mode).ShouldBe(expected);
}
[Theory]
[InlineData("-rw-rw-rw-", true)]
[InlineData("-rw-rw-r--", false)]
[InlineData("-rw-r--r--", false)]
[InlineData("--------w-", true)]
public void PosixMode_ReadsTheOthersWriteBit(string mode, bool expected)
{
// The others triple only. A group-writable file is writable by a named set of people, which is what
// groups are for; this asks about the column that names nobody.
PosixMode.IsWorldWritable(mode).ShouldBe(expected);
}
[Theory]
[InlineData("")]
[InlineData("rwxrwxrwx")]
[InlineData("?")]
public void PosixMode_AnswersFalseForAModeItDidNotWrite(string mode)
{
// These two decide a row's colour and nothing else. A listing is not worth failing over a string of
// the wrong length, and an index off the end of one is how that would happen.
PosixMode.HasAnyExecuteBit(mode).ShouldBeFalse();
PosixMode.IsWorldWritable(mode).ShouldBeFalse();
}
[Theory]
[InlineData(SftpEntryKind.File, "-rwxr-xr-x", true)]
[InlineData(SftpEntryKind.File, "-rw-r--r--", false)]
[InlineData(SftpEntryKind.Directory, "drwxr-xr-x", false)]
[InlineData(SftpEntryKind.SymbolicLink, "lrwxrwxrwx", false)]
public void AnEntry_IsExecutableOnlyWhenItIsAFile(SftpEntryKind kind, string mode, bool expected)
{
// The x on a directory means "may be searched", which is true of very nearly every directory a host
// has. A file browser that marked them all would be marking nothing.
Entry(kind, mode).IsExecutable.ShouldBe(expected);
}
[Theory]
[InlineData(SftpEntryKind.File, "-rw-rw-rw-", true)]
[InlineData(SftpEntryKind.File, "-rw-r--r--", false)]
[InlineData(SftpEntryKind.SymbolicLink, "lrwxrwxrwx", false)]
[InlineData(SftpEntryKind.Directory, "drwxrwxrwx", false)]
public void AnEntry_IsWorldWritableOnlyWhenItIsAFile(SftpEntryKind kind, string mode, bool expected)
{
// The two exclusions are the point. Every symbolic link is lrwxrwxrwx and its mode governs nothing —
// what may be written is the target, whose mode this listing never fetched. And a world-writable
// directory is /tmp, made safe by a sticky bit PosixMode does not render: warning there would be
// warning about the half of the mode that is on screen.
Entry(kind, mode).IsWorldWritable.ShouldBe(expected);
}
[Theory] [Theory]
[InlineData(0, "0 B")] [InlineData(0, "0 B")]
[InlineData(1023, "1023 B")] [InlineData(1023, "1023 B")]
@@ -96,4 +159,8 @@ public sealed class RemotePathTests
// "512.3 MB" claims a precision the figure does not have by the time it is that large. // "512.3 MB" claims a precision the figure does not have by the time it is that large.
ByteSize.Format(bytes).ShouldBe(expected); ByteSize.Format(bytes).ShouldBe(expected);
} }
/// <summary>An entry that is nothing but a kind and a mode, which is all these two questions read.</summary>
private static SftpEntry Entry(SftpEntryKind kind, string mode) =>
new("thing", "/tmp/thing", kind, 0, DateTimeOffset.UnixEpoch, mode);
} }
@@ -85,7 +85,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
var host = BuildHost(keyId); var host = BuildHost(keyId);
var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token); var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
var pushed = await laptop.SyncAsync(connection.Sync, Token); var pushed = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed); AssertTheKeyAndTheHostWentUpWithTheirLogEntries(pushed);
await AssertTheServerCannotSeeTheAddressAsync(connection, entityId); await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
@@ -96,7 +96,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
// item type exists to make and the only place it is proved through a real server. // item type exists to make and the only place it is proved through a real server.
var pin = await OpenAShellAsync(laptop, host); var pin = await OpenAShellAsync(laptop, host);
var trusted = await laptop.SyncAsync(connection.Sync, Token); var trusted = await laptop.SyncAsync(connection.Sync, laptop.ActiveVaultId, Token);
trusted.PushedItems.ShouldBe(1, "the host key the user approved at the prompt"); trusted.PushedItems.ShouldBe(1, "the host key the user approved at the prompt");
// And its activity entry. Worth asserting rather than ignoring: a pin is written programmatically at // And its activity entry. Worth asserting rather than ignoring: a pin is written programmatically at
@@ -256,7 +256,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
var desktop = await UnlockAsync(desktopCache); var desktop = await UnlockAsync(desktopCache);
await using var session = desktop; await using var session = desktop;
var pulled = await desktop.SyncAsync(connection.Sync, Token); var pulled = await desktop.SyncAsync(connection.Sync, desktop.ActiveVaultId, Token);
pulled.PulledItems.ShouldBe(3, "the host, the key and the approved host key, in one pass"); pulled.PulledItems.ShouldBe(3, "the host, the key and the approved host key, in one pass");
// And the three activity entries the first machine wrote about them, which is the claim the log // And the three activity entries the first machine wrote about them, which is the claim the log