Share a vault with a team, without the server holding a key

M3's teams, sharing and ACLs. Teams with roles, a public-key directory, the
append-only key log served for clients to check it against, team-owned vaults,
and vault key grants wrapped by a client and stored opaquely by the server.
VaultAccessService resolves team membership to PermissionFlags, 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 one that said it did not exist.
No migration: team, team_membership, vault.team_id and vault_key_grant have all
been there since the first one, which is what carrying two unused tables bought.

Membership is authorisation. A grant is access. The obvious model is one
concept — "access", with a role attached, handed out by the server — and this
architecture cannot implement it: a vault key is sealed to each member's X25519
key, and only a client holding the plaintext can seal it for somebody else. So
"give Bob access" decomposes into a database write and a wrap, which happen on
different machines. Adding a member makes the server serve them the vault; it
cannot make it readable. VaultSummary.WrappedVaultKey is null in the meantime
and the vault appears in their list saying it is waiting for a key, because
hiding it until a grant existed would have been tidier and would have implied
the server was the thing granting access. The screen says the same thing after
every add, in the status line. ADR 0009 records the whole decision.

Sharing verifies or refuses. A directory lookup is a claim by the server about
a third party's public key, and wrapping to an unverified claim hands the vault
to whoever made it — no amount of transport security helps, because the server
is inside the threat model. KeyLogAudit reads the whole log, recomputes every
entry's hash from its own contents, checks the chain from genesis, and refuses
unless the offered key appears in it unchanged. There is no override flag: one
that exists gets used on the day the log is briefly unreachable, and the
resulting grant is indistinguishable from a correct one afterwards. What it
still cannot promise is that the key is the right person's, so the fingerprint
comes back for an out-of-band comparison and the success message says so every
time. A test corrupts the fake server's log by one byte and watches the client
refuse rather than warn.

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 a session needs the credential's plaintext on that machine, and
"may connect but may not read the key" cannot be enforced here. Shipping it as
an option in a dropdown would have been a lie. Connect rides along with Read
and is documented as an interface hint. Removal is named for what it does — it
revokes grants and flags the vault for rekey, and claims nothing about what is
already on somebody's laptop.

Three things are deliberately absent, and each is a refusal rather than an
omission. The rekey itself, because re-wrapping every item's data key under a
new vault key needs a client holding the current one; the server records that a
rotation is owed and the interface reports it, which is more honest than a
button that only appears to do it. Ownership transfer, because allowing an
owner to be removed without one leaves a team nobody can administer. And
cross-vault host key trust: a pin in a team vault is listed but not consulted
at connect time, because any member with Write could otherwise pre-approve a
fingerprint another member's client then trusts silently for a host in their
own vault. Scoping trust properly needs a scope on the SSH connect path, which
IKnownHostStore has not got; until then the narrow direction is the safe one
and the cost is in the README rather than hidden.

Reading now spans vaults and writing still does not. Every list on the vault
and hosts screens covers each vault the keyring opened, rows carry the vault
they came from, and an edit goes back to that vault rather than to the active
one — writing it to the active vault would fork the item and only show up when
a colleague wondered why their change never arrived. A new item goes wherever a
picker says, defaulting to the personal vault and never moving on its own,
because an item filed into a team's vault is visible to that team and moving it
back means deleting and retyping. The sidebar heading stops naming one vault
once there are two, and each row names its own.

The server checks what it can and nothing it cannot. It will not record a grant
for a key its recipient no longer holds, for a superseded generation, or for
somebody who is not in the team — each of those would otherwise surface days
later at the far end as a tag failure indistinguishable from corruption. It
does not verify the wrap or the signature, and the grant service says so: that
would be a convenience and never the boundary, and would put an asymmetric
implementation on a machine that is supposed to hold no keys.

Two bugs the tests found. TeamsViewModel's busy gate blocked its own reload, so
a team created a moment earlier was missing from the list it had just been
added to. And syncing every vault turned a failure from an exception into a
report, which made a background pass announce an unreachable vault once a
minute — the exact behaviour AnAutomaticPassThatFails_LeavesTheStatusAlone
exists to prevent. The fact is recorded and the message swallowed, as it was
before; pressing Sync still names the vault and the reason.

Also fixes a build break this branch started with: QuickConnectTests was never
updated when M2 added ISftpSessionFactory to the shell's constructor, so
nothing built at all.
This commit is contained in:
2026-07-31 12:18:28 +02:00
parent d1700f5a34
commit 95816de0c5
45 changed files with 6699 additions and 133 deletions
+266
View File
@@ -0,0 +1,266 @@
namespace DodoSSH.Contracts;
/// <summary>
/// A member's role within a team, as it travels on the wire.
/// </summary>
/// <remarks>
/// <para>
/// A separate type from <c>DodoSSH.Domain.TeamRole</c> only because both are visible inside the
/// server, exactly as <c>GrantPurpose</c> is separate from <c>GrantKind</c>. The <b>numeric values
/// must match</b> that enum, and a test pins them: the two are converted by cast, so a renumbering
/// here silently promotes or demotes every member on the next deployment.
/// </para>
/// <para>
/// There is no <c>ConnectOnly</c> role, and there will not be one built this way. Connect is a
/// user-interface hint rather than a boundary — SSH terminates on the client, so opening a session
/// needs the credential's plaintext on that machine, and "may connect but may not read the key" is
/// unenforceable in this architecture. See <c>docs/adr/0001-e2ee-trust-model.md</c>.
/// </para>
/// </remarks>
public enum TeamMemberRole
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>May read the team's vaults and nothing else.</summary>
Viewer = 10,
/// <summary>May read and change the team's vaults.</summary>
Member = 20,
/// <summary>May also manage members, create vaults, and share vault keys.</summary>
Admin = 30,
/// <summary>Sole owner. Everything an admin may do, and cannot be removed while sole.</summary>
Owner = 40,
}
/// <summary>State of a team membership, as it travels on the wire.</summary>
/// <remarks>
/// Values match <c>DodoSSH.Domain.MembershipStatus</c>, for the reason
/// <see cref="TeamMemberRole"/> gives.
/// </remarks>
public enum TeamMemberStatus
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>
/// Invited but not yet accepted.
/// </summary>
/// <remarks>
/// Nothing writes this today. An invitation needs a token with a lifetime and an outbound mail
/// path, and this server has neither — so a member is added by looking their account up in the
/// directory, which requires that they have signed in here at least once. Retained because the
/// column exists and a client must not fail on a value a later server may send.
/// </remarks>
Invited = 1,
/// <summary>Active member.</summary>
Active = 2,
/// <summary>Removed. Retained so audit history stays resolvable to a person.</summary>
Revoked = 3,
}
/// <summary>State of a vault key grant, as it travels on the wire.</summary>
/// <remarks>Values match <c>DodoSSH.Domain.GrantState</c>.</remarks>
public enum VaultGrantState
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>Usable.</summary>
Active = 1,
/// <summary>
/// The recipient's identity key changed or the vault was rekeyed, so a member holding Share
/// must wrap the key afresh before the recipient can read anything again.
/// </summary>
AwaitingRewrap = 2,
/// <summary>
/// Revoked. Blocks future reads only — anything already downloaded is already gone, and the
/// remediation for a departed member is rotating the SSH credential itself. See ADR 0001.
/// </summary>
Revoked = 3,
}
/// <summary>A team the caller belongs to.</summary>
/// <param name="TeamId">The team.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">URL-safe unique identifier.</param>
/// <param name="Description">Optional description.</param>
/// <param name="Role">The caller's own role.</param>
/// <param name="MemberCount">Active members, including the caller.</param>
/// <param name="VaultCount">Vaults the team owns.</param>
/// <param name="CreatedAt">When the team was created.</param>
public sealed record TeamSummary(
Guid TeamId,
string Name,
string Slug,
string? Description,
TeamMemberRole Role,
int MemberCount,
int VaultCount,
DateTimeOffset CreatedAt);
/// <summary>A request to create a team.</summary>
/// <remarks>
/// <see cref="TeamId"/> is chosen by the client for the same reason a vault id is: a request whose
/// response was lost can be re-sent verbatim and returns the identical team rather than creating a
/// second one under a name the user only meant to type once.
/// </remarks>
/// <param name="TeamId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name.</param>
/// <param name="Slug">
/// URL-safe unique identifier, lowercase. Unique across the deployment, so this is the one field a
/// create can fail on for a reason the caller cannot see coming.
/// </param>
/// <param name="Description">Optional description.</param>
public sealed record CreateTeamRequest(
Guid TeamId,
string Name,
string Slug,
string? Description);
/// <summary>One member of a team.</summary>
/// <remarks>
/// Carries no last-active time and no avatar. <c>UserAccount.LastSeenAtUtc</c> is written at
/// provisioning and at enrollment and at no other point, so a column labelled "last active" would
/// be reporting something else entirely; and no picture is stored anywhere.
/// </remarks>
/// <param name="UserId">The member.</param>
/// <param name="Email">Email, for display.</param>
/// <param name="DisplayName">Display name.</param>
/// <param name="Role">Role within the team.</param>
/// <param name="Status">Membership state.</param>
/// <param name="IsEnrolled">
/// Whether this member has published an identity key. A member who has not cannot be granted a
/// vault key at all — there is nothing to wrap one to — so the interface has to be able to say so
/// rather than offering a share that would fail.
/// </param>
/// <param name="JoinedAt">When the membership became active.</param>
public sealed record TeamMemberSummary(
Guid UserId,
string? Email,
string? DisplayName,
TeamMemberRole Role,
TeamMemberStatus Status,
bool IsEnrolled,
DateTimeOffset? JoinedAt);
/// <summary>Adds a member to a team.</summary>
/// <remarks>
/// By user id rather than by email, and the id comes from a directory lookup the caller has already
/// made. That ordering is not incidental: whoever adds a member is usually about to wrap a vault key
/// to their public key, and the key they must verify is the one the directory returned. Adding by
/// email here would put an account resolution the client never saw between those two steps.
/// </remarks>
/// <param name="UserId">The account to add, as returned by the directory.</param>
/// <param name="Role">Role to grant.</param>
public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
/// <summary>Changes a member's role.</summary>
/// <param name="Role">The new role.</param>
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
/// <summary>
/// Creates a vault owned by a team, with its key already wrapped to the creator.
/// </summary>
/// <remarks>
/// Shaped like <see cref="PersonalVaultRequest"/> and for the same reasons: the vault key is
/// generated on the client and sealed to the creator's own X25519 key, so the server cannot produce
/// this and cannot check that <see cref="WrappedVaultKey"/> contains anything in particular. A vault
/// created with no grant would be a container nobody could ever open, so the two arrive together.
/// <para>
/// The creator's grant carries no key log head, exactly as a personal vault's does not: there is no
/// third party whose key could have been substituted. Every <em>other</em> member's grant does carry
/// one — see <see cref="IssueVaultGrantRequest"/>.
/// </para>
/// </remarks>
/// <param name="VaultId">Client-generated UUIDv7.</param>
/// <param name="Name">Display name. Plaintext, as all vault names are.</param>
/// <param name="WrappedVaultKey">The vault key sealed to the creator's encryption key.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record CreateTeamVaultRequest(
Guid VaultId,
string Name,
byte[] WrappedVaultKey,
byte[] GrantSignature,
DateTimeOffset GrantedAt);
/// <summary>Issues a vault key grant to another member.</summary>
/// <remarks>
/// <para>
/// The wrap is made by a client that holds the vault key, to a public key it has verified. The
/// server stores both the ciphertext and the signature and can check neither — which is the property
/// that makes it a zero-knowledge server rather than a key-holding one.
/// </para>
/// <para>
/// <see cref="KeyLogHead"/> is required here and absent for a self-grant. A third party's key could
/// have been substituted by the server; recording the log head the granter observed while wrapping
/// is what converts that from an undetectable attack into a detectable one. See docs/crypto.md §7.2.
/// </para>
/// </remarks>
/// <param name="RecipientUserId">Who the key was wrapped to.</param>
/// <param name="RecipientKeyFingerprint">
/// The exact identity key it was wrapped to. Stored so a later rotation invalidates this grant
/// explicitly rather than leaving a row that no longer opens.
/// </param>
/// <param name="KeyGeneration">
/// The generation wrapped. Rejected when it is not the vault's current one, because a grant for a
/// superseded generation opens nothing and would read as corruption at the far end.
/// </param>
/// <param name="WrappedVaultKey">The vault key sealed to the recipient. Opaque to the server.</param>
/// <param name="KeyLogHead">The key log head the granter observed while wrapping.</param>
/// <param name="GrantSignature">Ed25519 signature over the canonical grant tuple.</param>
/// <param name="GrantedAt">Signing timestamp, part of the signed tuple.</param>
public sealed record IssueVaultGrantRequest(
Guid RecipientUserId,
byte[] RecipientKeyFingerprint,
uint KeyGeneration,
byte[] WrappedVaultKey,
byte[] KeyLogHead,
byte[] GrantSignature,
DateTimeOffset GrantedAt);
/// <summary>One vault key grant, as the sharing interface sees it.</summary>
/// <remarks>
/// The wrapped key itself is deliberately not here. A member reads their own through
/// <see cref="VaultSummary.WrappedVaultKey"/>; this listing exists so somebody holding Share can see
/// <em>who has one</em>, and serving every member's sealed key to every member would be a pointless
/// widening of what a stolen access token yields.
/// </remarks>
/// <param name="RecipientUserId">Who holds it.</param>
/// <param name="Email">Their email, for display.</param>
/// <param name="DisplayName">Their display name.</param>
/// <param name="KeyGeneration">Generation this grant is for.</param>
/// <param name="State">Grant state.</param>
/// <param name="GranterUserId">Who issued it.</param>
/// <param name="CreatedAt">When it was issued.</param>
/// <param name="RevokedAt">When it was revoked, if it was.</param>
public sealed record VaultGrantSummary(
Guid RecipientUserId,
string? Email,
string? DisplayName,
uint KeyGeneration,
VaultGrantState State,
Guid GranterUserId,
DateTimeOffset CreatedAt,
DateTimeOffset? RevokedAt);
/// <summary>Who can open a vault, and at which generation.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="KeyGeneration">
/// The vault's current generation. A grant listed at anything lower is stale, which is what a client
/// compares against rather than inferring from <see cref="VaultGrantSummary.State"/> alone.
/// </param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
/// <param name="Grants">Every grant, including revoked ones.</param>
public sealed record VaultGrantsResponse(
Guid VaultId,
uint KeyGeneration,
bool RekeyRequired,
IReadOnlyList<VaultGrantSummary> Grants);