diff --git a/README.md b/README.md index 7690a8a..52945b9 100644 --- a/README.md +++ b/README.md @@ -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 [`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 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 the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a ranged GET is part of the protocol, which is the one place a bucket beats SFTP. -- **M3 — teams**, sharing, ACLs. +- **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. - **M5 — multi-provider OIDC**, key rotation, per-item content keys. diff --git a/docs/adr/0009-team-access-model.md b/docs/adr/0009-team-access-model.md new file mode 100644 index 0000000..33acb88 --- /dev/null +++ b/docs/adr/0009-team-access-model.md @@ -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. diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index bdbd5eb..18739dd 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -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 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 -migration, with entities in `DodoSSH.Domain/Teams.cs` and a `TeamRole` enum — and no endpoint reads or -writes any of it. `VaultAccessService.ResolveAsync` returns `Denied` for every vault that is not the -caller's own personal one. That removes the Teams screen entirely, and with it every role chip, scope and -"shared with" affordance the vault screen was drawn with. +**~~Teams are schema and nothing else.~~ Built in M3.** The `team` and `team_membership` tables were there +from the first migration with nothing reading them, and `VaultAccessService.ResolveAsync` denied every vault +that was not the caller's own. Both changed in M3 and neither needed a migration, which is what carrying two +unused tables bought. See [Teams](#teams). What has *not* changed is the split underneath: the server +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 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 | | --- | --- | --- | --- | -| 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. | | `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". | @@ -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. | | 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. | | `· ⤷ 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. | @@ -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 | | --- | --- | --- | --- | | `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. | -| 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` column | server | As above — and note scope here is a property of a *vault*, never of an item. | Omitted. | +| 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 | **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` 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. | | `•••• 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. | | `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. | -| `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. | | 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. | @@ -213,23 +215,40 @@ and both editors. What follows is what the design drew around them. ## 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. | -| Shared vaults | server | Vault creation, grants, and membership evaluation. Exactly one vault exists per user, created as a side effect of enrollment. | -| 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. | -| Members table | server | A member DTO and a directory endpoint. | -| `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`. | -| `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. | +| 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 + 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 | 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 | `TeamMemberSummary`, and a directory that resolves an exact email to a public key. | +| 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. | -> **A trap for whoever builds this.** `GET /api/v1/meta` advertises `features: ["teams"]` -> *unconditionally* (`MetaEndpoints.cs`). Do not gate a Teams screen on that string — it is true of every -> deployment today and means nothing. +| Design element | Layer | What it would take | What ships instead | +| --- | --- | --- | --- | +| `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. --- diff --git a/src/DodoSSH.Api/Authorization/VaultAccessService.cs b/src/DodoSSH.Api/Authorization/VaultAccessService.cs index b461d91..ccc19cb 100644 --- a/src/DodoSSH.Api/Authorization/VaultAccessService.cs +++ b/src/DodoSSH.Api/Authorization/VaultAccessService.cs @@ -46,13 +46,23 @@ public interface IVaultAccessService /// /// /// -/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the -/// v_user_vault_permission view and per-resource ACLs arrive in M3 — this is the one place -/// that changes, which is why every caller goes through it rather than comparing owner ids inline. +/// Two rules, and only two. A personal vault answers to its owner. A team vault answers to the +/// team's active members, with the role deciding how much. Everything else is denied, which is what +/// keeps an unimplemented ownership kind from falling through to a permissive default. /// /// -/// A team vault is explicitly denied for now rather than falling through to a permissive default. -/// Failing closed on an unimplemented path is the only safe direction. +/// Permission is not the same thing as readability. This service decides what the +/// server 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 VaultSummary.WrappedVaultKey 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. +/// +/// +/// Deliberately not a database view. v_user_vault_permission 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. /// /// internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService @@ -80,14 +90,23 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS 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 - // correct behaviour in the meantime. - return VaultAccess.Denied; + if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId) + { + return VaultAccess.Denied; + } + + var role = await FindRoleAsync(userId, teamId, cancellationToken).ConfigureAwait(false); + + return role is { } granted + ? new VaultAccess(vault, ForRole(granted)) + : VaultAccess.Denied; } /// @@ -95,16 +114,98 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS Guid userId, CancellationToken cancellationToken) { - // Personal ownership only, matching ResolveAsync. When M3 adds the - // v_user_vault_permission view, both methods change together and neither can drift. + // The memberships first, then one pass over the vaults. The alternative — a join per vault — + // 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 - .Where(v => v.OwnerKind == VaultOwnerKind.Personal - && v.OwnerUserId == userId - && v.DeletedAtUtc == null) + .Where(v => v.DeletedAtUtc == null + && ((v.OwnerKind == VaultOwnerKind.Personal && v.OwnerUserId == userId) + || (v.OwnerKind == VaultOwnerKind.Team + && v.TeamId != null + && teamIds.Contains(v.TeamId.Value)))) .OrderBy(v => v.CreatedAtUtc) .ToListAsync(cancellationToken) .ConfigureAwait(false); - return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))]; + var accessible = new List(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; + } + + /// + /// Maps a team role onto vault permissions. + /// + /// + /// + /// Union-only, with no Deny: evaluation stays monotonic and testable, and restriction is + /// expressed by granting narrowly. See . + /// + /// + /// 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. + /// + /// + /// Owner and Admin resolve identically here on purpose. What separates them is what they may do + /// to the team — appoint owners, delete it — which is not a vault permission and is + /// checked where those operations live. + /// + /// + 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, + }; + + /// + /// Only an 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. + /// + private async Task 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; } } diff --git a/src/DodoSSH.Api/Features/Identity/DirectoryService.cs b/src/DodoSSH.Api/Features/Identity/DirectoryService.cs new file mode 100644 index 0000000..be06c5c --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/DirectoryService.cs @@ -0,0 +1,162 @@ +using DodoSSH.Contracts; +using DodoSSH.Domain; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// The public-key directory. +/// +/// +/// +/// 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. +/// +/// +/// Lookup by exact email, never by prefix. 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. +/// +/// +/// Lookup by id is restricted to people the caller shares a team with. 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. +/// +/// +/// What this returns is evidence, 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 DirectoryEntry's own remarks. +/// +/// +internal sealed class DirectoryService(DodoDbContext database) +{ + /// Looks a user up by exact email address. + internal async Task> 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); + } + + /// Looks up accounts the caller shares an active team with. + internal async Task> FindTeammatesAsync( + Guid callerId, + IReadOnlyList 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); + } + + /// + /// 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. + /// + private async Task> BuildAsync( + List 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(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; + } +} diff --git a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs index fb8cab0..bb79fff 100644 --- a/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs +++ b/src/DodoSSH.Api/Features/Identity/IdentityEndpoints.cs @@ -104,6 +104,117 @@ internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, Enrollment } } +/// +/// Looks up the public keys a vault key can be wrapped to. +/// +/// +/// +/// A GET with query parameters rather than a request DTO, because BodyOnlyRequestBinder 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. +/// +/// +/// Exactly one of email and userId 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 . +/// +/// +internal sealed class LookupDirectoryEndpoint( + ICurrentUserContext currentUser, + DirectoryService directory) + : EndpointWithoutRequest>, ProblemHttpResult>> +{ + /// + 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")); + } + + /// + public override async Task>, 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."); + } +} + +/// +/// Serves the append-only key log, so a client can verify a public key rather than trust one. +/// +/// +/// Paged with after and limit on the query string, read one at a time as route values +/// are — see for why this endpoint has no request DTO. +/// +internal sealed class ReadKeyLogEndpoint(KeyLogService keyLog) + : EndpointWithoutRequest> +{ + /// + 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")); + } + + /// + public override async Task> 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)); + } +} + /// Registers a device key so this machine can unlock without the passphrase. /// /// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the diff --git a/src/DodoSSH.Api/Features/Identity/KeyLogService.cs b/src/DodoSSH.Api/Features/Identity/KeyLogService.cs new file mode 100644 index 0000000..89b3280 --- /dev/null +++ b/src/DodoSSH.Api/Features/Identity/KeyLogService.cs @@ -0,0 +1,87 @@ +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Api.Features.Identity; + +/// +/// Serves the append-only key log. +/// +/// +/// +/// 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. +/// +/// +/// 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 +/// and docs/crypto.md §7.2 for why serialising them is load-bearing. +/// +/// +internal sealed class KeyLogService(DodoDbContext database) +{ + /// Largest page served, whatever a caller asks for. + /// + /// 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. + /// + private const int MaxPageSize = 500; + + /// Reads entries after a sequence, with the log's current head. + internal async Task 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); + } +} diff --git a/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs b/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs new file mode 100644 index 0000000..e605e1a --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamEndpoints.cs @@ -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; + +/// Creates a team, with the caller as its owner. +/// +/// 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. +/// +internal sealed class CreateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint, ProblemHttpResult>> +{ + /// + 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")); + } + + /// + public override async Task, 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); + } + } +} + +/// Lists the teams the caller belongs to. +internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamService teams) + : EndpointWithoutRequest>> +{ + /// + 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")); + } + + /// + public override async Task>> ExecuteAsync(CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + + return TypedResults.Ok(await teams.ListAsync(user, ct).ConfigureAwait(false)); + } +} + +/// Lists a team's members. +internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams) + : EndpointWithoutRequest>, NotFound>> +{ + /// + 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")); + } + + /// + public override async Task>, NotFound>> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("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)); + } +} + +/// Adds a member to a team. +internal sealed class AddTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + 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")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + AddTeamMemberRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("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); + } + } +} + +/// Changes a member's role. +internal sealed class ChangeTeamMemberRoleEndpoint(ICurrentUserContext currentUser, TeamService teams) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + 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")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + ChangeTeamMemberRoleRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + var memberId = Route("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); + } + } +} + +/// +/// Removes a member from a team. +/// +/// +/// 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. +/// +internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams) + : EndpointWithoutRequest> +{ + /// + 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")); + } + + /// + public override async Task> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("teamId"); + var memberId = Route("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); + } + } +} + +/// Creates a vault owned by a team. +internal sealed class CreateTeamVaultEndpoint( + ICurrentUserContext currentUser, + TeamService teams, + VaultGrantService grants) + : Endpoint, NotFound, ProblemHttpResult>> +{ + /// + 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")); + } + + /// + public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( + CreateTeamVaultRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var teamId = Route("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); + } + } +} diff --git a/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs b/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs new file mode 100644 index 0000000..52daa08 --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamExceptions.cs @@ -0,0 +1,35 @@ +namespace DodoSSH.Api.Features.Teams; + +/// +/// A team create or membership change was structurally unacceptable. +/// +/// +/// 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. +/// +internal sealed class TeamInvalidException(string message) : Exception(message); + +/// The requested slug is already in use. +/// +/// 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. +/// +internal sealed class TeamSlugTakenException(string message) : Exception(message); + +/// The change would leave a team with no owner. +/// +/// 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. +/// +internal sealed class LastTeamOwnerException(string message) : Exception(message); + +/// +/// A vault key grant was rejected. +/// +/// +/// 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. +/// +internal sealed class VaultGrantInvalidException(string message) : Exception(message); diff --git a/src/DodoSSH.Api/Features/Teams/TeamLog.cs b/src/DodoSSH.Api/Features/Teams/TeamLog.cs new file mode 100644 index 0000000..2d12f6f --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamLog.cs @@ -0,0 +1,68 @@ +namespace DodoSSH.Api.Features.Teams; + +/// +/// Source-generated log events for teams, membership and vault key grants. +/// +/// +/// 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. +/// +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); + + /// + /// 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. + /// + [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); +} diff --git a/src/DodoSSH.Api/Features/Teams/TeamService.cs b/src/DodoSSH.Api/Features/Teams/TeamService.cs new file mode 100644 index 0000000..6d487c1 --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/TeamService.cs @@ -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; + +/// The result of a team access check. +/// The team, when the caller is an active member. +/// The caller's role. +internal readonly record struct TeamAccess(Team? Team, TeamRole Role) +{ + /// Whether the caller is in this team at all. + public bool Granted => Team is not null; + + /// + /// Whether the caller may manage members and vaults. + /// + /// + /// The team-level counterpart of PermissionFlags.Admin, and deliberately not derived from + /// it: those flags describe a vault, and adding a member is not an operation on any vault. + /// + public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner; + + /// Denied access. + public static TeamAccess Denied => new(null, TeamRole.Unspecified); +} + +/// +/// Teams and their membership. +/// +/// +/// +/// Membership is authorization; a key grant is access. Everything in this class moves rows +/// that decide what the server 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. +/// +/// +/// 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 future 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. +/// +/// +internal sealed class TeamService( + DodoDbContext database, + TimeProvider clock, + ILogger logger) +{ + /// Longest acceptable slug. Matches the column. + private const int MaxSlugLength = 128; + + /// Longest acceptable display name. Matches the column. + private const int MaxNameLength = 256; + + /// Longest acceptable description. Matches the column. + private const int MaxDescriptionLength = 2048; + + /// Creates a team, with the caller as its owner. + /// + /// 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. + /// + internal async Task 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); + } + + /// + /// Adds the team row and the creator's owner membership. + /// + /// + /// 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. + /// + 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; + } + + /// Lists the teams the caller is an active member of. + internal async Task> 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)), + ]; + } + + /// + /// Lists a team's members. + /// + /// + /// 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. + /// + internal async Task> 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)), + ]; + } + + /// Adds a member, or reactivates one who was removed. + /// + /// + /// The role may not be . 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. + /// + /// + /// 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 not + /// 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. + /// + /// + internal async Task 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); + } + + /// + /// 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. + /// + private async Task 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); + } + + /// Changes a member's role. + internal async Task 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); + } + + /// + /// Removes a member, revoking every vault key grant they hold from this team. + /// + /// + /// + /// 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. + /// + /// + /// Every affected vault is flagged RekeyRequired 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. + /// + /// + 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); + } + + /// Revokes one user's grants on every vault a team owns, and flags each for rekey. + private async Task 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; + } + + /// Reads the caller's own membership, for authorization checks. + internal Task 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); + + /// + /// Resolves what the caller may do with a team. + /// + /// + /// Answers identically for a team that does not exist and one the + /// caller is not in, for the reason VaultAccessService gives: a distinct "exists but + /// forbidden" is an oracle for other tenants' team ids. + /// + internal async Task 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 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."); + } + + /// + /// 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. + /// + private async Task 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); + } + + /// + /// Validates a slug. + /// + /// + /// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on + /// purpose: the column is citext, 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. + /// + 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; + } + + /// + /// A plain cast, which is why TeamMemberRole pins the same numeric values as + /// and a test asserts it. An unknown value becomes + /// 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. + /// + 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); +} diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs new file mode 100644 index 0000000..97b2289 --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantEndpoints.cs @@ -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; + +/// Lists who can open a vault. +/// +/// 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. +/// +internal sealed class ListVaultGrantsEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : EndpointWithoutRequest, NotFound>> +{ + /// + 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")); + } + + /// + public override async Task, NotFound>> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var access = await vaultAccess + .ResolveAsync(user.Id, Route("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)); + } +} + +/// Wraps this vault's key to another member. +/// +/// 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. +/// +internal sealed class IssueVaultGrantEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : Endpoint> +{ + /// + 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")); + } + + /// + public override async Task> ExecuteAsync( + IssueVaultGrantRequest req, + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var access = await vaultAccess + .ResolveAsync(user.Id, Route("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); + } + } +} + +/// Withdraws a member's key to this vault. +/// +/// 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. +/// +internal sealed class RevokeVaultGrantEndpoint( + ICurrentUserContext currentUser, + IVaultAccessService vaultAccess, + VaultGrantService grants) + : EndpointWithoutRequest> +{ + /// + 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")); + } + + /// + public override async Task> ExecuteAsync( + CancellationToken ct) + { + var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); + var access = await vaultAccess + .ResolveAsync(user.Id, Route("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("userId"), ct) + .ConfigureAwait(false); + + return revoked ? TypedResults.NoContent() : TypedResults.NotFound(); + } + catch (VaultGrantInvalidException exception) + { + return Problems.Coded( + StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message); + } + } +} diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs new file mode 100644 index 0000000..311e31f --- /dev/null +++ b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs @@ -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; + +/// +/// Team vaults and the key grants that make them readable. +/// +/// +/// +/// 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. +/// +/// +/// What the server can 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. +/// +/// +internal sealed class VaultGrantService( + DodoDbContext database, + TimeProvider clock, + ILogger logger) +{ + /// + /// Largest wrapped vault key accepted. + /// + /// + /// A SealTo 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. + /// + private const int MaxWrappedKeyBytes = 4096; + + /// Creates a vault owned by a team, with the creator's own grant. + /// + /// 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. + /// + internal async Task 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); + } + + /// Adds the vault row and the creator's own grant, in one unit of work. + 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; + } + + /// Lists who can open a vault. + internal async Task 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)), + ]); + } + + /// Wraps a vault key to another member. + /// + /// 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. + /// + 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); + } + + /// + /// Everything that can be checked about a grant without holding the vault key. + /// + /// + /// 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. + /// + 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."); + } + } + + /// + /// Withdraws a member's key grant. + /// + /// Whether there was a live grant to withdraw. + /// + /// 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. + /// + internal async Task 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 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); +} diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index 901e85c..68bdf46 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -1,6 +1,7 @@ using DodoSSH.Api.Authorization; using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Sync; +using DodoSSH.Api.Features.Teams; using DodoSSH.Api.Setup; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; @@ -32,6 +33,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs index 2b8eccd..e058a62 100644 --- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs +++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs @@ -1,6 +1,7 @@ using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Meta; using DodoSSH.Api.Features.Sync; +using DodoSSH.Api.Features.Teams; using DodoSSH.Contracts; using FastEndpoints; @@ -38,15 +39,26 @@ internal static class EndpointRegistration typeof(EnrollEndpoint), typeof(RegisterDeviceEndpoint), typeof(RevokeDeviceEndpoint), + typeof(LookupDirectoryEndpoint), + typeof(ReadKeyLogEndpoint), typeof(SyncPullEndpoint), 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: // Identity — key rotation, passphrase change - // Directory — public-key lookup - // Vaults — grants, rekey, ACL + // Vaults — rekey, per-item ACLs // Relay — tickets and the WebSocket - // Teams, Audit, Admin + // Audit, Admin }); /// Hides the endpoint listing FastEndpoints publishes at GET /_test_url_cache_. diff --git a/src/DodoSSH.Client.Api/DodoSshApiClient.cs b/src/DodoSSH.Client.Api/DodoSshApiClient.cs index a5edd25..2788583 100644 --- a/src/DodoSSH.Client.Api/DodoSshApiClient.cs +++ b/src/DodoSSH.Client.Api/DodoSshApiClient.cs @@ -58,6 +58,115 @@ public interface IAccountApi Task RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken); } +/// +/// Teams, their members, and the vaults they own. +/// +/// +/// Separated from although the two are used together, because they are +/// different kinds of act. Everything here changes what the server 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. +/// +public interface ITeamApi +{ + /// Lists the teams the caller belongs to. + Task> ListTeamsAsync(CancellationToken cancellationToken); + + /// Creates a team, with the caller as its owner. + Task CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken); + + /// Lists a team's members. + Task> ListTeamMembersAsync( + Guid teamId, + CancellationToken cancellationToken); + + /// Adds a member to a team. + Task AddTeamMemberAsync( + Guid teamId, + AddTeamMemberRequest request, + CancellationToken cancellationToken); + + /// Changes a member's role. + Task ChangeTeamMemberRoleAsync( + Guid teamId, + Guid userId, + ChangeTeamMemberRoleRequest request, + CancellationToken cancellationToken); + + /// + /// Removes a member, revoking every vault key grant they hold from this team. + /// + /// + /// 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. + /// + Task RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken); + + /// Creates a vault owned by a team, with the creator's key grant. + Task CreateTeamVaultAsync( + Guid teamId, + CreateTeamVaultRequest request, + CancellationToken cancellationToken); +} + +/// +/// The public-key directory and the log that makes it checkable. +/// +/// +/// 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. +/// +public interface IDirectoryApi +{ + /// Looks a user up by exact email address. There is no search. + Task> LookupByEmailAsync( + string email, + CancellationToken cancellationToken); + + /// Looks up an account the caller shares a team with. + Task LookupByIdAsync(Guid userId, CancellationToken cancellationToken); + + /// Reads entries after a sequence, with the log's current head. + Task ReadKeyLogAsync( + long afterSequence, + int? limit, + CancellationToken cancellationToken); +} + +/// +/// Vault key grants: who can open a vault, and the record of who let them. +/// +/// +/// 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. +/// +public interface IVaultGrantApi +{ + /// Lists who holds a key to this vault. + Task ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken); + + /// Records a vault key wrapped to another member. + Task IssueVaultGrantAsync( + Guid vaultId, + IssueVaultGrantRequest request, + CancellationToken cancellationToken); + + /// + /// Withdraws a member's key to this vault. + /// + /// Whether there was a live grant to withdraw. + /// + /// 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. + /// + Task RevokeVaultGrantAsync( + Guid vaultId, + Guid userId, + CancellationToken cancellationToken); +} + /// /// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP. /// @@ -99,13 +208,16 @@ public interface ISyncApi /// /// 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 ConfigurationPath = "/.well-known/dodossh-configuration"; private const string MePath = "/api/v1/me"; private const string EnrollmentPath = "/api/v1/me/enrollment"; 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"; /// /// Reads the server's capabilities, versions and limits. @@ -209,6 +321,168 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token DodoSshJsonContext.Default.SyncPushResponse, cancellationToken); + /// + public Task> ListTeamsAsync(CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Get, + TeamsPath, + null, + DodoSshJsonContext.Default.IReadOnlyListTeamSummary, + cancellationToken); + + /// + public Task CreateTeamAsync( + CreateTeamRequest request, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Post, + TeamsPath, + JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest), + DodoSshJsonContext.Default.TeamSummary, + cancellationToken); + + /// + public Task> ListTeamMembersAsync( + Guid teamId, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Get, + string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"), + null, + DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary, + cancellationToken); + + /// + public Task 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); + + /// + public Task 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); + + /// + public Task RemoveTeamMemberAsync( + Guid teamId, + Guid userId, + CancellationToken cancellationToken) => + DeleteAsync( + string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"), + cancellationToken); + + /// + public Task 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); + + /// + /// Looks a user up by exact email address. + /// + /// + /// 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. rather than string + /// concatenation: an unescaped & or # in an address would silently become a + /// lookup for something else. + /// + public Task> LookupByEmailAsync( + string email, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(email); + + return SendAsync( + HttpMethod.Get, + $"{DirectoryPath}?email={Uri.EscapeDataString(email)}", + null, + DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry, + cancellationToken); + } + + /// + public async Task 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]; + } + + /// + public Task 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); + } + + /// + public Task ListVaultGrantsAsync( + Guid vaultId, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Get, + string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"), + null, + DodoSshJsonContext.Default.VaultGrantsResponse, + cancellationToken); + + /// + 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); + + /// + public Task RevokeVaultGrantAsync( + Guid vaultId, + Guid userId, + CancellationToken cancellationToken) => + DeleteAsync( + string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"), + cancellationToken); + private async Task GetAnonymousAsync( string path, System.Text.Json.Serialization.Metadata.JsonTypeInfo 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 /// worth an exception; here it is the answer. /// + /// + /// Sends a request whose success carries no body. + /// + /// + /// Its own path for the reason 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. + /// + 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 DeleteAsync(string path, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Delete, path); diff --git a/src/DodoSSH.Client.Api/KeyLogAudit.cs b/src/DodoSSH.Client.Api/KeyLogAudit.cs new file mode 100644 index 0000000..6e1e292 --- /dev/null +++ b/src/DodoSSH.Client.Api/KeyLogAudit.cs @@ -0,0 +1,313 @@ +using System.Security.Cryptography; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Api; + +/// Why a directory entry was or was not accepted. +public enum RecipientVerdict +{ + /// Not a legal value. + Unspecified = 0, + + /// + /// The key log verifies, and it introduces exactly the key the directory described. + /// + /// + /// 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 + /// and ADR 0001. + /// + Verified = 1, + + /// No account with that address, or none the caller may look up. + NotFound = 2, + + /// The account exists but has published no identity key, so there is nothing to wrap to. + NotEnrolled = 3, + + /// + /// The key log's hash chain does not verify. + /// + /// + /// 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. + /// + ChainBroken = 4, + + /// + /// The log holds no entry matching the key the directory returned. + /// + /// + /// 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. + /// + NotInKeyLog = 5, + + /// The fingerprint does not match the keys it is supposed to be over. + FingerprintMismatch = 6, + + /// + /// The log introduces a newer generation for this user than the directory returned. + /// + /// + /// 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. + /// + Superseded = 7, +} + +/// +/// A recipient whose published key has been checked against the key log. +/// +/// The directory entry, as returned. +/// +/// 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. +/// +/// +/// The recipient's identity fingerprint, recomputed here rather than taken from the response. +/// +/// Show this to a human before sharing anything that matters. 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. +/// +/// +public sealed record VerifiedRecipient( + DirectoryEntry Entry, + byte[] KeyLogHead, + byte[] Fingerprint); + +/// The outcome of verifying a recipient. +/// What happened. +/// The recipient, present only when verified. +/// One line for a person. Never contains key material. +public sealed record RecipientVerification( + RecipientVerdict Verdict, + VerifiedRecipient? Recipient, + string Message) +{ + /// Whether a key came back that is safe to wrap to. + public bool IsVerified => Verdict == RecipientVerdict.Verified && Recipient is not null; +} + +/// +/// Reads the whole key log, checks its hash chain, and decides whether a directory answer agrees +/// with it. +/// +/// +/// +/// This is the check that makes sharing safe to offer at all. 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. +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +public static class KeyLogAudit +{ + /// Entries requested per page. + private const int PageSize = 500; + + /// + /// Pages the whole log with the chain checked link by link. + /// + /// The verified log, or a null Entries when a link did not hold. + public static async Task ReadAsync( + IDirectoryApi directory, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(directory); + + var entries = new List(); + 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); + } + + /// Decides whether a directory entry agrees with a verified log. + 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); + } + + /// Compares one directory entry with the log entries for that account. + 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."); + } + + /// + /// 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. + /// + 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); + } +} + +/// A key log that has been read, with its chain checked. +/// +/// Every entry in order, or 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. +/// +/// The head the server reported, for recording in a grant. +public sealed record AuditedKeyLog(IReadOnlyList? Entries, byte[] Head); diff --git a/src/DodoSSH.Client.App/ViewModels/KnownHostsViewModel.cs b/src/DodoSSH.Client.App/ViewModels/KnownHostsViewModel.cs index cbfeca1..de1f218 100644 --- a/src/DodoSSH.Client.App/ViewModels/KnownHostsViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/KnownHostsViewModel.cs @@ -22,8 +22,18 @@ namespace DodoSSH.Client.App.ViewModels; /// of pinning one is to compare it with what they published. /// /// -internal sealed class KnownHostRowViewModel(VaultItem pin, bool isDialledByAHost) +internal sealed class KnownHostRowViewModel( + VaultItem pin, + bool isDialledByAHost, + Guid vaultId, + string vaultName) { + /// Which vault this pin lives in. See . + internal Guid VaultId => vaultId; + + /// The vault's display name. + internal string VaultName => vaultName; + internal Guid EntityId => pin.EntityId; internal KnownHostSecret Pin => pin.Secret; diff --git a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs index 707c4a3..87a059b 100644 --- a/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/MainWindowViewModel.cs @@ -198,6 +198,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp /// private readonly ConnectionRecorder connectionLog; + private readonly TeamsViewModel teams; + private IVaultServer? connection; /// The refresh token last written to the cache, so a rotation is noticed without reading it back. @@ -274,6 +276,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp connectionLog = new ConnectionRecorder(clock, Environment.MachineName); this.workspace.ConnectionLog = connectionLog; + // Both dependencies as functions rather than values: the connection arrives after sign-in and the + // session after unlock, and both go away again on lock. Capturing either would give this screen a + // reference that outlives what it points at — which for a session means holding vault keys past the + // moment locking is supposed to have zeroed them. + teams = new TeamsViewModel(() => connection, () => Vault?.Session); + // Subscribed for the life of the process, because the workspace lives that long and so does the tab // list. Detached in DisposeAsync, which is the only point either of them ends. this.workspace.SessionEnded += OnWorkspaceSessionEnded; @@ -365,6 +373,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp [ObservableProperty] private LogsViewModel? logsScreen; + /// + /// The teams screen, which the window binds to whether or not a vault is open. + /// + /// + /// Not nullable and never replaced, for the reason 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. + /// + internal TeamsViewModel Teams => teams; + /// The transfers screen, which the window binds to whether or not a vault is open. /// /// Not nullable and never replaced, unlike . The screen is unreachable while locked — @@ -2009,6 +2028,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp { _ = 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); + } } /// diff --git a/src/DodoSSH.Client.App/ViewModels/TeamsViewModel.cs b/src/DodoSSH.Client.App/ViewModels/TeamsViewModel.cs new file mode 100644 index 0000000..8d5d9cd --- /dev/null +++ b/src/DodoSSH.Client.App/ViewModels/TeamsViewModel.cs @@ -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; + +/// One team, as a row in the list. +internal sealed record TeamRowViewModel(TeamSummary Team) +{ + internal Guid TeamId => Team.TeamId; + + internal string Name => Team.Name; + + internal string Slug => Team.Slug; + + /// The caller's own role, as the chip the list shows. + internal string Role => Team.Role.ToString().ToUpperInvariant(); + + internal string Detail => string.Create( + CultureInfo.CurrentCulture, + $"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)"); + + /// Whether this account may add members and create vaults here. + internal bool CanAdminister => + Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner; +} + +/// One member, as a row in the members table. +internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf) +{ + internal Guid UserId => Member.UserId; + + /// What to call them. The address, or the id when the account has neither. + /// + /// 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. + /// + internal string Name => + Member.DisplayName ?? Member.Email ?? Member.UserId.ToString(); + + internal string Email => Member.Email ?? "—"; + + internal string Role => Member.Role.ToString().ToUpperInvariant(); + + /// + /// What the account can be given, in one phrase. + /// + /// + /// Not a two-factor column, not a last-active column. The server records neither: there is no + /// second-factor concept anywhere in it, and LastSeenAtUtc 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. + /// + internal string KeyState => Member.IsEnrolled + ? "key published" + : "no key yet — cannot be given a vault"; + + internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner; +} + +/// One vault of the selected team, with what this account can do to it. +internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired) +{ + /// What the row says about itself. + /// + /// 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. + /// + 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", + }; +} + +/// +/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to. +/// +/// +/// +/// Two separate acts, and the screen is built around saying so. 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 TeamService and ADR 0001. +/// +/// +/// 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. +/// +/// +internal sealed partial class TeamsViewModel( + Func connection, + Func session) : ObservableObject +{ + /// Teams this account belongs to. + internal ObservableCollection Teams { get; } = []; + + /// Members of the selected team. + internal ObservableCollection Members { get; } = []; + + /// Vaults the selected team owns, as far as this account can see them. + internal ObservableCollection 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; + + /// Whether there is a server to talk to at all. + internal bool IsOnline => connection() is not null; + + /// Whether the selected team can be administered by this account. + internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true; + + /// Whether there is anything to show below the team list. + internal bool HasSelection => SelectedTeam is not null; + + internal bool HasTeams => Teams.Count > 0; + + /// Reads the teams this account belongs to, and the selected one's detail. + internal Task LoadAsync(CancellationToken cancellationToken) => + RunAsync(() => ReloadAsync(cancellationToken)); + + /// + /// The reload itself, without the busy gate. + /// + /// + /// Separate from 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. + /// + 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; + } + + /// Opens the create-a-team form. + [RelayCommand] + private void NewTeam() + { + NewTeamName = string.Empty; + NewTeamSlug = string.Empty; + IsCreatingTeam = true; + Status = string.Empty; + } + + /// Abandons the create-a-team form. + [RelayCommand] + private void CancelNewTeam() + { + IsCreatingTeam = false; + Status = string.Empty; + } + + /// Creates a team, with this account as its owner. + /// + /// 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. + /// + [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); + } + + /// + /// Adds a member, by looking their address up in the directory first. + /// + /// + /// 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. + /// + [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); + } + + /// Removes a member, revoking every vault key grant they hold from this team. + [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); + } + + /// Creates a vault owned by the selected team. + [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); + } + + /// + /// Wraps the selected vault's key to the selected member. + /// + /// + /// Everything that makes this safe happens inside : 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. + /// + [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); + } + + /// Withdraws the selected member's key to the selected vault. + [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); + } + + /// Reads the selected team's members and vaults. + 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)); + } + + /// + /// 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 Problems — so it + /// is shown rather than replaced with something vaguer. + /// + private async Task RunAsync(Func 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; + } + } +} diff --git a/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs index f195bff..03fb8d2 100644 --- a/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/TransfersViewModel.cs @@ -49,6 +49,12 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry) /// The mode as drwxr-xr-x, which is the design's PERMS column. internal string Permissions => entry.Permissions; + + /// Whether the row is a file with an execute bit, which the NAME column colours for. + internal bool IsExecutable => entry.IsExecutable; + + /// Whether the row is a file anyone may write to, which the PERMS column colours for. + internal bool IsWorldWritable => entry.IsWorldWritable; } /// One local file or directory, as a row. diff --git a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs index ea959e2..b826018 100644 --- a/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs @@ -124,10 +124,40 @@ internal sealed class SnippetRowViewModel(VaultItem snippet) /// 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. /// -internal sealed partial class HostRowViewModel(VaultItem host) : ObservableObject, ISidebarRow +internal sealed partial class HostRowViewModel( + VaultItem host, + Guid vaultId, + string vaultName) : ObservableObject, ISidebarRow { internal Guid EntityId => host.EntityId; + /// + /// Which vault this host lives in. + /// + /// + /// 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. + /// + internal Guid VaultId => vaultId; + + /// The vault's display name, for the heading the sidebar groups under. + internal string VaultName => vaultName; + + /// + /// The vault name to print on this row, or empty when there is only one vault to be in. + /// + /// + /// 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. + /// + internal string VaultBadge { get; init; } = string.Empty; + + /// Whether this row has a vault to name. + internal bool HasVaultBadge => VaultBadge.Length > 0; + internal HostSecret Host => host.Secret; internal string Label => host.Secret.Label; @@ -270,10 +300,16 @@ internal sealed record AuthenticationChoice( /// property. /// /// -internal sealed class SshKeyRowViewModel(VaultItem key) +internal sealed class SshKeyRowViewModel(VaultItem key, Guid vaultId, string vaultName) { internal Guid EntityId => key.EntityId; + /// Which vault this key lives in. See . + internal Guid VaultId => vaultId; + + /// The vault's display name. + internal string VaultName => vaultName; + internal SshKeySecret Key => key.Secret; internal string Label => key.Secret.Label; @@ -335,10 +371,19 @@ internal sealed class ObjectStoreRowViewModel(VaultItem store internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges); } -internal sealed class CredentialRowViewModel(VaultItem credential) +internal sealed class CredentialRowViewModel( + VaultItem credential, + Guid vaultId, + string vaultName) { internal Guid EntityId => credential.EntityId; + /// Which vault this credential lives in. See . + internal Guid VaultId => vaultId; + + /// The vault's display name. + internal string VaultName => vaultName; + internal CredentialSecret Credential => credential.Secret; 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 /// by comparing it against the literal "not synced" would break the day that wording improves. /// +/// One vault, as an option in the "file this into" picker. +/// The vault. +/// Its display name, which is plaintext as all vault names are. +/// Whether this is the caller's own vault rather than a team's. +internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal) +{ + /// + /// What the picker shows. + /// + /// + /// 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. + /// + internal string Display => IsPersonal ? Name : $"{Name} · TEAM"; +} + internal sealed record VaultItemRowViewModel( VaultItemKind Kind, Guid EntityId, @@ -743,10 +805,16 @@ internal sealed partial class VaultViewModel( /// /// The vault's name, because the vault is the only grouping a host has — there are no tags and no /// folders on HostSecret, 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 - /// every vault that is not this user's own. See docs/design-import-gaps.md. + /// presented as structure. + /// + /// 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. + /// /// - internal string HostsHeading => VaultName.ToUpperInvariant(); + internal string HostsHeading => + session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant(); /// Whether the host list under the heading is folded away. [ObservableProperty] @@ -770,6 +838,36 @@ internal sealed partial class VaultViewModel( internal string VaultName => session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain"; + /// + /// The vaults a new item may be filed into: readable, and writable by this account. + /// + /// + /// 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. + /// + internal ObservableCollection TargetVaults { get; } = []; + + /// + /// Where the next new item goes. + /// + /// + /// 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. + /// + internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId; + + /// Whether there is more than one vault to choose between. + /// + /// 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. + /// + internal bool HasVaultChoice => TargetVaults.Count > 1; + + [ObservableProperty] + private VaultChoiceViewModel? selectedTargetVault; + [ObservableProperty] private HostRowViewModel? selectedHost; @@ -1036,6 +1134,24 @@ internal sealed partial class VaultViewModel( /// The item being edited, or null when creating. private Guid? editingEntityId; + /// + /// Which vault the editor will write to. + /// + /// + /// 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 at that moment, so changing the picker afterwards cannot silently move + /// a half-typed host into a team's vault. + /// + private Guid editingHostVaultId; + + /// Which vault the key editor will write to. See . + private Guid editingKeyVaultId; + + /// Which vault the credential editor will write to. See . + private Guid editingCredentialVaultId; + /// /// Whether the editor is showing a host that could have a pinned key to forget. /// @@ -1356,6 +1472,10 @@ internal sealed partial class VaultViewModel( /// 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 // gets counted under them — so the host reload is the pass that can put both together. var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true); @@ -1379,20 +1499,74 @@ internal sealed partial class VaultViewModel( await LoadConflictsAsync(cancellationToken).ConfigureAwait(true); } + /// Refills the "file this into" picker from the vaults this session can read and write. + /// + /// 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. + /// + 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)); + } + /// How many hosts would not decrypt. private async Task ReloadHostsAsync(CancellationToken cancellationToken) { - var listing = await session.Hosts - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedHost?.EntityId; + var unreadable = 0; + var rows = new List(); + + // 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 + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + 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(); - 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 @@ -1404,7 +1578,7 @@ internal sealed partial class VaultViewModel( RebuildGroups(); RebuildVisibleHosts(); - return listing.Unreadable; + return unreadable; } /// How many buckets would not decrypt. @@ -1533,9 +1707,21 @@ internal sealed partial class VaultViewModel( /// How many groups would not decrypt. /// + /// /// The listing is kept rather than projected straight into , because a group row /// carries how many hosts name it and the hosts have not been read yet when this runs. See /// , which is where the two meet. + /// + /// + /// The active vault only, unlike every other list on this screen. 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 + /// docs/design-import-gaps.md. + /// /// private async Task ReloadGroupsAsync(CancellationToken cancellationToken) { @@ -1735,22 +1921,35 @@ internal sealed partial class VaultViewModel( /// private async Task ReloadKeysAsync(CancellationToken cancellationToken) { - var listing = await session.SshKeys - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedKey?.EntityId; + var unreadable = 0; + var rows = new List(); + + foreach (var vault in session.ReadableVaults) + { + var listing = await session.SshKeys + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + unreadable += listing.Unreadable; + + rows.AddRange(listing.Items.Select( + item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name))); + } 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); - return listing.Unreadable; + return unreadable; } /// How many credentials would not decrypt. @@ -1762,23 +1961,35 @@ internal sealed partial class VaultViewModel( /// private async Task ReloadCredentialsAsync(CancellationToken cancellationToken) { - var listing = await session.Credentials - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedCredential?.EntityId; + var unreadable = 0; + var rows = new List(); + + foreach (var vault in session.ReadableVaults) + { + var listing = await session.Credentials + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + unreadable += listing.Unreadable; + + rows.AddRange(listing.Items.Select( + item => new CredentialRowViewModel(item, vault.VaultId, vault.Name))); + } Credentials.Clear(); - foreach (var credential in listing.Items - .OrderBy(credential => credential.Secret.Label, StringComparer.CurrentCulture)) + foreach (var credential in rows + .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); - return listing.Unreadable; + return unreadable; } /// How many pins would not decrypt. @@ -1789,11 +2000,9 @@ internal sealed partial class VaultViewModel( /// private async Task ReloadKnownHostsAsync(CancellationToken cancellationToken) { - var listing = await session.KnownHosts - .ListAsync(session.ActiveVaultId, cancellationToken) - .ConfigureAwait(true); - var selectedId = SelectedKnownHost?.EntityId; + var unreadable = 0; + var rows = new List(); // 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. @@ -1801,20 +2010,39 @@ internal sealed partial class VaultViewModel( .Select(host => Endpoint(host.Host.Hostname, host.Host.Port)) .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(); - foreach (var pin in listing.Items - .OrderBy(pin => pin.Secret.Host, StringComparer.CurrentCulture) - .ThenBy(pin => pin.Secret.Port) - .ThenBy(pin => pin.Secret.Algorithm, StringComparer.Ordinal)) + foreach (var pin in rows + .OrderByDescending(row => row.VaultId == session.ActiveVaultId) + .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) + .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { - KnownHostPins.Add(new KnownHostRowViewModel( - pin, dialled.Contains(Endpoint(pin.Secret.Host, pin.Secret.Port)))); + KnownHostPins.Add(pin); } SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId); - return listing.Unreadable; + return unreadable; } /// @@ -1938,16 +2166,17 @@ internal sealed partial class VaultViewModel( 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 - // 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. - // 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 - // overwrite that message after every single save. - if (report is not null - && (report.PulledItems > 0 || report.PushedItems > 0 || report.NeedsAttention - || report.ResyncedFromStart)) + if (report is null) + { + return; + } + + // A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it + // gets the treatment the catch below gives a total failure, the fact kept and the message + // swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was + // reading, once a minute, with the name of a vault it could not reach. Pressing Sync still + // names the vault and the reason, because somebody who pressed it is waiting for an answer. + if (IsWorthReporting(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 /// waiting for it, and queueing them would turn a slow server into a backlog of identical work. /// - private async Task SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken) + private async Task?> SyncOnceAsync( + ISyncApi api, + CancellationToken cancellationToken) { if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true)) { @@ -1984,9 +2215,16 @@ internal sealed partial class VaultViewModel( 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); @@ -2076,6 +2314,7 @@ internal sealed partial class VaultViewModel( } editingEntityId = null; + editingHostVaultId = TargetVaultId; EditorLabel = string.Empty; EditorHostname = string.Empty; EditorPort = HostSecret.DefaultPort; @@ -2109,6 +2348,7 @@ internal sealed partial class VaultViewModel( } editingEntityId = row.EntityId; + editingHostVaultId = row.VaultId; EditorLabel = row.Host.Label; EditorHostname = row.Host.Hostname; EditorPort = row.Host.Port; @@ -2354,13 +2594,13 @@ internal sealed partial class VaultViewModel( if (editingEntityId is { } entityId) { await session.Hosts - .UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken) + .UpdateAsync(editingHostVaultId, entityId, host, cancellationToken) .ConfigureAwait(true); } else { editingEntityId = await session.Hosts - .CreateAsync(session.ActiveVaultId, host, cancellationToken) + .CreateAsync(editingHostVaultId, host, cancellationToken) .ConfigureAwait(true); } @@ -2483,7 +2723,7 @@ internal sealed partial class VaultViewModel( async () => { await session.Hosts - .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) + .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); @@ -2506,6 +2746,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Keys; editingKeyId = null; + editingKeyVaultId = TargetVaultId; ClearKeyEditor(); IsEditingKey = true; Status = "Adding an SSH key."; @@ -2532,6 +2773,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Keys; editingKeyId = row.EntityId; + editingKeyVaultId = row.VaultId; KeyEditorLabel = row.Key.Label; KeyEditorPrivateKey = row.Key.PrivateKeyPem; KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty; @@ -2615,6 +2857,12 @@ internal sealed partial class VaultViewModel( IsGeneratingKey = false; 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(); KeyEditorLabel = comment; @@ -2694,13 +2942,13 @@ internal sealed partial class VaultViewModel( if (editingKeyId is { } entityId) { await session.SshKeys - .UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken) + .UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken) .ConfigureAwait(true); } else { editingKeyId = await session.SshKeys - .CreateAsync(session.ActiveVaultId, key, cancellationToken) + .CreateAsync(editingKeyVaultId, key, cancellationToken) .ConfigureAwait(true); } @@ -2758,7 +3006,7 @@ internal sealed partial class VaultViewModel( async () => { await session.SshKeys - .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) + .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); @@ -2779,6 +3027,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Credentials; editingCredentialId = null; + editingCredentialVaultId = TargetVaultId; ClearCredentialEditor(); IsEditingCredential = true; Status = "Adding a credential."; @@ -2805,6 +3054,7 @@ internal sealed partial class VaultViewModel( Section = VaultSection.Credentials; editingCredentialId = row.EntityId; + editingCredentialVaultId = row.VaultId; CredentialEditorLabel = row.Credential.Label; CredentialEditorUsername = row.Credential.Username ?? string.Empty; CredentialEditorPassword = row.Credential.Password; @@ -3028,13 +3278,13 @@ internal sealed partial class VaultViewModel( if (editingCredentialId is { } entityId) { await session.Credentials - .UpdateAsync(session.ActiveVaultId, entityId, credential, cancellationToken) + .UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken) .ConfigureAwait(true); } else { editingCredentialId = await session.Credentials - .CreateAsync(session.ActiveVaultId, credential, cancellationToken) + .CreateAsync(editingCredentialVaultId, credential, cancellationToken) .ConfigureAwait(true); } @@ -3086,7 +3336,7 @@ internal sealed partial class VaultViewModel( async () => { await session.Credentials - .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) + .DeleteAsync(row.VaultId, row.EntityId, 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 /// of recording those is that somebody sees them. /// + /// + /// 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 + /// 's catch block exists to avoid; the caller records + /// 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. + /// + /// + /// 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. + /// + private static bool IsWorthReporting(IReadOnlyList 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)); + + /// + /// Counts are summed across vaults, and a failure is named with its reason. 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. + /// + private static string Describe(IReadOnlyList 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 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) { // Said first, and in both branches, because it is the explanation for the numbers after it. A pass diff --git a/src/DodoSSH.Client.App/Views/HostSidebar.axaml b/src/DodoSSH.Client.App/Views/HostSidebar.axaml index 0ae4a2e..e5005d9 100644 --- a/src/DodoSSH.Client.App/Views/HostSidebar.axaml +++ b/src/DodoSSH.Client.App/Views/HostSidebar.axaml @@ -36,8 +36,10 @@