Let a team be joined only by somebody who is already here

An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
This commit is contained in:
2026-08-05 08:28:57 +02:00
parent 7dc3b8950d
commit 69bc9e270b
39 changed files with 2258 additions and 2275 deletions
+44 -31
View File
@@ -87,39 +87,52 @@ Two smaller choices, recorded because the alternative was written down first and
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.
### An invitation is membership decided before there is an account to hold it
### Addendum: invitations are gone, and an address is not a way into a team
There was an invitation here, and it is worth recording what it was before recording why it is not.
A membership names an account: `team_membership.user_id` is not nullable and carries a foreign key, so
somebody who has never signed in here has nothing for that row to point at. `MembershipStatus.Invited`
has existed since the first migration and is still never written — not as an oversight, but because a
membership waiting for a person is the one shape this model cannot store. An invitation is therefore its
own record, `team_invitation`, held against an **address**, and it becomes an ordinary active membership
the moment an account with that address signs in.
somebody who has never signed in has nothing for that row to point at. `MembershipStatus.Invited` has
existed since the first migration and has never been written, because a membership waiting for a person
is the one shape this model cannot store. So an invitation was its own record, `team_invitation`, held
against an **address**, and it became an ordinary active membership the moment an account with that
address signed in — provided the access token asserted `email_verified` over it.
That extends the model rather than bending it. An invitation grants nothing readable and cannot be a
step towards sharing, because there is no account and so no public key to wrap a vault to. It moves the
first half of the split earlier and leaves the second half exactly where it was.
**That last clause was the whole feature, and it is why the feature is gone.** Everything else in this
model decides access from something a person did: an admin naming an account, a key holder wrapping a
vault key to a public key they verified. The invitation decided access from an *assertion about an
address*, made by an identity provider, on behalf of somebody the deployment had never seen. It was
guarded as tightly as that can be guarded — the claim was refused outright on an unverified or absent
claim, with no setting to relax it, for the same reason `OidcOptions.AllowEmailLinking` is off and stays
off. But the guard and the risk were the same shape: the server was trusting a token's email claim to
decide who is in a team, and the whole of the defence was one boolean sent by a system this deployment
does not control.
Three decisions inside it belong here, because each had a more convenient alternative:
So the decision now reads: **membership is granted only to an account that already exists, named by
somebody who can already see it.** `POST /api/v1/teams/{id}/members` takes a user id from the directory,
or an address it resolves to an existing account, and an address with no account is refused with
`ProblemCodes.NoSuchAccount`. That refusal is the end of the road rather than a step on it — there is
nothing to fall through to.
- **The claim requires `email_verified` on the access token, and nothing relaxes it.** This is the whole
of the security boundary. Membership is authorisation, so an invitation that could be taken by anybody
able to obtain a token asserting somebody else's address is a way into a team — the same attack
`OidcOptions.AllowEmailLinking` exists to refuse, arriving by another door and deserving the same bar.
An unverified or absent claim claims nothing and logs a warning, which is the only signal an operator
gets that their provider is not sending it. There is deliberately no setting to trust an unverified
address: a flag that exists is a flag somebody turns on for the afternoon their provider is
misconfigured, and this is the one it must not be possible to turn on.
- **Nothing is sent, and the product says so rather than implying a mail path it has not got.** There is
no token and no link — the row is a standing instruction, and telling the invitee to go and sign in
happens over a channel this server does not carry. A link nobody can deliver would be worse than none.
The compensation, such as it is, is real: an invitation that is not a bearer credential is one that
cannot be forwarded, intercepted or replayed.
- **An address that already has an account here is accepted rather than refused.** Refusing and pointing
at the directory would have been tidier, and would have turned the endpoint into an oracle for which
addresses have accounts on this deployment, answerable by anybody willing to create a team first. Only
an address already belonging to a member of *this* team is refused, and that is a fact the caller can
already read off the members table, so naming it leaks nothing.
Three consequences, all of them stated rather than mitigated:
- **Somebody who has never signed in here cannot be added yet.** The remedy is theirs: they sign in
once, which is what creates the account, and then they can be added. Both clients say exactly that
when the refusal comes back. This is a real loss of convenience, and it is the price of not having a
path where an address is a credential.
- **Nothing reads the email claim for authorisation any more**, anywhere in the server.
`OidcOptions.EmailVerifiedClaim` was read by the claim path and by nothing else, so it went with it —
a setting that changes no outcome is worse than no setting. The address is still recorded and still
resolved by the directory, both for display and for naming an account that exists.
- **Pending invitations were dropped rather than converted.** Converting one would have meant creating a
membership because an address matched, which is the property being removed; and an invitation to an
address that *did* have an account here had already been claimed by the hourly sweep, so what was left
was offers to people who never arrived. See the `DropTeamInvitation` migration.
The oracle question the old design worried about — whether this endpoint tells a caller which addresses
have accounts here — is answered rather than avoided. `NoSuchAccount` does say so, to an admin or owner
of the team the add names, which is the same fact the member list shows them a moment later. That was
already true of adding by address before invitations existed.
### Addendum: the vault is what the product shows, and the team is behind it
@@ -165,9 +178,9 @@ Three consequences of the change belong here:
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. Invitations widen it by one
edge — an address that has been invited is on the graph before its owner has ever been here which is
the same class of fact and worth naming rather than leaving to be noticed.
and is not made worse here, but it is now a graph rather than a list. Every node on it is an account
that exists: there is no longer an edge to an address whose owner has never been here, which is what
invitations added and what removing them took back.
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
-1
View File
@@ -48,7 +48,6 @@ this specification is not implementable.
| --- | --- | --- | --- | --- |
| Passphrase → master key | 256 MiB | 4 | 1 | 32 B |
| Recovery code → KEK | 64 MiB | 3 | 1 | 32 B |
| Invite secret → KEK | 64 MiB | 3 | 1 | 32 B |
Salt is 16 bytes from a CSPRNG, fresh on every passphrase change.
+27 -24
View File
@@ -30,11 +30,11 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
>
> **A sixth is behind MORE that v2 never drew: VAULTS.** It is the reverse case — a shipped screen the
> design had no slot for — and it is on the phone for a reason the design could not have anticipated,
> because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the
> person being invited is at least as likely to be holding a phone as sitting at a desktop; a vault the
> server has just put somebody in, visible only on a head they may not have installed, is a membership
> they cannot see. It runs over the same view model the desktop screen drives, like the other four.
> design had no slot for — and it is on the phone because a vault arrives without being asked for.
> Somebody wraps its key to you from their machine, and the person it arrives for is at least as likely to
> be holding a phone as sitting at a desktop; a vault the server has just put somebody in, visible only on
> a head they may not have installed, is a membership they cannot see. It runs over the same view model
> the desktop screen drives, like the other four.
>
> | v2 element | What ships instead |
> | --- | --- |
@@ -195,12 +195,13 @@ unused tables bought. See [Vaults](#vaults). What has *not* changed is the split
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.
One table did have to be added, and what it shows is the limit of that reservation. An invitation names an
One table was added and then dropped again, and the round trip is worth a line. An invitation named an
**address**, and `team_membership.user_id` is not nullable and carries a foreign key, so somebody who has
never signed in has nothing for that row to point at — which is why `MembershipStatus.Invited` has been
reserved since the first migration and is still never written. `team_invitation` is its own table for that
one reason. The schema was right about the shape of a team and had said nothing about the shape of joining
one.
never signed in had nothing for that row to point at — which is why `MembershipStatus.Invited` has been
reserved since the first migration and has never once been written. `team_invitation` existed for that one
reason and is gone: membership is granted only to an account that exists, so there is no longer a shape the
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
**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
@@ -394,9 +395,8 @@ drew around them.
## Vaults
**Built in M3, and reshaped since.** The screen ships: a vault list, a members table with a real
last-active column, the invitations standing against addresses that have no account here yet, who holds a
key, and the two buttons the whole design was really about — add somebody, and share a vault key. A vault
can also be renamed and handed to another member.
last-active column, who holds a key, and the two buttons the whole design was really about — add somebody,
and share a vault key. A vault can also be renamed and handed to another member.
**It lists vaults where it used to list teams, and that is the reshaping.** A team is still what the server
authorises against; what went is the requirement that anybody make one. Naming a vault makes the membership
@@ -416,15 +416,19 @@ place that changed, exactly as its remark predicted, and no migration was needed
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.
**There are two ways into a team and they are not interchangeable.** Adding a member takes a *user id* the
caller has already got from the directory, so that account must have signed in here at least once — and the
**There is one way into a team, and that is the decision.** Adding a member takes a *user id* the caller
has already got from the directory, so that account must have signed in here at least once — and the
ordering is deliberate rather than incidental, because whoever adds a member is usually about to wrap a
vault key to the public key that lookup returned. Inviting takes an *address*, grants nothing readable, and
cannot be a step towards sharing: there is no account, so there is no key to wrap to. Inviting an address
that already belongs to a member of this team is refused and says so — that is a fact about a team the
caller can already see. Inviting one that merely has an account somewhere on this deployment is **not**
refused, because answering that would turn the endpoint into an oracle for which addresses have accounts,
answerable by anybody willing to create a team first. It simply gets claimed sooner.
vault key to the public key that lookup returned. An *address* is accepted too, and has to be: the
directory lists only accounts that have published a key, so everybody between a first sign-in and their
enrollment is invisible there and would otherwise be unaddable. What is refused is an address with no
account at all, under `no-such-account`, and that refusal is the end of the road — the remedy is that
person signing in once.
There was a second way in, and there is not any more. An invitation took an address, waited, and became a
membership the next time somebody signed in asserting it. It is gone because it was the one place in this
product where access followed from an identity provider's claim about an address rather than from somebody
naming an account. See [ADR 0009](adr/0009-team-access-model.md).
| Design element | Layer | What ships |
| --- | --- | --- |
@@ -436,17 +440,16 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
| Adding a member shares the team's vaults | client | Adding somebody wraps every team vault the adding machine can open to them, as part of the add rather than as a button to remember. Membership and a key are still two acts on two machines; the client just performs both. A vault this machine holds no key to is skipped and named. |
| Removing a member rotates the vaults | server + client | `POST /api/v1/vaults/{id}/rekey` advances the generation and records the caller's new grant in one transaction — the server contributes the moment and no cryptography. The client then wraps the new key to the members who remain. Grants for earlier generations are kept, or the vault's stored items would become unreadable to everybody. See [ADR 0010](adr/0010-vault-key-rotation.md). |
| Re-sealing a rotated vault's stored items | client | `VaultResealer`, the last step of a rotation: every item is opened and the same plaintext sealed again under the new key, in batches, against the version the server holds. The plaintext is never decoded, so an item written by a newer client crosses a rotation untouched. Resumable, because a vault at mixed generations is readable — a pass that stops half way leaves a working vault and is re-run. A change queued before the rotation is re-sealed by the push path instead, so nothing reaches the server under a superseded key at all. |
| Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. |
| Ownership transfer | server | `POST /api/v1/teams/{id}/owner`, owner only. One transaction: the named member becomes owner and the outgoing owner becomes an admin. Not two role changes — ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody. The outgoing owner is demoted rather than removed, because removing them would revoke their vault key grants and rotate every team vault, which is a far larger act than the one being asked for. |
| `LAST ACTIVE` | server | Real, and coarse on purpose. `UserAccount.LastSeenAtUtc` is now refreshed on ordinary authenticated requests, at most once per account per hour: writing it per request would put an UPDATE on the hot path of every authenticated call and start losing races on `user_account`'s own concurrency token. So the column answers "this week or not", which is the granularity the question is actually asked at, and is shown coarsely rather than to the minute. |
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. |
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team and every membership in one transaction — and is refused outright while the team owns any vault. |
| 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 columns carry what *is* known and matters: whether they have published a key a vault can be wrapped to, and when they were last here. |
| Avatars | server | No picture is stored anywhere. | Omitted; the row shows a name and an address. |
| The invitation mail, and **resend** | server | An outbound mail path: an SMTP configuration, a template, a bounce story and a deliverability problem, none of which this server has. | **Nothing is sent, and the interface says so.** An invitation is a standing instruction rather than a message — the next account to sign in with that address joins the team — so there is no token, no link, and nothing to resend. Telling somebody to sign in is done over a channel this server does not carry. A link nobody can deliver would be worse than no link. |
| Pending invites, the invitation mail, and **resend** | server | An outbound mail path this server has not got, and — the larger cost — a rule that turns an identity provider's claim about an address into team membership. | **Nothing pending exists, and the screen says why.** Only an account that already exists can be added; an address with no account is refused, naming the address and saying the person has to sign in here once. There was a `team_invitation` table doing the deferred version of this and it has been dropped. See [ADR 0009](adr/0009-team-access-model.md). |
| Archiving a team that owns vaults | — | Nothing that would be safe. A team vault resolves through membership, so archiving would take those vaults away from everybody holding a key, silently, including the caller — and nothing in this product deletes a vault, so there is no sequence of calls that turns the refusal into a success. | Refused, with `team-not-empty` and a count of the vaults in the way. A stated limit rather than a coming feature, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. |
| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
+37 -63
View File
@@ -1239,86 +1239,60 @@ case, and those two have to move together — the switch mirrors that property b
## Phase 12 — Shared vaults: the operations that span two accounts
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
tokens the test mints itself. That last freedom is exactly what puts this phase here. **A test can mint a
token asserting anything it likes, so it can prove the server's rule and can say nothing whatever about
whether *your* identity provider sends the claim that rule depends on** — and an invitation that never
activates fails by sitting still, which is the failure mode nobody notices. What is left needs two real
accounts, a real sign-in, and in two cases a clock.
vault is in the way, ownership changes hands, and an address with no account is refused with its own code.
What is left needs two real accounts, a real sign-in, and in one case a clock — because the half of sharing
this product is built around happens on a machine rather than on the server, and no server test can reach
it.
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`, both with verified addresses; a
second DodoSSH profile means a second machine, a second OS user, or the same machine after signing out.
Whichever account plays the invitee **must not have signed in to this deployment before**most of what
follows is about what happens the first time it does.
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`; a second DodoSSH profile means a
second machine, a second OS user, or the same machine after signing out. Whichever account plays the
newcomer **must not have signed in to this deployment before**12.1 is about exactly that boundary.
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
### 12.1 An address with no account is refused, and joins nothing when it later signs in · **the one worth the most care**
1. Sign in as `alice`, make a vault on the VAULTS screen, and select it.
2. Add `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was**
no "invitation emailed", no link to copy, no token anywhere on the screen.
3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out.
Bob is **not** in the members table, because he has no account here for a membership row to point at.
2. Add `bob@example.com` as a Member, with Bob having never signed in here.
3. **Pass:** it is refused. The status line names the address and says to ask them to sign in to this
server once and then add them. **Nothing on the screen should suggest anything is pending** — no
invited row, no "we will add them when they arrive", and the address stays in the box so it can be
used again in a moment.
4. Sign in as `bob` on the second profile and enroll.
5. **Pass:** the vault is in Bob's list the first time he looks, at Member, with nothing further pressed
on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
and Bob is now in the members table.
6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
opens. This is where an invitation differs from an add and the difference is not a defect: adding an
account the directory already knows wraps the key on the spot, because the adding machine has it and
the recipient has a published key to wrap to. An invitation has neither at the moment it is issued —
there is no account yet — and the claim happens on Bob's machine, which holds nothing. So the key is
still owed, and somebody has to hand it over.
5. **Pass, and this is the half that is easiest to lose:** Bob's vault list holds only his personal vault.
Alice's members table is unchanged. Signing in with an address somebody typed earlier must join nothing
at all.
6. Back on Alice's machine, add `bob@example.com` again.
7. **Pass:** he is added, and — because Alice's machine holds the vault key and Bob has now published one
— the key is wrapped in the same step. Have Bob sync; the vault opens.
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
Step 6 opening the vault *without* Alice sharing a key would be the far more serious failure: nothing on
the server can wrap a vault key, so an item that decrypts after a membership change alone means a key
reached that machine by a route this architecture says does not exist.
**Failure means:** step 5 is the one to stop on. A vault appearing in Bob's list because he signed in with
an address is the deferred-membership path coming back, and it is the property this phase exists to check:
access is granted to an account somebody named, never to an address. See ADR 0009.
### 12.2 An unverified address claims nothing, and the log is the only place that says so
Step 7 opening the vault *without* Alice's machine having wrapped a key would be the more serious failure:
nothing on the server can wrap a vault key, so an item that decrypts after a membership change alone means
a key reached that machine by a route this architecture says does not exist.
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
Invite that address, then sign in as them.
### 12.2 Somebody who has signed in but not enrolled can still be added
**Pass:** they get an account and a personal vault and no shared one. The invitation stays *pending* on
the inviter's screen rather than turning into anything, and the API log carries a warning naming how many
invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request
that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not
hurry it along — the account already exists, so there is no second first-sign-in to trigger it.
Have a third account sign in and stop — no passphrase, no enrollment. Then add its address to a vault.
**Failure means:** if the vault appears while the address is unverified, the one security boundary
invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address
can walk into their vault. Stop there. If it stays pending after verifying, the claim is not reaching the
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
**Pass:** accepted. Their row appears saying they hold no key, and SHARE KEY does not offer to wrap one to
them. The directory returns nothing for that address, which is correct: it lists accounts that have
published a key, and this one has not.
### 12.3 An invitation can be withdrawn until it is taken up
**Failure means:** a refusal here is the old bug, and it is the reason ADD does not take the directory's
silence as an answer. Everybody passes through the window between a first sign-in and enrollment, and
being told they have no account while they are standing beside you is the worst moment to say it.
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
**Pass:** the row reads *revoked* and stays on the list rather than disappearing, and the sign-in produces
an ordinary account in no shared vault. Revoking one that has *already* been accepted answers that there
was nothing to withdraw.
**Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An
accepted one that could be unpicked here would be worse: it is a membership now, and removing a member
revokes their vault key grants and flags the vault for rekey, which is not what "revoke invitation"
should quietly do.
### 12.4 An address already in the team is refused; an address that merely has an account is not
### 12.3 An address already in the team is refused, and says which
With Bob in the vault, add `bob@example.com` to it again.
**Pass:** refused, with a sentence saying the address already belongs to a member and to change their role
instead. Now make a **second** vault and invite the same address there.
instead — distinct from the "no account here" refusal in 12.1, because the two lead somewhere different.
**Pass:** accepted. Bob having an account is deliberately not a reason to refuse — it is claimed within the
hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked.
**Failure means:** if the second invitation is refused because the address already has an account, this
endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody
willing to create a vault first. See ADR 0009.
**Failure means:** the two refusals reading alike leaves somebody checking what they typed when the answer
is that the person is already in.
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**