Move the keys when a membership changes, not just the flag

Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.

The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.

The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.

What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.

Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
This commit is contained in:
2026-08-03 23:05:40 +02:00
parent e82a25c912
commit d5b1a73182
35 changed files with 2838 additions and 173 deletions
+27 -14
View File
@@ -285,8 +285,8 @@ rather than only in the ID token or the userinfo response.
become owner and you become an admin, in a single transaction. Not two role changes — promoting first
leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the
authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep
your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody
handing over a team is usually staying in it.
your vault key grants; removing you would revoke them and rotate every team vault, and somebody handing
over a team is usually staying in it.
**Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team
vault is readable *because* of membership, so archiving a team that still owned vaults would take them away
@@ -299,10 +299,19 @@ still holding.
Four 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
- **Removing a member is not retroactive.** It revokes their grants, rotates every team vault your machine
can open, and hands each new key to the members who are left — so nothing written from that point on is
readable to them. Everything they already pulled is still 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.
- **A rotation re-keys the vault, not what is already in it.** Existing items stay sealed under the
generation they were written with, and everybody still in the team keeps those keys as well as the new
one — which is what stops a rotation making the vault's own history unreadable. It also means somebody
who left with a copy of the old key could still open old ciphertext they later got hold of. Re-sealing
stored items under the new key is the remaining half; see [ADR 0010](docs/adr/0010-vault-key-rotation.md).
- **Adding a member shares the vaults you can open, including their history.** Membership is still one act
and a key is still another — nothing changed about that — but the client now performs the second one for
you, wrapping every generation it holds so the new member can read the vault back to its first item. A
vault your machine holds no key to is skipped and says so; somebody who holds it has to share that one.
- **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
@@ -570,7 +579,7 @@ 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. *Done, except rekey.* Teams with roles, a public-key directory, the
- **M3 — teams**, sharing, ACLs. *Done.* 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
@@ -582,20 +591,24 @@ keychain plus a terminal — and the spike that gates all of it.
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.
**Membership changes now move the keys, not just the flag.** Adding somebody wraps every team vault the
adding machine can open to them — every generation of each, so they can read the vault's history and not
only what happens next. Removing somebody revokes their grants, advances each vault it can open to a
fresh key generation in one server transaction, and wraps that key to the members who remain. What a
rotation buys is exact: everything written from then on is unreadable to the person who left. Items
already stored keep the generation they were sealed under and are not re-encrypted — that half is still
outstanding, and it is safe to add later precisely because a vault at mixed generations stays readable.
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
**Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner
and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves
the team owned twice and demoting first leaves it owned by nobody, and there is nobody left with the
authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault
key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey,
which is a much larger act than the one being asked for.
key grants, because removing the outgoing owner would revoke them and rotate every team vault, which is a
much larger act than the one being asked for.
- **M4 — hardening and ops**, packaging, self-hosting guide.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
- **M5 — multi-provider OIDC**, identity key rotation, re-sealing a rotated vault's stored items,
per-item content keys.
## Licence
+6
View File
@@ -67,6 +67,12 @@ One thing is deliberately **not** built, and it is a refusal rather than an omis
- **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.
> **Superseded 2026-08-03 by [ADR 0010](0010-vault-key-rotation.md).** Rotation now ships, and it
> turned out to divide differently than this paragraph assumed: advancing the generation is one
> server transaction and is not the same act as re-wrapping the items, which is still outstanding.
> Removing a member rotates the vaults the removing client can open and hands the new key to whoever
> is left.
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
+122
View File
@@ -0,0 +1,122 @@
# ADR 0010 — Rotation advances a generation; the keys before it are kept
- Status: accepted
- Date: 2026-08-03
- Builds on: [ADR 0001](0001-e2ee-trust-model.md), [ADR 0009](0009-team-access-model.md)
## Context
ADR 0009 shipped removal as "revoke the grants and flag the vault", and named the missing half
plainly: only a client holding the current vault key can produce the next one, so the server could
record that a rotation was owed and nothing more. Nothing acted on the flag. In practice that meant
removing somebody from a team left every vault they could read encrypted under the key they had, for
ever — the interface said a rotation was owed and no button existed to perform one.
Two things had to be decided before that flag could be acted on, and they are not independent.
**When does the generation change?** A vault key is per vault *per generation* (`docs/crypto.md` §3),
and a grant names the generation it opens. If two admins rotate at the same time, both wrap a key,
both issue grants, and the vault ends up with two claimed "current" keys and a set of members split
between them — half of whom cannot read what the other half writes, with nothing to point at as the
cause.
**What happens to everything already stored?** An item carries the generation it was sealed under, in
its own row and in its AAD. A rotation that advanced the generation and left the old grants behind
would make every item written before it unreadable to everybody, including the person who rotated. A
rotation that re-encrypted every item would avoid that — and is a different, much larger operation:
`crypto.md` §3 puts it at N × 32 bytes of re-wrapped data keys, which is cheap in bytes and is still
a write to every row of a vault, in batches, against a server that caps a push at 500 operations and
8 MB, with the connection/activity logs alone reaching five thousand entries per kind.
## Decision
**The rotation is the generation bump, and it is one server transaction. Grants for earlier
generations are kept.**
`POST /api/v1/vaults/{id}/rekey` takes the next generation and the new key sealed to the caller. In
one transaction the vault's `key_generation` advances, the caller's grant for it is inserted, and the
rekey flag is cleared. The request must name exactly `current + 1`, and the vault's `xmin`
concurrency token makes that check binding rather than advisory — the second of two simultaneous
rotations is refused and told to read the vault again. The server contributes the *moment*, which is
the one part of a rotation a client cannot decide for itself; it contributes no cryptography, cannot
tell that the key it is handed differs from the old one, and cannot tell whether the caller held the
old one. That last part is checked the only way it can be: the caller must hold a live grant at the
current generation, which is a row rather than a proof.
Everything that follows from keeping the old grants:
- **A member holds one grant per generation, and `VaultSummary` serves all of them.** The current
wrap stays where it was; the rest arrive as `PriorKeyWraps`, oldest first. `VaultKeyring` holds a
key per generation, hands out the newest for writing and the item's own for reading. Every read
path picks its key from the payload's `keyGeneration` rather than from the vault's.
- **Sharing hands over the history.** `ShareVaultAsync` issues a grant for every generation the
sharing client holds, oldest first. Somebody added after a rotation who was given only the newest
key would open the vault to a list of items that will not decrypt — which reads as corruption, not
as a missing grant. The server accordingly accepts a grant for any generation the vault has
reached, and refuses one for a generation ahead of it: nothing is sealed under that, and accepting
it would let a client move the vault forward outside the transaction that is allowed to.
- **Revocation takes every generation.** Removing a member, and `RevokeGrantAsync`, revoke all of a
recipient's grants rather than the current one. Leaving the history would leave them able to read
everything written before the rotation, which is exactly what the rotation was for.
- **A member between the rotation and their re-wrap can read and cannot write.** They hold the
history and no current key, so the vault lists as unreadable and writes refuse. Writing under a
superseded key would produce items nobody else could open, and the author's own keyring — which
still holds that key — would show no sign of it.
**Removing a member rotates automatically.** The teams screen removes the member, then rotates every
team vault the machine can currently open and wraps each new key to the members who remain. Adding a
member is the mirror image: every team vault this machine can open is wrapped to them as part of the
add. Both report per vault, including what they could not do — a vault whose key this machine does
not hold is skipped and stays flagged, because somebody else has to finish it.
## What this deliberately does not do
**It does not re-encrypt what is already stored.** After a rotation the vault's existing items remain
sealed under the generations they were written with. The person who left keeps whatever plaintext
they already pulled — that is the non-retroactive limit ADR 0001 records and no design here changes
it — and, if they kept the old vault key and later obtained ciphertext they had not already
downloaded, that ciphertext would still open to them.
So the guarantee this buys is exact and worth stating in those words: **everything written from the
rotation onwards is unreadable to them.** Nothing about the past changes. The product says that
rather than the reassuring version, and the honest remediation for a departure is still to rotate the
credentials themselves.
Re-sealing the vault's existing items under the new key is the remaining half and is deferred. It is
safe to add incrementally *because* of the decision above: mixed generations are readable, so a pass
that migrates items one batch at a time cannot strand anything, and a pass that fails half way leaves
a vault that still works. Building it the other way round — bumping the generation only once every
item had been re-sealed — would have needed the whole vault to move in one transaction, which is a
request-size limit dressed as an architecture.
## Alternatives rejected
- **Revoke the old grants on rotation.** Tidier, and it makes the grant list say exactly one thing
per member. It also makes every item written before the rotation unreadable to everybody, which is
data loss performed by a security feature.
- **Chain the keys: store each old key sealed under its successor.** One wrap per rotation instead of
one grant per member per generation, and new members get the history for free. It needs a new table,
a new AAD purpose, and a recursive unwrap on the read path — and it makes the vault's whole history
reachable from the current key, which is a strictly larger blast radius than a set of grants that
can be revoked one at a time.
- **Rotate atomically with every item re-sealed, in one request.** The safest shape on paper and the
one `crypto.md` implies. It caps rotation at the push limits — 500 operations and 8 MB — which a
vault with a year of connection log in it exceeds, and the failure mode is a vault that can never
be rotated at all.
- **Let the server generate the new key.** It would make rotation a single call and would end the
product: a server that can produce a vault key can read the vault.
## Consequences
`vault_key_grant` grows by one row per member per rotation. The unique index is already per
`(vault, generation, recipient)`, so this needed no migration; the rows are 110-byte seals and a vault
rotated monthly for a decade with ten members holds twelve hundred of them.
The sharing graph gains a dimension the operator can read: which generation each member holds, and so
which of them have been re-wrapped since the last rotation. That is the same class of metadata ADR
0009 already records as visible, and it is the same fact the sharing screen shows the members
themselves.
A client that never comes back holds keys to generations that no longer receive writes, which is the
same exposure as any copy of a vault key on a machine that has been lost — bounded by the fact that
the server will not serve them anything, and unbounded in the way every non-retroactive revocation is.
+12 -2
View File
@@ -216,13 +216,23 @@ This is the load-bearing structural choice. Because every wrap protects the *sam
### Why a per-item DataKey
1. **Cheap rotation.** Rotating a vault key re-wraps N × 32-byte data keys and never touches
content blobs. A 10,000-item vault rotates in a few hundred kilobytes of writes.
1. **Cheap rotation.** Re-keying N items re-wraps N × 32-byte data keys and never touches
content blobs. A 10,000-item vault re-keys in a few hundred kilobytes of writes.
2. **Narrow sharing.** A single item can be re-wrapped to another vault key or user key.
3. **Nonce hygiene.** Each key encrypts about one message.
4. **Versioning.** A new item version gets a new data key, so prior ciphertext stays
independently decryptable for history and undo.
> **Added 2026-08-03: what a vault key rotation actually does.** Advancing a vault to a new
> generation does **not** re-wrap the items already in it. Each item keeps the generation it was
> sealed under, in its row and in its AAD, so a rotated vault holds items under two or three keys
> at once and every read chooses the key its item names. That is why a member's grants for earlier
> generations are kept rather than revoked, why `VaultSummary` serves all of them, and why sharing
> issues one grant per generation held: a client holding only the newest key would read the vault's
> whole history as tag failures. Re-sealing stored items under the new key is a separate pass and is
> not yet built — see [ADR 0010](adr/0010-vault-key-rotation.md) for the guarantee this does and does
> not buy.
Per-item keys wrapped *to individual users* — which is what would make per-item ACLs
cryptographic rather than server-enforced — are deferred to M5. The `content_key_id` column
exists from the first migration so that lands without a migration. Until then, **an item ACL
+5 -3
View File
@@ -410,9 +410,11 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
| 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. |
| 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. One grant per generation the sharing client holds, so a recipient can read a rotated vault's history and not only what happens next. |
| 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). |
| 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 flag every team vault for rekey, which is a far larger act than the one being asked for. |
| 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. |
@@ -424,7 +426,7 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
| 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. |
| 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. |
| 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. |
| Re-sealing a rotated vault's stored items | client | Re-wrapping every item's data key under the new vault key, in batches, which only a client holding both keys can do. M5. | The rotation itself ships — see the row above. Existing items keep the generation they were sealed under and stay readable, because every member keeps the keys they were granted. What is outstanding is closing the gap where a departed member's copy of the old key would still open old ciphertext they later obtained. |
> **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
@@ -81,15 +81,25 @@ internal sealed class IdentityService(DodoDbContext database, IVaultAccessServic
{
var vault = access.Vault!;
// The grant must match both the current key generation and the exact identity key it
// was wrapped to. A grant left over from a superseded key is not merely stale — the
// client's current private key cannot open it, so offering it would produce a tag
// failure the user reads as data corruption.
var grant = grants.Find(g =>
g.VaultId == vault.Id
&& g.KeyGeneration == vault.KeyGeneration
&& key is not null
&& g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256));
// The grant must match the exact identity key it was wrapped to. One left over from a
// superseded identity key is not merely stale — the client's current private key cannot
// open it, so offering it would produce a tag failure the user reads as data corruption.
var mine = grants
.Where(g => g.VaultId == vault.Id
&& key is not null
&& g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256))
.ToList();
var grant = mine.Find(g => g.KeyGeneration == vault.KeyGeneration);
// Everything older, oldest first. A rotation does not re-encrypt what is already stored —
// each item keeps the generation it was sealed under — so a client holding only the
// current key would read the vault's whole history as corrupt. See RekeyVaultRequest.
var prior = mine
.Where(g => g.KeyGeneration < vault.KeyGeneration)
.OrderBy(g => g.KeyGeneration)
.Select(g => new VaultKeyWrap((uint)g.KeyGeneration, g.WrappedKey))
.ToArray();
summaries.Add(new VaultSummary(
VaultId: vault.Id,
@@ -103,7 +113,8 @@ internal sealed class IdentityService(DodoDbContext database, IVaultAccessServic
// re-wrap it; the client has to say so rather than showing an empty vault.
WrappedVaultKey: grant?.WrappedKey,
RekeyRequired: vault.RekeyRequired));
RekeyRequired: vault.RekeyRequired,
PriorKeyWraps: prior));
}
return summaries;
+15
View File
@@ -66,6 +66,21 @@ internal static partial class TeamLog
internal static partial void GrantRevoked(
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
/// <remarks>
/// Warning, because a rotation is the one operation that changes what every other member's key is
/// worth: until each of them is wrapped the new generation, they hold the vault's history and
/// cannot read anything written since. An operator seeing members report an unreadable vault needs
/// this line and its timestamp to explain it.
/// </remarks>
[LoggerMessage(
EventId = 2115,
Level = LogLevel.Warning,
Message = "Rotated the key of vault {VaultId} to generation {KeyGeneration}, by {ActorId}. "
+ "Earlier grants are kept so stored items stay readable; every other member needs the new "
+ "generation wrapped to them before they can read anything written from now on.")]
internal static partial void VaultRekeyed(
ILogger logger, Guid vaultId, int keyGeneration, Guid actorId);
[LoggerMessage(
EventId = 2108,
Level = LogLevel.Information,
@@ -114,6 +114,70 @@ internal sealed class IssueVaultGrantEndpoint(
}
}
/// <summary>Moves this vault to a fresh key.</summary>
/// <remarks>
/// Gated on Share rather than on a rotation permission of its own. Rotating decides who can read what
/// is written next, which is the same question sharing and withdrawing answer, and a fourth permission
/// would be a distinction nobody administering a team would be able to explain.
/// </remarks>
internal sealed class RekeyVaultEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
VaultGrantService grants)
: Endpoint<RekeyVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/vaults/{vaultId:guid}/rekey");
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("RekeyVault")
.WithSummary("Advances this vault's key generation, wrapped to the caller.")
.WithTags("Vaults"));
}
/// <inheritdoc />
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
RekeyVaultRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var access = await vaultAccess
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
.ConfigureAwait(false);
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
{
return TypedResults.NotFound();
}
if (!access.Permissions.HasFlag(PermissionFlags.Share))
{
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"You do not have permission to share this vault, so you cannot rotate its key.");
}
try
{
var summary = await grants
.RekeyAsync(user, access.Vault!, (int)access.Permissions, req, ct)
.ConfigureAwait(false);
return TypedResults.Ok(summary);
}
catch (VaultGrantInvalidException exception)
{
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
}
}
}
/// <summary>Withdraws a member's key to this vault.</summary>
/// <remarks>
/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device
@@ -152,6 +152,13 @@ internal sealed class VaultGrantService(
}
/// <summary>Lists who can open a vault.</summary>
/// <remarks>
/// One row per holder, not one per grant. A rotated vault holds several grants per member — one per
/// generation, which is what lets them read its history — and a listing that showed each of them
/// would answer "who can open this" with the same person three times. The row carries the best key
/// they hold: the live grant at the highest generation, or, for somebody whose access has been
/// withdrawn, the most recent grant they had, so the withdrawal is still visible.
/// </remarks>
internal async Task<VaultGrantsResponse> ListGrantsAsync(
Vault vault,
CancellationToken cancellationToken)
@@ -163,13 +170,25 @@ internal sealed class VaultGrantService(
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var holders = grants
.GroupBy(g => g.RecipientUserId!.Value)
.Select(group => group
.OrderByDescending(g => g.RevokedAtUtc is null)
.ThenByDescending(g => g.KeyGeneration)
.First())
// The order the first grant of each holder was made in, so the list reads as the vault was
// shared rather than reshuffling itself every time somebody is re-wrapped.
.OrderBy(g => grants.Find(first => first.RecipientUserId == g.RecipientUserId)!.CreatedAtUtc)
.ToList();
return new VaultGrantsResponse(
VaultId: vault.Id,
KeyGeneration: (uint)vault.KeyGeneration,
RekeyRequired: vault.RekeyRequired,
Grants:
[
.. grants.Select(g => new VaultGrantSummary(
.. holders.Select(g => new VaultGrantSummary(
g.RecipientUserId!.Value,
g.RecipientUser?.Email,
g.RecipientUser?.DisplayName,
@@ -183,10 +202,18 @@ internal sealed class VaultGrantService(
/// <summary>Wraps a vault key to another member.</summary>
/// <remarks>
/// Re-issuing to a recipient who already holds a live grant replaces it in place rather than
/// inserting a second row, because the unique index permits exactly one live grant per recipient
/// per generation — and because the operation somebody is actually performing when they do this
/// is "wrap it again", after a rotation or a botched first attempt.
/// <para>
/// Re-issuing to a recipient who already holds a live grant <em>for that generation</em> 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 botched first attempt.
/// </para>
/// <para>
/// A recipient may hold one grant per generation at once, and after a rotation they need to: an item
/// is sealed under whatever generation was current when it was written, so somebody given only the
/// newest key would find everything older unreadable. Which generations get wrapped is the sharing
/// client's decision — it is the only party that can tell which ones it holds.
/// </para>
/// </remarks>
internal async Task IssueGrantAsync(
UserAccount actor,
@@ -199,10 +226,12 @@ internal sealed class VaultGrantService(
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
.ConfigureAwait(false);
var generation = (int)request.KeyGeneration;
var existing = await database.VaultKeyGrants
.SingleOrDefaultAsync(
g => g.VaultId == vault.Id
&& g.KeyGeneration == vault.KeyGeneration
&& g.KeyGeneration == generation
&& g.RecipientUserId == request.RecipientUserId
&& g.RevokedAtUtc == null,
cancellationToken)
@@ -212,7 +241,7 @@ internal sealed class VaultGrantService(
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
KeyGeneration = vault.KeyGeneration,
KeyGeneration = generation,
Kind = GrantKind.Member,
RecipientUserId = request.RecipientUserId,
CreatedAtUtc = clock.GetUtcNow(),
@@ -239,7 +268,7 @@ internal sealed class VaultGrantService(
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
TeamLog.GrantIssued(
logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id);
logger, vault.Id, generation, request.RecipientUserId, actor.Id);
}
/// <summary>
@@ -270,7 +299,11 @@ internal sealed class VaultGrantService(
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
RequireDigest(request.KeyLogHead, "key log head");
if (request.KeyGeneration != (uint)vault.KeyGeneration)
// Any generation the vault has actually reached, not only the current one — sharing a rotated
// vault means handing over its history as well as its present. A generation ahead of the
// current one is refused: nothing is sealed under it, so the grant would open nothing, and
// accepting it would let a client move the vault forward without the transaction that does so.
if (request.KeyGeneration is 0 || request.KeyGeneration > (uint)vault.KeyGeneration)
{
throw new VaultGrantInvalidException(
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
@@ -309,6 +342,170 @@ internal sealed class VaultGrantService(
}
}
/// <summary>
/// Moves a vault to a fresh key generation, wrapped to the caller.
/// </summary>
/// <returns>The vault as the caller now sees it, at the generation this call created.</returns>
/// <remarks>
/// <para>
/// <b>What the server contributes is the moment, not the key.</b> It cannot generate a vault key, tell
/// that the one it is handed differs from the old one, or check that the caller held the old one at
/// all. What it can do — and what nothing else can — is advance the generation exactly once, so two
/// admins rotating the same vault at the same time do not both walk away believing they succeeded.
/// The stale one's generation is no longer one past the current, and it is refused.
/// </para>
/// <para>
/// <b>Earlier grants are left standing.</b> They are what the remaining members read the vault's
/// history with: an item carries the generation it was sealed under, and nothing here re-encrypts
/// items — only a client holding both keys could. The departed member is cut off by the revocation
/// that removal already performed, which takes every generation they held.
/// </para>
/// <para>
/// The rekey flag is cleared here rather than when the last member is re-wrapped, because it records
/// that a membership change left the vault owing a rotation, and the rotation is this. Who still
/// needs the new key is a different question, and the grant list answers it by generation.
/// </para>
/// </remarks>
internal async Task<VaultSummary> RekeyAsync(
UserAccount actor,
Vault vault,
int permissions,
RekeyVaultRequest request,
CancellationToken cancellationToken)
{
var key = await RequireRotatableAsync(actor, vault, request, cancellationToken)
.ConfigureAwait(false);
var now = clock.GetUtcNow();
var generation = (int)request.KeyGeneration;
AddSelfGrant(actor, vault, key, generation, request, now);
vault.KeyGeneration = generation;
vault.RekeyRequired = false;
vault.RekeyReason = RekeyReason.None;
vault.UpdatedAtUtc = now;
try
{
// One SaveChanges, so the row and the grant land together. The vault's xmin concurrency
// token is what makes the generation check above binding rather than advisory: a second
// rotation that read the same generation fails here instead of overwriting this one.
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateConcurrencyException)
{
// Reported as the same refusal the pre-check gives, because it is the same situation seen a
// moment later — and a 500 about a concurrency token would tell the user nothing they could
// act on. Retrying is safe: the caller generates a fresh key and reads the generation again.
throw new VaultGrantInvalidException(
"Somebody else rotated this vault while this rotation was being recorded. Read it again "
+ "and rotate from the generation they left behind.");
}
TeamLog.VaultRekeyed(logger, vault.Id, generation, actor.Id);
var prior = await database.VaultKeyGrants
.Where(g => g.VaultId == vault.Id
&& g.RecipientUserId == actor.Id
&& g.KeyGeneration < generation
&& g.State == GrantState.Active
&& g.RevokedAtUtc == null)
.OrderBy(g => g.KeyGeneration)
.Select(g => new VaultKeyWrap((uint)g.KeyGeneration, g.WrappedKey))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return new VaultSummary(
VaultId: vault.Id,
Name: vault.Name,
IsPersonal: false,
TeamId: vault.TeamId,
KeyGeneration: request.KeyGeneration,
Permissions: permissions,
WrappedVaultKey: request.WrappedVaultKey,
RekeyRequired: false,
PriorKeyWraps: prior);
}
/// <summary>Records the rotating client's grant for the generation it has just created.</summary>
private void AddSelfGrant(
UserAccount actor,
Vault vault,
UserKey key,
int generation,
RekeyVaultRequest request,
DateTimeOffset now) =>
database.VaultKeyGrants.Add(new VaultKeyGrant
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
KeyGeneration = generation,
Kind = GrantKind.Member,
RecipientUserId = actor.Id,
RecipientKeyFingerprint = key.FingerprintSha256,
WrappedKey = request.WrappedVaultKey,
GranterUserId = actor.Id,
GranterKeyFingerprint = key.FingerprintSha256,
// No key log head, as every self-grant carries none: there is no third party whose key
// could have been substituted when you wrap something to yourself.
KeyLogHead = null,
Signature = request.GrantSignature,
State = GrantState.Active,
CreatedAtUtc = now,
});
/// <summary>
/// Everything that can be checked about a rotation before it is recorded.
/// </summary>
/// <returns>The caller's current identity key, which the new grant is filed against.</returns>
private async Task<UserKey> RequireRotatableAsync(
UserAccount actor,
Vault vault,
RekeyVaultRequest request,
CancellationToken cancellationToken)
{
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is null)
{
throw new VaultGrantInvalidException(
"Only a team vault can be rotated. A personal vault has one reader, so a rotation "
+ "would re-wrap a key to the same person and change nothing about who can read it.");
}
RequireWrappedKey(request.WrappedVaultKey);
RequireSignature(request.GrantSignature);
if (request.KeyGeneration != (uint)vault.KeyGeneration + 1)
{
throw new VaultGrantInvalidException(
$"This vault is at key generation {vault.KeyGeneration}, so the next one is "
+ $"{vault.KeyGeneration + 1} and not {request.KeyGeneration}. Read the vault again — "
+ "somebody else has rotated it since you last looked.");
}
var key = await RequireCurrentKeyAsync(actor.Id, cancellationToken).ConfigureAwait(false);
// Held now, not merely permitted. The new key has to be wrapped from the old one, and an
// account that cannot open the current generation cannot have done that — so a request from
// one is either a mistake or an attempt to strand every other member behind a key nobody has.
var holdsCurrent = await database.VaultKeyGrants
.AnyAsync(
g => g.VaultId == vault.Id
&& g.KeyGeneration == vault.KeyGeneration
&& g.RecipientUserId == actor.Id
&& g.State == GrantState.Active
&& g.RevokedAtUtc == null,
cancellationToken)
.ConfigureAwait(false);
return holdsCurrent
? key
: throw new VaultGrantInvalidException(
"You hold no key to this vault at its current generation, so you cannot rotate it. Ask "
+ "a member who does.");
}
/// <summary>
/// Withdraws a member's key grant.
/// </summary>
@@ -59,10 +59,11 @@ internal static class EndpointRegistration
typeof(ListVaultGrantsEndpoint),
typeof(IssueVaultGrantEndpoint),
typeof(RevokeVaultGrantEndpoint),
typeof(RekeyVaultEndpoint),
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Vaults — rekey, per-item ACLs
// Vaults — per-item ACLs
// Relay — tickets and the WebSocket
// Audit, Admin
});
@@ -198,6 +198,21 @@ public interface IVaultGrantApi
IssueVaultGrantRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Advances this vault to a fresh key generation, wrapped to the caller.
/// </summary>
/// <returns>The vault at its new generation, with the caller's grants for the earlier ones.</returns>
/// <remarks>
/// The key is generated by the caller and sealed to itself; the server contributes the moment it
/// takes effect, which is the one part a client cannot decide on its own. Wrapping the new
/// generation to everybody else is a separate act, and it is the caller's — see
/// <see cref="IssueVaultGrantAsync"/>.
/// </remarks>
Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws a member's key to this vault.
/// </summary>
@@ -581,6 +596,18 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
cancellationToken);
/// <inheritdoc />
public Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/rekey"),
JsonContent.Create(request, DodoSshJsonContext.Default.RekeyVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
@@ -196,5 +196,6 @@ public sealed class AccountProvisioner(
summary.KeyGeneration,
summary.Permissions,
summary.WrappedVaultKey,
summary.RekeyRequired);
summary.RekeyRequired,
summary.PriorKeyWraps);
}
+325 -11
View File
@@ -14,10 +14,58 @@ namespace DodoSSH.Client.Session;
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
/// </param>
/// <param name="Message">One line for a person. Never contains key material.</param>
/// <param name="Generations">
/// How many generations of the vault key were wrapped. One for a vault that has never been rotated;
/// more for one that has, because its older items are still sealed under the keys they were written
/// with and a recipient given only the newest would find them unreadable.
/// </param>
public sealed record ShareOutcome(
bool Shared,
RecipientVerification Verification,
string Message);
string Message,
int Generations = 0);
/// <summary>What sharing or rotating one vault did, named so a message can say which vault.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name.</param>
/// <param name="Outcome">What happened, when the attempt was made.</param>
/// <param name="Failure">
/// Why it was not, when it failed. Carried rather than thrown for the reason a per-vault sync report
/// carries its own: one unreachable vault must not stop the others, and a vault that silently did not
/// get the key is the outcome this whole design exists to make visible.
/// </param>
public sealed record VaultShareReport(
Guid VaultId,
string Name,
ShareOutcome? Outcome,
Exception? Failure)
{
/// <summary>Whether a grant was recorded for this vault.</summary>
public bool Succeeded => Outcome is { Shared: true };
}
/// <summary>What rotating one vault did.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name.</param>
/// <param name="KeyGeneration">The generation it now holds, or zero if it was not rotated.</param>
/// <param name="Shared">The members the new key was wrapped to.</param>
/// <param name="NotShared">
/// The members it was not, with the reason. A rotation that re-wrapped to nobody has locked the
/// remaining members out of everything written from now on, which they must be told rather than left
/// to discover.
/// </param>
/// <param name="Failure">Why the rotation itself did not happen, when it did not.</param>
public sealed record VaultRekeyReport(
Guid VaultId,
string Name,
uint KeyGeneration,
IReadOnlyList<Guid> Shared,
IReadOnlyList<(Guid UserId, string Reason)> NotShared,
Exception? Failure)
{
/// <summary>Whether the vault moved to a new key.</summary>
public bool Rotated => Failure is null && KeyGeneration > 0;
}
/// <summary>
/// Sharing, from the side that holds the keys.
@@ -113,6 +161,13 @@ public sealed partial class VaultSession
/// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
/// that is the only step that closes the gap, and the outcome message says so.
/// </para>
/// <para>
/// <b>Every generation this session holds is wrapped, not only the newest.</b> A rotation does not
/// re-encrypt what is already stored, so a vault that has been rotated twice holds items under three
/// keys — and a recipient handed only the current one would open the vault to find most of it
/// unreadable. This is also the only party that can do it: the server holds ciphertext it cannot
/// read, and the recipient holds nothing yet.
/// </para>
/// </remarks>
public async Task<ShareOutcome> ShareVaultAsync(
IVaultGrantApi grants,
@@ -125,7 +180,7 @@ public sealed partial class VaultSession
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
if (!keyring.TryGet(vaultId, out var vaultKey, out var keyGeneration))
if (!keyring.TryGet(vaultId, out _, out _))
{
throw new VaultUnreadableException(vaultId);
}
@@ -142,17 +197,271 @@ public sealed partial class VaultSession
}
var recipient = verification.Recipient!;
var generations = keyring.GenerationsHeld(vaultId);
await IssueAsync(grants, vaultId, vaultKey, keyGeneration, recipient, cancellationToken)
.ConfigureAwait(false);
// Oldest first, so an interruption leaves the recipient holding history without the present
// rather than the reverse. Both are incomplete; only one of them looks like a working vault
// that is quietly missing its recent items.
foreach (var generation in generations)
{
if (!keyring.TryGetAt(vaultId, generation, out var vaultKey))
{
continue;
}
await IssueAsync(grants, vaultId, vaultKey, generation, recipient, cancellationToken)
.ConfigureAwait(false);
}
return new ShareOutcome(
true,
verification,
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
+ "its own only proves this server has been consistent with itself.");
+ "its own only proves this server has been consistent with itself.",
generations.Count);
}
/// <summary>
/// Moves a vault to a fresh key and hands it to the members who are left.
/// </summary>
/// <param name="grants">The grant calls.</param>
/// <param name="directory">The directory and the key log that makes it checkable.</param>
/// <param name="vaultId">The vault to rotate.</param>
/// <param name="recipients">
/// Who should hold the new key. The caller's own id may be in here and is ignored: this session
/// wrapped the new key to itself as part of the rotation.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// <para>
/// <b>Two acts, and only the first is atomic.</b> The generation advances in one server transaction,
/// so there is no moment at which two clients disagree about which key is current. Wrapping it to
/// each remaining member is a separate call per member, each verified against the key log the same
/// way an ordinary share is — and any of them can fail. A member who was missed holds the vault's
/// history and cannot read anything written since, which the report says so the interface can too.
/// </para>
/// <para>
/// <b>What a rotation is worth, stated honestly.</b> Nothing already stored is re-encrypted — only a
/// client holding both keys could, and that is deferred work. So this does not take back what the
/// departed member already has, and it does not re-seal the vault's history against the key they may
/// have kept. What it does is make everything written from now on unreadable to them. Retroactive
/// revocation is not achievable; rotate the credentials themselves. See ADR 0001.
/// </para>
/// </remarks>
public async Task<VaultRekeyReport> RekeyVaultAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
Guid vaultId,
IReadOnlyList<Guid> recipients,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(recipients);
if (!keyring.TryGet(vaultId, out _, out var keyGeneration))
{
throw new VaultUnreadableException(vaultId);
}
var name = Vaults.FirstOrDefault(vault => vault.VaultId == vaultId)?.Name ?? "this vault";
var summary = await RotateAsync(grants, vaultId, keyGeneration, cancellationToken)
.ConfigureAwait(false);
var shared = new List<Guid>();
var missed = new List<(Guid UserId, string Reason)>();
foreach (var recipient in recipients.Distinct().Where(id => id != Profile.UserId))
{
try
{
var outcome = await ShareVaultAsync(
grants, directory, vaultId, recipient, cancellationToken)
.ConfigureAwait(false);
if (outcome.Shared)
{
shared.Add(recipient);
}
else
{
missed.Add((recipient, outcome.Message));
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// One member's key being unusable — never enrolled, rotated their identity key mid-call
// — is not a reason to leave the rest of the team without the new one.
missed.Add((recipient, exception.Message));
}
}
return new VaultRekeyReport(
vaultId, name, summary.KeyGeneration, shared, missed, Failure: null);
}
/// <summary>Generates the next vault key, records it, and takes it into the keyring.</summary>
/// <remarks>
/// The key is adopted only after the server has accepted the rotation. The other order would leave
/// this session sealing items under a generation the vault never reached, and every one of them
/// would be unreadable to everybody including its author at the next unlock.
/// </remarks>
private async Task<VaultSummary> RotateAsync(
IVaultGrantApi grants,
Guid vaultId,
uint keyGeneration,
CancellationToken cancellationToken)
{
var generation = keyGeneration + 1;
var vaultKey = VaultKeys.Create();
var now = clock.GetUtcNow();
try
{
var wrapped = VaultKeys.WrapTo(
vaultKey, bundle.EncryptionPublicKey, vaultId, generation);
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
generation,
GrantPurpose.Member,
granteeUserId: Profile.UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: fingerprint,
// Absent, as in every self-grant: there is no third party whose key could have been
// substituted when you wrap something to yourself.
keyLogHead: default,
grantedAt: now);
var summary = await grants.RekeyVaultAsync(
vaultId,
new RekeyVaultRequest(
KeyGeneration: generation,
WrappedVaultKey: wrapped,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now),
cancellationToken)
.ConfigureAwait(false);
var stored = ToStored(summary);
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return summary;
}
catch
{
// Never reached the keyring, so this is the only thing that can release it.
CryptographicOperations.ZeroMemory(vaultKey);
throw;
}
}
/// <summary>
/// Hands every team vault this session can open to one member.
/// </summary>
/// <returns>One report per vault, in the order they were attempted.</returns>
/// <remarks>
/// What "adding somebody to a team" means in full. Membership is a server-side authorization change
/// and takes effect at once; a key is a cryptographic act only a machine holding one can perform, so
/// this is the half that has to happen here. A vault this session cannot open is skipped rather than
/// failed — somebody else holds its key, and this client has nothing to wrap.
/// </remarks>
public async Task<IReadOnlyList<VaultShareReport>> ShareTeamVaultsAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
Guid teamId,
Guid recipientUserId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
var reports = new List<VaultShareReport>();
foreach (var vault in TeamVaults(teamId))
{
try
{
var outcome = await ShareVaultAsync(
grants, directory, vault.VaultId, recipientUserId, cancellationToken)
.ConfigureAwait(false);
reports.Add(new VaultShareReport(vault.VaultId, vault.Name, outcome, null));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
reports.Add(new VaultShareReport(vault.VaultId, vault.Name, null, exception));
}
}
return reports;
}
/// <summary>
/// Rotates every team vault this session can open, handing each new key to the members who remain.
/// </summary>
/// <returns>One report per vault, in the order they were attempted.</returns>
/// <remarks>
/// What "removing somebody from a team" means in full, and the reason it is per vault rather than
/// per team: a key belongs to a vault, and a client can only rotate the ones it can currently open.
/// A vault it cannot is left alone and stays flagged for rekey, which is the honest state — somebody
/// who holds its key has to finish the job.
/// </remarks>
public async Task<IReadOnlyList<VaultRekeyReport>> RekeyTeamVaultsAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
Guid teamId,
IReadOnlyList<Guid> recipients,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(recipients);
var reports = new List<VaultRekeyReport>();
foreach (var vault in TeamVaults(teamId))
{
try
{
reports.Add(
await RekeyVaultAsync(
grants, directory, vault.VaultId, recipients, cancellationToken)
.ConfigureAwait(false));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
reports.Add(new VaultRekeyReport(
vault.VaultId, vault.Name, KeyGeneration: 0, [], [], exception));
}
}
return reports;
}
/// <summary>The team's vaults this session actually holds a current key for.</summary>
/// <remarks>
/// Materialised before the loops above use it, because both of them write to <see cref="Vaults"/>
/// through the vault store — and a rotation part-way through a lazily evaluated sequence would be
/// enumerating a list that has been replaced underneath it.
/// </remarks>
private List<StoredVault> TeamVaults(Guid teamId) =>
[.. Vaults.Where(vault => vault.TeamId == teamId && keyring.CanRead(vault.VaultId))];
/// <summary>
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
/// </summary>
@@ -179,14 +488,18 @@ public sealed partial class VaultSession
foreach (var vault in Vaults)
{
if (keyring.CanRead(vault.VaultId))
{
continue;
}
// Attempted even for a vault that already opens, because the answer can have grown: a
// rotated vault arrives with a new current generation, and a vault shared by somebody who
// holds more of its history arrives with wraps this session did not have. Admitting is
// idempotent, so the only thing an unconditional call costs is the unwrap it skips.
var readable = keyring.CanRead(vault.VaultId);
if (keyring.TryAdmit(bundle, vault))
{
admitted++;
if (!readable)
{
admitted++;
}
}
else
{
@@ -289,5 +602,6 @@ public sealed partial class VaultSession
summary.KeyGeneration,
summary.Permissions,
summary.WrappedVaultKey,
summary.RekeyRequired);
summary.RekeyRequired,
summary.PriorKeyWraps);
}
@@ -607,33 +607,134 @@ internal sealed partial class TeamsViewModel(
InviteEmail = string.Empty;
// Before the reload, so the vault list this screen redraws already shows what they can
// open. The sharing is what makes the membership worth anything, and doing it here rather
// than leaving a SHARE KEY button to be pressed is the difference between adding a
// colleague and adding a colleague who then waits for somebody to notice.
var shared = await ShareWithAsync(server, team, member, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = Describe(member);
Status = Describe(member, shared);
}).ConfigureAwait(true);
}
/// <summary>
/// Wraps every team vault this machine can open to somebody who has just been added.
/// </summary>
/// <returns>What to tell the user about the keys, or null when there was nothing to say.</returns>
/// <remarks>
/// <para>
/// Skipped outright for an account with no identity key: there is nothing to wrap to, and a
/// refusal per vault would bury that one fact under a list. Their row says so, and adding them was
/// still worth doing.
/// </para>
/// <para>
/// A failure here is reported and never thrown. The membership has already been recorded on the
/// server and is not undone by a key that could not be wrapped — so the honest outcome is "they are
/// in the team, and this vault still needs sharing", which is a state somebody can act on.
/// </para>
/// </remarks>
private async Task<string?> ShareWithAsync(
IVaultServer server,
TeamRowViewModel team,
TeamMemberSummary member,
CancellationToken cancellationToken)
{
if (!member.IsEnrolled)
{
return null;
}
if (session() is not { } open)
{
// Distinguished from holding no keys, because the two lead somewhere different: this one is
// fixed by unlocking, and the other by asking somebody who holds the vault.
return "Nothing was shared with them — a vault key is wrapped on an unlocked machine, and "
+ "this keychain is locked.";
}
var reports = await open
.ShareTeamVaultsAsync(
server.Grants, server.Directory, team.TeamId, member.UserId, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
{
return null;
}
var shared = reports.Where(report => report.Succeeded).ToList();
var refused = reports.Where(report => !report.Succeeded).ToList();
var sentence = shared.Count > 0
? $"Shared {VaultCount(shared.Count)} with them: {Join(shared.Select(r => r.Name))}."
: null;
if (refused.Count == 0)
{
return sentence;
}
// Named one by one rather than counted. Each of these is a vault somebody now expects them to
// be able to open, and which one it is decides who has to fix it.
var reasons = refused.Select(report =>
$"'{report.Name}' ({report.Failure?.Message ?? report.Outcome?.Message})");
return (sentence is null ? string.Empty : sentence + " ")
+ $"Could not share {Join(reasons)}.";
}
/// <summary>
/// What just happened to the account that was added, and what is still owed them.
/// </summary>
/// <remarks>
/// Both branches say out loud that nothing readable was granted, because the single most common
/// misunderstanding this design invites is that adding somebody gave them the vault. The unenrolled
/// branch says more, and has to: their row will sit in the list saying it holds no key, and without
/// this somebody would read that as the addition having half-failed rather than as a colleague who
/// has not finished setting their machine up. It is also the one case where SHARE KEY cannot be the
/// next step, so pointing at it would be pointing at a button that will refuse.
/// <para>
/// The enrolled branch reports what the keys did, because that is the half of "adding somebody"
/// that this machine performs and the half that can partly fail. A vault that could not be wrapped
/// is named there rather than left to be noticed when they say they cannot open it.
/// </para>
/// <para>
/// The unenrolled branch says more, and has to: their row will sit in the list saying it holds no
/// key, and without this somebody would read that as the addition having half-failed rather than as
/// a colleague who has not finished setting their machine up. Nothing was shared with them and
/// nothing could have been — there is no key to wrap to — so the membership is all there is yet.
/// </para>
/// </remarks>
private static string Describe(TeamMemberSummary member)
private static string Describe(TeamMemberSummary member, string? shared)
{
var who = member.Email ?? member.DisplayName ?? "the account";
return member.IsEnrolled
? $"Added {who} as a member. They cannot read anything yet — select a vault below and "
+ "share its key."
: $"Added {who} as a member. They have no key yet, so their row says so and no vault can "
+ "be shared with them until they finish signing in on their own machine. The "
if (!member.IsEnrolled)
{
return $"Added {who} as a member. They have no key yet, so their row says so and no vault "
+ "can be shared with them until they finish signing in on their own machine. The "
+ "membership is real in the meantime.";
}
return shared is null
? $"Added {who} as a member. This machine holds no team vault key to give them — select a "
+ "vault below and press SHARE KEY from one that does."
: $"Added {who} as a member. {shared}";
}
/// <summary>"1 vault" or "3 vaults", for a sentence that has to read either way.</summary>
private static string VaultCount(int count) =>
string.Create(CultureInfo.CurrentCulture, $"{count} vault{(count == 1 ? string.Empty : "s")}");
/// <summary>Joins names into a phrase a person would say, rather than a comma-separated list.</summary>
private static string Join(IEnumerable<string> parts)
{
var list = parts.ToList();
return list.Count switch
{
0 => string.Empty,
1 => list[0],
2 => $"{list[0]} and {list[1]}",
_ => string.Join(", ", list.Take(list.Count - 1)) + " and " + list[^1],
};
}
/// <summary>
@@ -917,7 +1018,14 @@ internal sealed partial class TeamsViewModel(
}).ConfigureAwait(true);
}
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
/// <summary>
/// Removes a member, revoking their grants and rotating the vaults they could read.
/// </summary>
/// <remarks>
/// The removal and the rotation are separate acts and only the first is the server's. Nothing here
/// undoes the removal if the rotation fails, and nothing waits for it: the membership change is what
/// stops them fetching anything more, and it has already happened by then.
/// </remarks>
[RelayCommand]
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
{
@@ -928,22 +1036,110 @@ internal sealed partial class TeamsViewModel(
return;
}
// Read before the removal, because afterwards this list no longer contains them — and it is the
// list of who the new key goes to.
var remaining = Members
.Where(row => row.UserId != member.UserId)
.Select(row => row.UserId)
.ToList();
await RunAsync(async () =>
{
await server.Teams
.RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
.ConfigureAwait(true);
var rotated = await RotateAfterRemovalAsync(server, team, remaining, 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.";
// and a message implying otherwise is the one thing this screen must not say. The rotation
// is described in the same breath for the same reason — it decides what happens next, not
// what already happened.
Status = $"Removed {member.Name}. {rotated} Anything they had already downloaded is still "
+ "on their machine — rotate the credentials that matter.";
}).ConfigureAwait(true);
}
/// <summary>
/// Rotates every team vault this machine can open, handing each new key to the members who remain.
/// </summary>
/// <returns>What to tell the user about the keys. Never null — something always happened.</returns>
/// <remarks>
/// A vault this machine cannot open is not rotated and is not counted as a failure here: its key
/// belongs to somebody else, the server has flagged it as owing a rekey, and the vault row says so
/// until one of them does it.
/// </remarks>
private async Task<string> RotateAfterRemovalAsync(
IVaultServer server,
TeamRowViewModel team,
IReadOnlyList<Guid> remaining,
CancellationToken cancellationToken)
{
if (session() is not { } open)
{
return "Their key grants are withdrawn, so they can fetch nothing more. Unlock your "
+ "keychain to rotate the vault keys themselves.";
}
var reports = await open
.RekeyTeamVaultsAsync(
server.Grants, server.Directory, team.TeamId, remaining, cancellationToken)
.ConfigureAwait(true);
if (reports.Count == 0)
{
return "Their key grants are withdrawn, so they can fetch nothing more. This machine holds "
+ "no key to any of this team's vaults, so there was nothing here to rotate.";
}
var rotated = reports.Where(report => report.Rotated).ToList();
var failed = reports.Where(report => !report.Rotated).ToList();
var sentences = new List<string>();
if (rotated.Count > 0)
{
// Says what a rotation is and is not worth, because the word promises more than it can
// deliver: from here on they cannot read this vault, and what is already in it was sealed
// under the key they used to hold.
sentences.Add(
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
+ "written from now on is readable to them.");
// The members who did not get the new key. They are still in the team and can still write,
// but until somebody wraps it to them they will find the vault stops updating.
// Distinct by id rather than by name, because two accounts can share a display name and
// collapsing them would tell somebody one person is owed a key when two are.
var missed = rotated
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
.Distinct()
.Select(Name)
.ToList();
if (missed.Count > 0)
{
sentences.Add(
$"The new key did not reach {Join(missed)} — press SHARE KEY for them, or they "
+ "will stop seeing changes.");
}
}
if (failed.Count > 0)
{
sentences.Add(
$"Could not rotate {Join(failed.Select(r => $"'{r.Name}' ({r.Failure?.Message})"))}.");
}
return string.Join(" ", sentences);
}
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
private string Name(Guid userId) =>
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
/// <summary>Opens the name-a-vault form, aimed at the selected team.</summary>
[RelayCommand]
private void NewVault() => ArmNewVault(SelectedTeam?.TeamId);
@@ -1196,8 +1392,16 @@ internal sealed partial class TeamsViewModel(
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
.ConfigureAwait(true);
// The generation count is said out loud when there is more than one, because it is the
// answer to a question somebody will have about a rotated vault: whether the person they
// just shared it with can see what was in it before the rotation.
var history = outcome.Generations > 1
? $" All {outcome.Generations} generations of the key were wrapped, so they can read "
+ "what was in the vault before it was last rotated."
: string.Empty;
Status = outcome.Shared
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}{history}"
: $"Did not share '{vault.Name}': {outcome.Message}";
}).ConfigureAwait(true);
}
@@ -7025,7 +7025,12 @@ internal sealed partial class VaultViewModel(
if (report.RekeyRequired)
{
notes.Add("this keychain was rekeyed and your access needs re-issuing");
// What is readable and what is not, because the two differ and the difference is the whole
// of what somebody in this state needs to know: the keys they hold still open everything
// written before the rotation, and nothing written since.
notes.Add(
"this keychain was rekeyed — you can still read what was here, and need the new key "
+ "before you can see anything written since");
}
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
+25
View File
@@ -159,6 +159,31 @@ internal sealed class CachedVaultRow
public DateTimeOffset UpdatedAtUtc { get; set; }
}
/// <summary>
/// A vault key this user holds for a generation the vault has moved past.
/// </summary>
/// <remarks>
/// <para>
/// A table rather than a column, because there is one of these per rotation and the vault row has one
/// of everything else. The current generation's wrap stays on <see cref="CachedVaultRow"/>: it is what
/// unlocking needs, and burying it in a child table would make the common case the awkward one.
/// </para>
/// <para>
/// Cached for the reason the current wrap is. An item keeps the generation it was sealed under, so a
/// machine that came back from a rotation with only the newest key would read everything written
/// before it as corrupt — offline, with no way to ask for the rest.
/// </para>
/// </remarks>
internal sealed class CachedVaultKeyWrapRow
{
public Guid VaultId { get; set; }
public uint KeyGeneration { get; set; }
/// <summary>The vault key at this generation, sealed to this user's X25519 key.</summary>
public byte[] WrappedKey { get; set; } = [];
}
/// <summary>
/// The last state of an item that the server confirmed.
/// </summary>
@@ -123,7 +123,8 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
entity.Property(row => row.SealedRefreshToken).IsRequired();
});
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
private static void ConfigureVaults(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CachedVaultRow>(entity =>
{
entity.ToTable("vault");
@@ -132,6 +133,18 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
entity.Property(row => row.Name).IsRequired();
});
// No foreign key to the vault row, deliberately. The two are written by the same store in the
// same call, and a cascade would make "which of these two tables is authoritative" a question
// the schema answers rather than the code — while buying nothing, since a wrap for a vault this
// machine can no longer see is removed by the same pass that removes the vault.
modelBuilder.Entity<CachedVaultKeyWrapRow>(entity =>
{
entity.ToTable("vault_key_wrap");
entity.HasKey(row => new { row.VaultId, row.KeyGeneration });
entity.Property(row => row.WrappedKey).IsRequired();
});
}
private static void ConfigureItems(ModelBuilder modelBuilder) =>
modelBuilder.Entity<CachedItemRow>(entity =>
{
@@ -0,0 +1,465 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20260803202241_AddVaultKeyWrapHistory")]
partial class AddVaultKeyWrapHistory
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("Hidden")
.HasColumnType("INTEGER")
.HasColumnName("hidden");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<Guid?>("DeviceId")
.HasColumnType("TEXT")
.HasColumnName("device_id");
b.Property<byte[]>("DeviceWrappedPrivateKey")
.HasColumnType("BLOB")
.HasColumnName("device_wrapped_private_key");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,35 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddVaultKeyWrapHistory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "vault_key_wrap",
columns: table => new
{
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
wrapped_key = table.Column<byte[]>(type: "BLOB", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_vault_key_wrap", x => new { x.vault_id, x.key_generation });
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "vault_key_wrap");
}
}
}
@@ -83,6 +83,27 @@ namespace DodoSSH.Client.Storage.Migrations
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
+7 -1
View File
@@ -71,6 +71,11 @@ public sealed record StoredUnlockMaterial(
/// <param name="Permissions">Effective permissions, as a flags value.</param>
/// <param name="WrappedVaultKey">The vault key sealed to this user. Null while awaiting re-wrap.</param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
/// <param name="PriorKeyWraps">
/// The same key at every generation before <paramref name="KeyGeneration"/> that this user still holds
/// a grant for. Empty for a vault that has never been rotated, and what makes one that has readable
/// back to its first item.
/// </param>
public sealed record StoredVault(
Guid VaultId,
string Name,
@@ -79,7 +84,8 @@ public sealed record StoredVault(
uint KeyGeneration,
int Permissions,
byte[]? WrappedVaultKey,
bool RekeyRequired)
bool RekeyRequired,
IReadOnlyList<VaultKeyWrap>? PriorKeyWraps = null)
{
/// <summary>
/// The <c>Write</c> bit of <see cref="Permissions"/>.
+91 -4
View File
@@ -1,3 +1,4 @@
using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
@@ -25,7 +26,9 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return [.. rows.Select(ToStored)];
var wraps = await ReadWrapsAsync(context, cancellationToken).ConfigureAwait(false);
return [.. rows.Select(row => ToStored(row, wraps.GetValueOrDefault(row.VaultId, [])))];
}
/// <summary>Reads one vault.</summary>
@@ -39,7 +42,19 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
if (row is null)
{
return null;
}
var wraps = await context.Set<CachedVaultKeyWrapRow>()
.AsNoTracking()
.Where(w => w.VaultId == vaultId)
.OrderBy(w => w.KeyGeneration)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return ToStored(row, [.. wraps.Select(ToWrap)]);
}
/// <summary>
@@ -81,10 +96,20 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
}
Apply(row, vault, now);
await ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);
}
context.RemoveRange(existing.Values);
// The wraps of a vault that is gone from the list go with it. They are keys to something this
// machine can no longer fetch, and keeping them would be keeping key material for a vault the
// user has been told they no longer have.
foreach (var dropped in existing.Keys)
{
await RemoveWrapsAsync(context, dropped, cancellationToken).ConfigureAwait(false);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
@@ -116,6 +141,8 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
Apply(row, vault, clock.GetUtcNow());
await ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
@@ -182,7 +209,66 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
row.UpdatedAtUtc = now;
}
private static StoredVault ToStored(CachedVaultRow row) =>
/// <summary>
/// Replaces one vault's earlier-generation wraps with what the server reported.
/// </summary>
/// <remarks>
/// Deleted and re-inserted rather than merged. There are a handful of these per vault at most, the
/// server's list is authoritative, and a merge would have to decide what a wrap present here and
/// absent there means — which is "that grant was revoked", and the answer to that is to drop it.
/// </remarks>
private static async Task ApplyWrapsAsync(
ClientCacheContext context,
StoredVault vault,
CancellationToken cancellationToken)
{
await RemoveWrapsAsync(context, vault.VaultId, cancellationToken).ConfigureAwait(false);
foreach (var wrap in vault.PriorKeyWraps ?? [])
{
context.Add(new CachedVaultKeyWrapRow
{
VaultId = vault.VaultId,
KeyGeneration = wrap.KeyGeneration,
WrappedKey = wrap.WrappedKey,
});
}
}
private static async Task RemoveWrapsAsync(
ClientCacheContext context,
Guid vaultId,
CancellationToken cancellationToken)
{
var stale = await context.Set<CachedVaultKeyWrapRow>()
.Where(w => w.VaultId == vaultId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
context.RemoveRange(stale);
}
private static async Task<Dictionary<Guid, IReadOnlyList<VaultKeyWrap>>> ReadWrapsAsync(
ClientCacheContext context,
CancellationToken cancellationToken)
{
var rows = await context.Set<CachedVaultKeyWrapRow>()
.AsNoTracking()
.OrderBy(row => row.KeyGeneration)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows
.GroupBy(row => row.VaultId)
.ToDictionary(
group => group.Key,
group => (IReadOnlyList<VaultKeyWrap>)[.. group.Select(ToWrap)]);
}
private static VaultKeyWrap ToWrap(CachedVaultKeyWrapRow row) =>
new(row.KeyGeneration, row.WrappedKey);
private static StoredVault ToStored(CachedVaultRow row, IReadOnlyList<VaultKeyWrap> priorWraps) =>
new(
row.VaultId,
row.Name,
@@ -191,5 +277,6 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
row.KeyGeneration,
row.Permissions,
row.WrappedVaultKey,
row.RekeyRequired);
row.RekeyRequired,
priorWraps);
}
+27 -8
View File
@@ -144,14 +144,19 @@ internal sealed class ItemReconciler<TSecret>(
{
ArgumentNullException.ThrowIfNull(pending);
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation) || pending.Payload is null)
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|| pending.Payload is null
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey))
{
return "This item has no usable vault key.";
}
// Opened under the generation it was queued at and re-sealed under the current one. Those
// differ whenever a rotation lands between an offline edit and its push, and re-sealing is
// the point: what goes back to the server has to be readable by everybody holding the new key.
var local = kind.TryOpen(
pending.Payload,
vaultKey.Span,
queuedKey.Span,
pending.EntityId,
SyncVersions.NextVersion(pending.ExpectedVersion));
@@ -253,8 +258,16 @@ internal sealed class ItemReconciler<TSecret>(
var (local, remoteSecret, vaultKey, generation) = opened.Value;
var ancestor = kind.TryOpen(
pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version);
// The ancestor is the version the server last confirmed, so it carries its own generation —
// typically the oldest of the three when a rotation has happened since.
var ancestor =
keyring.TryGetAt(vaultId, pending.Ancestor.Payload.KeyGeneration, out var ancestorKey)
? kind.TryOpen(
pending.Ancestor.Payload,
ancestorKey.Span,
remote.EntityId,
pending.Ancestor.Version)
: null;
if (ancestor is null)
{
@@ -313,10 +326,11 @@ internal sealed class ItemReconciler<TSecret>(
}
var local = pending.Payload is null
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
? null
: kind.TryOpen(
pending.Payload,
vaultKey.Span,
queuedKey.Span,
remote.EntityId,
SyncVersions.NextVersion(pending.ExpectedVersion));
@@ -419,21 +433,26 @@ internal sealed class ItemReconciler<TSecret>(
{
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|| pending.Payload is null
|| remote.Payload is null)
|| remote.Payload is null
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
|| !keyring.TryGetAt(vaultId, remote.Payload.KeyGeneration, out var remoteKey))
{
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
.ConfigureAwait(false);
return null;
}
// Each side under its own generation. The two genuinely differ after a rotation: what the
// server holds was sealed before it, and the queued edit after — or the other way round, for a
// client that rotated while this one was offline.
var local = kind.TryOpen(
pending.Payload,
vaultKey.Span,
queuedKey.Span,
remote.EntityId,
SyncVersions.NextVersion(pending.ExpectedVersion));
var remoteSecret = kind.TryOpen(
remote.Payload, vaultKey.Span, remote.EntityId, remote.Version);
remote.Payload, remoteKey.Span, remote.EntityId, remote.Version);
if (local is null || remoteSecret is null)
{
+66 -35
View File
@@ -82,7 +82,10 @@ internal sealed class VaultItemRepository<TSecret>(
Guid vaultId,
CancellationToken cancellationToken)
{
if (!keyring.TryGet(vaultId, out var vaultKey, out _))
// TryGet rather than CanRead, which answers false for a disposed keyring where this has to
// throw: a locked session being read from is a caller holding something it should have let go
// of, and the exception is what says so.
if (!keyring.TryGet(vaultId, out _, out _))
{
throw new VaultUnreadableException(vaultId);
}
@@ -104,31 +107,17 @@ internal sealed class VaultItemRepository<TSecret>(
{
if (pendingByEntity.Remove(item.EntityId, out var local))
{
AddPending(listed, ref unreadable, vaultKey, local);
AddPending(listed, ref unreadable, vaultId, local);
continue;
}
if (item.IsDeleted || item.Payload is null)
{
continue;
}
var opened = kind.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version);
if (opened is null)
{
unreadable++;
continue;
}
listed.Add(new VaultItem<TSecret>(
item.EntityId, opened.Secret, item.Version, false, false, opened.IsReadOnly));
AddMirrored(listed, ref unreadable, vaultId, item);
}
// Whatever is left has no mirror row yet: items created here and not yet accepted.
foreach (var local in pendingByEntity.Values)
{
AddPending(listed, ref unreadable, vaultKey, local);
AddPending(listed, ref unreadable, vaultId, local);
}
return new ItemListing<TSecret>(listed, unreadable);
@@ -211,7 +200,7 @@ internal sealed class VaultItemRepository<TSecret>(
// the payload was sealed at, which is why the pending and mirror cases differ: a pending payload
// holds the version the server will assign, and a mirror row holds the one it has.
var before = IsAudited
? Open(vaultKey, entityId, pending, ancestor)
? Open(vaultId, entityId, pending, ancestor)
: null;
await outbox.QueueAsync(
@@ -327,15 +316,10 @@ internal sealed class VaultItemRepository<TSecret>(
PendingOperation? pending,
CancellationToken cancellationToken)
{
if (!keyring.TryGet(vaultId, out var vaultKey, out _))
{
return null;
}
var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken)
.ConfigureAwait(false);
return Open(vaultKey, entityId, pending, ancestor)?.Label;
return Open(vaultId, entityId, pending, ancestor)?.Label;
}
/// <summary>
@@ -348,23 +332,29 @@ internal sealed class VaultItemRepository<TSecret>(
/// at the version the server <em>will</em> assign, and a mirror row holds the one it has.
/// </remarks>
private TSecret? Open(
ReadOnlyMemory<byte> vaultKey,
Guid vaultId,
Guid entityId,
PendingOperation? pending,
StoredAncestor? ancestor)
{
if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued })
{
return kind.TryOpen(
queued,
vaultKey.Span,
entityId,
SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret;
return keyring.TryGetAt(vaultId, queued.KeyGeneration, out var queuedKey)
? kind.TryOpen(
queued,
queuedKey.Span,
entityId,
SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret
: null;
}
return ancestor is null
? null
: kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
if (ancestor is null
|| !keyring.TryGetAt(vaultId, ancestor.Payload.KeyGeneration, out var vaultKey))
{
return null;
}
return kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
}
/// <summary>
@@ -401,10 +391,43 @@ internal sealed class VaultItemRepository<TSecret>(
}
}
/// <summary>Adds one row of the server's mirror to a listing, or counts it as unreadable.</summary>
private void AddMirrored(
List<VaultItem<TSecret>> listed,
ref int unreadable,
Guid vaultId,
StoredItem item)
{
if (item.IsDeleted || item.Payload is null)
{
return;
}
// The generation the item names, not the vault's current one. A rotated vault holds items
// written under two or three keys at once, and a list that assumed the newest would report
// everything older as unreadable.
if (!keyring.TryGetAt(vaultId, item.Payload.KeyGeneration, out var vaultKey))
{
unreadable++;
return;
}
var opened = kind.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version);
if (opened is null)
{
unreadable++;
return;
}
listed.Add(new VaultItem<TSecret>(
item.EntityId, opened.Secret, item.Version, false, false, opened.IsReadOnly));
}
private void AddPending(
List<VaultItem<TSecret>> listed,
ref int unreadable,
ReadOnlyMemory<byte> vaultKey,
Guid vaultId,
PendingOperation local)
{
if (local.Operation == SyncOperation.Delete)
@@ -419,6 +442,14 @@ internal sealed class VaultItemRepository<TSecret>(
return;
}
// A queued change is sealed under whatever generation was current when it was queued, which is
// not necessarily the current one: a rotation can land between an offline edit and its push.
if (!keyring.TryGetAt(vaultId, local.Payload.KeyGeneration, out var vaultKey))
{
unreadable++;
return;
}
var version = SyncVersions.NextVersion(local.ExpectedVersion);
var opened = kind.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version);
+187 -23
View File
@@ -15,6 +15,13 @@ namespace DodoSSH.Client.Sync;
/// "the keys exist only while unlocked" a property of the code and not of everyone's discipline.
/// </para>
/// <para>
/// <b>A vault has a key per generation, and this holds every one it was granted.</b> A rotation does not
/// re-encrypt what is already stored — each item keeps the generation it was sealed under — so reading a
/// rotated vault means opening items under two or three different keys, chosen per item rather than per
/// vault. Writing uses the newest, which is what <see cref="TryGet"/> answers; reading an item asks for
/// the generation that item names, which is <see cref="TryGetAt"/>.
/// </para>
/// <para>
/// A grant that will not open is not an error: it means the vault has been rekeyed and this client's
/// grant has not been re-wrapped yet, or the grant was fabricated. Both leave the vault temporarily
/// unreadable and both are reported rather than thrown, so one bad grant does not take the other vaults
@@ -23,7 +30,7 @@ namespace DodoSSH.Client.Sync;
/// </remarks>
public sealed class VaultKeyring : IDisposable
{
private readonly Dictionary<Guid, byte[]> keys = [];
private readonly Dictionary<Guid, Dictionary<uint, byte[]>> keys = [];
private readonly Dictionary<Guid, uint> generations = [];
private bool disposed;
@@ -51,6 +58,12 @@ public sealed class VaultKeyring : IDisposable
{
foreach (var vault in vaults)
{
// The history first, and never conditional on the current generation opening. A member
// who has been rotated past but not yet re-wrapped can still read everything written
// before the rotation, and dropping those keys because the newest grant is missing
// would turn "you cannot see the last hour's changes" into "the vault is empty".
keyring.OpenPriorWraps(bundle, vault);
if (vault.WrappedVaultKey is null)
{
// The server said so itself: a grant awaiting re-wrap after a rekey.
@@ -70,8 +83,7 @@ public sealed class VaultKeyring : IDisposable
continue;
}
keyring.keys[vault.VaultId] = key;
keyring.generations[vault.VaultId] = vault.KeyGeneration;
keyring.Adopt(vault.VaultId, key, vault.KeyGeneration);
}
keyring.Unopened = unopened;
@@ -94,26 +106,49 @@ public sealed class VaultKeyring : IDisposable
/// </param>
/// <param name="keyGeneration">The generation this key is for.</param>
/// <remarks>
/// Creating a team vault is the only case: the client generates the key, wraps it to itself and
/// sends the wrap, so the plaintext is already here and unwrapping the server's copy back would be
/// a round trip to learn something this process just chose. Adopting it also means the new vault is
/// usable immediately rather than at the next unlock, which is what somebody who just pressed
/// "create" expects.
/// <para>
/// Two cases, and they are the same operation: creating a team vault, and rotating one. Both
/// generate the key here, wrap it to this user and send the wrap, so the plaintext is already in
/// this process and unwrapping the server's copy back would be a round trip to learn something it
/// just chose. Adopting it also means the vault is usable immediately rather than at the next
/// unlock, which is what somebody who has just pressed a button expects.
/// </para>
/// <para>
/// This generation becomes the one writes are sealed under. A key for a generation the vault has
/// moved <em>past</em> goes in through <see cref="AdoptPrior"/> instead, which is not the same
/// operation: it makes old items readable and must not walk the write target backwards.
/// </para>
/// </remarks>
public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(vaultKey);
if (keys.TryGetValue(vaultId, out var previous))
{
CryptographicOperations.ZeroMemory(previous);
}
Store(vaultId, vaultKey, keyGeneration);
keys[vaultId] = vaultKey;
generations[vaultId] = keyGeneration;
Promote(vaultId, keyGeneration);
}
Unopened = [.. Unopened.Where(id => id != vaultId)];
/// <summary>
/// Takes a vault key for a generation the vault has already moved past.
/// </summary>
/// <param name="vaultId">The vault.</param>
/// <param name="vaultKey">
/// The plaintext key. <b>The keyring takes ownership</b>, exactly as <see cref="Adopt"/> does.
/// </param>
/// <param name="keyGeneration">The superseded generation this key opens.</param>
/// <remarks>
/// Holding one of these is what lets a rotated vault be read at all: items are not re-encrypted by a
/// rotation, so everything written before it is still sealed under the key it was written with.
/// Nothing is ever <em>written</em> under one, which is why this does not touch the current
/// generation and does not make an otherwise unreadable vault readable.
/// </remarks>
public void AdoptPrior(Guid vaultId, byte[] vaultKey, uint keyGeneration)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(vaultKey);
Store(vaultId, vaultKey, keyGeneration);
}
/// <summary>
@@ -132,13 +167,23 @@ public sealed class VaultKeyring : IDisposable
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(vault);
// Attempted whatever happens to the current generation, and before it. A share of a vault that
// has been rotated since it was created arrives as a current wrap plus its history, and the
// history is not a consolation prize — without it the recipient sees a vault full of items that
// will not decrypt.
OpenPriorWraps(bundle, vault);
if (vault.WrappedVaultKey is null)
{
return false;
}
if (keys.ContainsKey(vault.VaultId) && generations[vault.VaultId] == vault.KeyGeneration)
if (Held(vault.VaultId, vault.KeyGeneration) is not null)
{
// Already open at this generation. Promoted rather than returned early, because a vault
// that was rotated and re-granted arrives here with a generation this keyring has been
// treating as historic, and it is now the one writes belong under.
Promote(vault.VaultId, vault.KeyGeneration);
return true;
}
@@ -157,14 +202,26 @@ public sealed class VaultKeyring : IDisposable
/// <summary>Records that a vault cannot be read, so the interface can say so.</summary>
/// <remarks>
/// <para>
/// The counterpart of <see cref="TryAdmit"/> for the case where the grant did not open. Kept
/// explicit rather than inferred from the absence of a key, because "no key" is also what a vault
/// this session has never heard of looks like.
/// </para>
/// <para>
/// <b>It also gives up the write target, and that is the load-bearing half.</b> The usual way to
/// reach here is another client having rotated the vault: this session still holds the previous
/// generation's key and it is no longer the current one. Going on treating it as current would seal
/// new items under a superseded key — readable here, unreadable to everybody else, and with nothing
/// to show the author that anything was wrong. The keys themselves are kept, because the items
/// already written under them are still readable through <see cref="TryGetAt"/>.
/// </para>
/// </remarks>
public void MarkUnreadable(Guid vaultId)
{
ObjectDisposedException.ThrowIf(disposed, this);
generations.Remove(vaultId);
if (!Unopened.Contains(vaultId))
{
Unopened = [.. Unopened, vaultId];
@@ -172,20 +229,27 @@ public sealed class VaultKeyring : IDisposable
}
/// <summary>
/// Borrows a vault's key.
/// Borrows a vault's current key: the one new items are sealed under.
/// </summary>
/// <remarks>
/// The returned memory is the keyring's own buffer, not a copy, and is zeroed when the keyring is
/// disposed. Callers must not retain it past the operation they borrowed it for.
/// <para>
/// False for a vault this session holds only the history of — one rotated past a grant that has not
/// been re-wrapped yet. That is deliberate: writing under a superseded key would produce an item
/// nobody else could read, and the honest answer is that the vault is not writable until the new
/// key arrives.
/// </para>
/// </remarks>
public bool TryGet(Guid vaultId, out ReadOnlyMemory<byte> vaultKey, out uint keyGeneration)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (keys.TryGetValue(vaultId, out var key))
if (generations.TryGetValue(vaultId, out var current)
&& Held(vaultId, current) is { } key)
{
vaultKey = key;
keyGeneration = generations[vaultId];
keyGeneration = current;
return true;
}
@@ -194,8 +258,52 @@ public sealed class VaultKeyring : IDisposable
return false;
}
/// <summary>Whether this vault can be read at all.</summary>
public bool CanRead(Guid vaultId) => !disposed && keys.ContainsKey(vaultId);
/// <summary>
/// Borrows the key one particular generation of a vault was sealed under.
/// </summary>
/// <param name="vaultId">The vault.</param>
/// <param name="keyGeneration">The generation the item names.</param>
/// <param name="vaultKey">The key, borrowed on the same terms as <see cref="TryGet"/>.</param>
/// <returns>Whether this session holds that generation.</returns>
/// <remarks>
/// What every read goes through, because an item names the generation it was sealed under and a
/// rotated vault holds items from more than one. False means that item is unreadable here and says
/// nothing about the rest of the vault — which is why a caller counts it rather than failing.
/// </remarks>
public bool TryGetAt(Guid vaultId, uint keyGeneration, out ReadOnlyMemory<byte> vaultKey)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (Held(vaultId, keyGeneration) is { } key)
{
vaultKey = key;
return true;
}
vaultKey = default;
return false;
}
/// <summary>
/// Every generation of one vault's key that this session holds, oldest first.
/// </summary>
/// <remarks>
/// Read when sharing: a recipient given only the newest key would find the vault's history
/// undecryptable, so the sharing client wraps each of these in turn. It is the only party that can
/// — the server holds ciphertext, and the recipient holds nothing yet.
/// </remarks>
public IReadOnlyList<uint> GenerationsHeld(Guid vaultId)
{
ObjectDisposedException.ThrowIf(disposed, this);
return keys.TryGetValue(vaultId, out var held) ? [.. held.Keys.Order()] : [];
}
/// <summary>Whether this vault can be read and written at its current generation.</summary>
public bool CanRead(Guid vaultId) =>
!disposed
&& generations.TryGetValue(vaultId, out var current)
&& Held(vaultId, current) is not null;
/// <inheritdoc />
public void Dispose()
@@ -207,14 +315,70 @@ public sealed class VaultKeyring : IDisposable
disposed = true;
foreach (var key in keys.Values)
foreach (var held in keys.Values)
{
CryptographicOperations.ZeroMemory(key);
foreach (var key in held.Values)
{
CryptographicOperations.ZeroMemory(key);
}
}
keys.Clear();
generations.Clear();
}
/// <summary>Opens whatever superseded generations this vault came with.</summary>
/// <remarks>
/// A wrap that will not open is skipped rather than reported. It means one historic grant is
/// unusable — the items under that generation stay unreadable and are counted as such where they
/// are listed — and it is not a reason to refuse the generations that did open.
/// </remarks>
private void OpenPriorWraps(UserSecretBundle bundle, StoredVault vault)
{
foreach (var wrap in vault.PriorKeyWraps ?? [])
{
if (Held(vault.VaultId, wrap.KeyGeneration) is not null)
{
continue;
}
var key = VaultKeys.TryUnwrap(
bundle.EncryptionKey, wrap.WrappedKey, vault.VaultId, wrap.KeyGeneration);
if (key is not null)
{
AdoptPrior(vault.VaultId, key, wrap.KeyGeneration);
}
}
}
private byte[]? Held(Guid vaultId, uint keyGeneration) =>
keys.TryGetValue(vaultId, out var held) && held.TryGetValue(keyGeneration, out var key)
? key
: null;
private void Store(Guid vaultId, byte[] vaultKey, uint keyGeneration)
{
if (!keys.TryGetValue(vaultId, out var held))
{
held = [];
keys[vaultId] = held;
}
if (held.TryGetValue(keyGeneration, out var previous))
{
CryptographicOperations.ZeroMemory(previous);
}
held[keyGeneration] = vaultKey;
}
private void Promote(Guid vaultId, uint keyGeneration)
{
generations[vaultId] = keyGeneration;
Unopened = [.. Unopened.Where(id => id != vaultId)];
}
}
/// <summary>Thrown when an operation needs a vault key the keyring does not hold.</summary>
@@ -54,6 +54,7 @@ namespace DodoSSH.Contracts;
[JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))]
[JsonSerializable(typeof(CreateTeamVaultRequest))]
[JsonSerializable(typeof(IssueVaultGrantRequest))]
[JsonSerializable(typeof(RekeyVaultRequest))]
[JsonSerializable(typeof(VaultGrantsResponse))]
[JsonSerializable(typeof(KeyLogPage))]
[JsonSerializable(typeof(SyncPullRequest))]
+21 -1
View File
@@ -209,6 +209,16 @@ public sealed record MeResponse(
/// must complete it.
/// </param>
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
/// <param name="PriorKeyWraps">
/// Generations before <paramref name="KeyGeneration"/> that this caller still holds a grant for.
/// <para>
/// Empty for a vault that has never been rotated, which is nearly all of them. It is not empty after
/// one, and it has to be served: an item is sealed under the generation in force when it was written,
/// so a client that held only the current key would find every item older than the rotation
/// undecryptable. See <c>VaultGrantService.RekeyAsync</c> for why old grants are kept rather than
/// revoked.
/// </para>
/// </param>
public sealed record VaultSummary(
Guid VaultId,
string Name,
@@ -217,4 +227,14 @@ public sealed record VaultSummary(
uint KeyGeneration,
int Permissions,
byte[]? WrappedVaultKey,
bool RekeyRequired);
bool RekeyRequired,
IReadOnlyList<VaultKeyWrap>? PriorKeyWraps = null);
/// <summary>A vault key sealed to one recipient, at one generation.</summary>
/// <remarks>
/// Only ever the caller's own. <c>VaultGrantSummary</c> deliberately carries no wrap: serving every
/// member's sealed key to every member would widen what a stolen access token yields for nothing.
/// </remarks>
/// <param name="KeyGeneration">The generation this wrap opens.</param>
/// <param name="WrappedKey">The vault key sealed to the caller's X25519 key. Opaque.</param>
public sealed record VaultKeyWrap(uint KeyGeneration, byte[] WrappedKey);
+36 -2
View File
@@ -359,6 +359,19 @@ DodoSSH.Contracts.RegisterDeviceResponse.EnrolledAt.get -> System.DateTimeOffset
DodoSSH.Contracts.RegisterDeviceResponse.EnrolledAt.init -> void
DodoSSH.Contracts.RegisterDeviceResponse.Equals(DodoSSH.Contracts.RegisterDeviceResponse? other) -> bool
DodoSSH.Contracts.RegisterDeviceResponse.RegisterDeviceResponse(System.Guid DeviceId, System.DateTimeOffset EnrolledAt) -> void
DodoSSH.Contracts.RekeyVaultRequest
DodoSSH.Contracts.RekeyVaultRequest.<Clone>$() -> DodoSSH.Contracts.RekeyVaultRequest!
DodoSSH.Contracts.RekeyVaultRequest.Deconstruct(out uint KeyGeneration, out byte[]! WrappedVaultKey, out byte[]! GrantSignature, out System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.RekeyVaultRequest.Equals(DodoSSH.Contracts.RekeyVaultRequest? other) -> bool
DodoSSH.Contracts.RekeyVaultRequest.GrantedAt.get -> System.DateTimeOffset
DodoSSH.Contracts.RekeyVaultRequest.GrantedAt.init -> void
DodoSSH.Contracts.RekeyVaultRequest.GrantSignature.get -> byte[]!
DodoSSH.Contracts.RekeyVaultRequest.GrantSignature.init -> void
DodoSSH.Contracts.RekeyVaultRequest.KeyGeneration.get -> uint
DodoSSH.Contracts.RekeyVaultRequest.KeyGeneration.init -> void
DodoSSH.Contracts.RekeyVaultRequest.RekeyVaultRequest(uint KeyGeneration, byte[]! WrappedVaultKey, byte[]! GrantSignature, System.DateTimeOffset GrantedAt) -> void
DodoSSH.Contracts.RekeyVaultRequest.WrappedVaultKey.get -> byte[]!
DodoSSH.Contracts.RekeyVaultRequest.WrappedVaultKey.init -> void
DodoSSH.Contracts.RelayConfiguration
DodoSSH.Contracts.RelayConfiguration.<Clone>$() -> DodoSSH.Contracts.RelayConfiguration!
DodoSSH.Contracts.RelayConfiguration.Deconstruct(out bool Enabled, out System.Uri? WebSocketUrl) -> void
@@ -705,9 +718,18 @@ DodoSSH.Contracts.VaultGrantSummary.RevokedAt.init -> void
DodoSSH.Contracts.VaultGrantSummary.State.get -> DodoSSH.Contracts.VaultGrantState
DodoSSH.Contracts.VaultGrantSummary.State.init -> void
DodoSSH.Contracts.VaultGrantSummary.VaultGrantSummary(System.Guid RecipientUserId, string? Email, string? DisplayName, uint KeyGeneration, DodoSSH.Contracts.VaultGrantState State, System.Guid GranterUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset? RevokedAt) -> void
DodoSSH.Contracts.VaultKeyWrap
DodoSSH.Contracts.VaultKeyWrap.<Clone>$() -> DodoSSH.Contracts.VaultKeyWrap!
DodoSSH.Contracts.VaultKeyWrap.Deconstruct(out uint KeyGeneration, out byte[]! WrappedKey) -> void
DodoSSH.Contracts.VaultKeyWrap.Equals(DodoSSH.Contracts.VaultKeyWrap? other) -> bool
DodoSSH.Contracts.VaultKeyWrap.KeyGeneration.get -> uint
DodoSSH.Contracts.VaultKeyWrap.KeyGeneration.init -> void
DodoSSH.Contracts.VaultKeyWrap.VaultKeyWrap(uint KeyGeneration, byte[]! WrappedKey) -> void
DodoSSH.Contracts.VaultKeyWrap.WrappedKey.get -> byte[]!
DodoSSH.Contracts.VaultKeyWrap.WrappedKey.init -> void
DodoSSH.Contracts.VaultSummary
DodoSSH.Contracts.VaultSummary.<Clone>$() -> DodoSSH.Contracts.VaultSummary!
DodoSSH.Contracts.VaultSummary.Deconstruct(out System.Guid VaultId, out string! Name, out bool IsPersonal, out System.Guid? TeamId, out uint KeyGeneration, out int Permissions, out byte[]? WrappedVaultKey, out bool RekeyRequired) -> void
DodoSSH.Contracts.VaultSummary.Deconstruct(out System.Guid VaultId, out string! Name, out bool IsPersonal, out System.Guid? TeamId, out uint KeyGeneration, out int Permissions, out byte[]? WrappedVaultKey, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultKeyWrap!>? PriorKeyWraps) -> void
DodoSSH.Contracts.VaultSummary.Equals(DodoSSH.Contracts.VaultSummary? other) -> bool
DodoSSH.Contracts.VaultSummary.IsPersonal.get -> bool
DodoSSH.Contracts.VaultSummary.IsPersonal.init -> void
@@ -717,13 +739,15 @@ DodoSSH.Contracts.VaultSummary.Name.get -> string!
DodoSSH.Contracts.VaultSummary.Name.init -> void
DodoSSH.Contracts.VaultSummary.Permissions.get -> int
DodoSSH.Contracts.VaultSummary.Permissions.init -> void
DodoSSH.Contracts.VaultSummary.PriorKeyWraps.get -> System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultKeyWrap!>?
DodoSSH.Contracts.VaultSummary.PriorKeyWraps.init -> void
DodoSSH.Contracts.VaultSummary.RekeyRequired.get -> bool
DodoSSH.Contracts.VaultSummary.RekeyRequired.init -> void
DodoSSH.Contracts.VaultSummary.TeamId.get -> System.Guid?
DodoSSH.Contracts.VaultSummary.TeamId.init -> void
DodoSSH.Contracts.VaultSummary.VaultId.get -> System.Guid
DodoSSH.Contracts.VaultSummary.VaultId.init -> void
DodoSSH.Contracts.VaultSummary.VaultSummary(System.Guid VaultId, string! Name, bool IsPersonal, System.Guid? TeamId, uint KeyGeneration, int Permissions, byte[]? WrappedVaultKey, bool RekeyRequired) -> void
DodoSSH.Contracts.VaultSummary.VaultSummary(System.Guid VaultId, string! Name, bool IsPersonal, System.Guid? TeamId, uint KeyGeneration, int Permissions, byte[]? WrappedVaultKey, bool RekeyRequired, System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultKeyWrap!>? PriorKeyWraps = null) -> void
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.get -> byte[]?
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void
override DodoSSH.Contracts.AddTeamMemberRequest.Equals(object? obj) -> bool
@@ -789,6 +813,9 @@ override DodoSSH.Contracts.RegisterDeviceRequest.ToString() -> string!
override DodoSSH.Contracts.RegisterDeviceResponse.Equals(object? obj) -> bool
override DodoSSH.Contracts.RegisterDeviceResponse.GetHashCode() -> int
override DodoSSH.Contracts.RegisterDeviceResponse.ToString() -> string!
override DodoSSH.Contracts.RekeyVaultRequest.Equals(object? obj) -> bool
override DodoSSH.Contracts.RekeyVaultRequest.GetHashCode() -> int
override DodoSSH.Contracts.RekeyVaultRequest.ToString() -> string!
override DodoSSH.Contracts.RelayConfiguration.Equals(object? obj) -> bool
override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int
override DodoSSH.Contracts.RelayConfiguration.ToString() -> string!
@@ -846,6 +873,9 @@ override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
override DodoSSH.Contracts.VaultGrantSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultGrantSummary.GetHashCode() -> int
override DodoSSH.Contracts.VaultGrantSummary.ToString() -> string!
override DodoSSH.Contracts.VaultKeyWrap.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultKeyWrap.GetHashCode() -> int
override DodoSSH.Contracts.VaultKeyWrap.ToString() -> string!
override DodoSSH.Contracts.VaultSummary.Equals(object? obj) -> bool
override DodoSSH.Contracts.VaultSummary.GetHashCode() -> int
override DodoSSH.Contracts.VaultSummary.ToString() -> string!
@@ -894,6 +924,8 @@ static DodoSSH.Contracts.RegisterDeviceRequest.operator !=(DodoSSH.Contracts.Reg
static DodoSSH.Contracts.RegisterDeviceRequest.operator ==(DodoSSH.Contracts.RegisterDeviceRequest? left, DodoSSH.Contracts.RegisterDeviceRequest? right) -> bool
static DodoSSH.Contracts.RegisterDeviceResponse.operator !=(DodoSSH.Contracts.RegisterDeviceResponse? left, DodoSSH.Contracts.RegisterDeviceResponse? right) -> bool
static DodoSSH.Contracts.RegisterDeviceResponse.operator ==(DodoSSH.Contracts.RegisterDeviceResponse? left, DodoSSH.Contracts.RegisterDeviceResponse? right) -> bool
static DodoSSH.Contracts.RekeyVaultRequest.operator !=(DodoSSH.Contracts.RekeyVaultRequest? left, DodoSSH.Contracts.RekeyVaultRequest? right) -> bool
static DodoSSH.Contracts.RekeyVaultRequest.operator ==(DodoSSH.Contracts.RekeyVaultRequest? left, DodoSSH.Contracts.RekeyVaultRequest? right) -> bool
static DodoSSH.Contracts.RelayConfiguration.operator !=(DodoSSH.Contracts.RelayConfiguration? left, DodoSSH.Contracts.RelayConfiguration? right) -> bool
static DodoSSH.Contracts.RelayConfiguration.operator ==(DodoSSH.Contracts.RelayConfiguration? left, DodoSSH.Contracts.RelayConfiguration? right) -> bool
static DodoSSH.Contracts.RelaySessionSummary.operator !=(DodoSSH.Contracts.RelaySessionSummary? left, DodoSSH.Contracts.RelaySessionSummary? right) -> bool
@@ -932,5 +964,7 @@ static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.Vault
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
static DodoSSH.Contracts.VaultGrantSummary.operator ==(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
static DodoSSH.Contracts.VaultKeyWrap.operator !=(DodoSSH.Contracts.VaultKeyWrap? left, DodoSSH.Contracts.VaultKeyWrap? right) -> bool
static DodoSSH.Contracts.VaultKeyWrap.operator ==(DodoSSH.Contracts.VaultKeyWrap? left, DodoSSH.Contracts.VaultKeyWrap? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
+37 -1
View File
@@ -404,6 +404,37 @@ public sealed record IssueVaultGrantRequest(
byte[] GrantSignature,
DateTimeOffset GrantedAt);
/// <summary>
/// Moves a vault to a fresh key, wrapped to the caller.
/// </summary>
/// <remarks>
/// <para>
/// The new key is generated by a client that already holds the current one, and arrives sealed to that
/// same client — the server can neither produce it nor tell that it differs from the old one. What the
/// server does is decide the moment it takes effect: the generation advances in one transaction, so
/// there is no instant at which two clients disagree about which generation is current.
/// </para>
/// <para>
/// <b>Grants for earlier generations are kept, not revoked.</b> Every item still carries the generation
/// it was sealed under, so withdrawing them would make the vault's whole history unreadable to the
/// people who are still in it. The departed member's grants are revoked — that is what
/// <c>RevokeGrantAsync</c> and removal from the team already do — and this is what stops them reading
/// anything written from here on. It does not reach back; see ADR 0001.
/// </para>
/// </remarks>
/// <param name="KeyGeneration">
/// The generation being created. Must be exactly one past the vault's current one, so two clients
/// rotating at once cannot both believe they succeeded.
/// </param>
/// <param name="WrappedVaultKey">The new vault key, sealed to the caller's own 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 RekeyVaultRequest(
uint KeyGeneration,
byte[] WrappedVaultKey,
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
@@ -436,7 +467,12 @@ public sealed record VaultGrantSummary(
/// 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>
/// <param name="Grants">
/// One row per holder, including those whose access has been withdrawn. Not one per grant: a rotated
/// vault leaves a member holding one grant per generation, and the row carries the best of them — so
/// <see cref="VaultGrantSummary.KeyGeneration"/> below <paramref name="KeyGeneration"/> means they have
/// not been wrapped the current key yet, rather than that one of their grants is old.
/// </param>
public sealed record VaultGrantsResponse(
Guid VaultId,
uint KeyGeneration,
@@ -103,6 +103,10 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
// Gated on Share inside the handler as the two writes above are, and additionally on holding the
// current key — which no policy could express, since it is a row in vault_key_grant.
"POST /api/v1/vaults/{vaultId:guid}/rekey name=RekeyVault tags=Vaults policies=Enrolled anon=False",
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
// MapHealthChecks constrains no verb, hence ANY.
@@ -999,6 +999,198 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
/// <remarks>
/// The rotation itself: the generation advances, the caller holds the new key, and the flag a
/// removal set is cleared because the rotation it recorded has happened. What the server cannot do
/// is any part of the cryptography — the wrap arrives sealed and is stored as bytes.
/// </remarks>
[Fact]
public async Task RekeyingAVault_AdvancesTheGenerationAndWrapsItToTheCaller()
{
var owner = await EnrolledClientAsync("rotate-owner", "rotowner@example.com");
await EnrolledClientAsync("rotate-member", "rotmember@example.com");
var team = await CreateTeamAsync(owner, "Rotations");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "rotmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));
var rotated = await RekeyAsync(owner, vaultId, generation: 2);
rotated.KeyGeneration.ShouldBe(2u);
rotated.RekeyRequired.ShouldBeFalse();
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
grants.KeyGeneration.ShouldBe(2u);
grants.RekeyRequired.ShouldBeFalse();
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
summary.KeyGeneration.ShouldBe(2u);
summary.WrappedVaultKey.ShouldNotBeNull();
}
/// <remarks>
/// The reason a rotation does not have to re-encrypt anything to be safe, and the reason it cannot
/// throw the old grants away: every item still carries the generation it was sealed under, so the
/// caller has to go on holding every key they were given or the vault's history becomes unreadable
/// to the people who are still in the team.
/// </remarks>
[Fact]
public async Task ARotatedVault_StillServesTheCallerTheGenerationsItHasMovedPast()
{
var owner = await EnrolledClientAsync("history-owner", "histowner@example.com");
var team = await CreateTeamAsync(owner, "History");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
await RekeyAsync(owner, vaultId, generation: 2);
await RekeyAsync(owner, vaultId, generation: 3);
var me = await ReadAsync<MeResponse>(owner, MeUrl);
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
summary.KeyGeneration.ShouldBe(3u);
summary.PriorKeyWraps.ShouldNotBeNull();
summary.PriorKeyWraps.Select(wrap => wrap.KeyGeneration).ShouldBe([1u, 2u]);
}
/// <remarks>
/// The sharing list answers "who can open this", so a member appears once however many generations
/// they hold — and the generation on their row is the best key they have, which is what makes a row
/// below the vault's own generation mean "still owed the new key".
/// </remarks>
[Fact]
public async Task TheGrantListing_ShowsAMemberOnceWithTheBestKeyTheyHold()
{
var owner = await EnrolledClientAsync("listing-owner", "listowner@example.com");
await EnrolledClientAsync("listing-member", "listmember@example.com");
var team = await CreateTeamAsync(owner, "Listings");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "listmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var issued = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
issued.EnsureSuccessStatusCode();
await RekeyAsync(owner, vaultId, generation: 2);
var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");
grants.KeyGeneration.ShouldBe(2u);
grants.Grants.Count.ShouldBe(2);
// The rotating owner holds both generations and is listed at the newer one.
grants.Grants.Single(g => g.RecipientUserId != entry.UserId).KeyGeneration.ShouldBe(2u);
// The member has not been re-wrapped, so their row says so by generation rather than by state.
var stale = grants.Grants.Single(g => g.RecipientUserId == entry.UserId);
stale.KeyGeneration.ShouldBe(1u);
stale.State.ShouldBe(VaultGrantState.Active);
}
/// <remarks>
/// Two admins rotating at once must not both succeed, or one of them ends up holding a key nobody
/// else has and every item they write is unreadable to the rest of the team. The generation is what
/// makes that decidable: the second request is no longer one past the current, and is refused with a
/// message that says to read the vault again.
/// </remarks>
[Fact]
public async Task ARekeyFromASupersededGeneration_IsRefused()
{
var owner = await EnrolledClientAsync("race-owner", "raceowner@example.com");
var team = await CreateTeamAsync(owner, "Races");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
await RekeyAsync(owner, vaultId, generation: 2);
var stale = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2));
await ShouldBeProblemAsync(stale, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
/// A member with no key to the current generation cannot rotate. They could not have wrapped the
/// new key from the old one, so the request is either a mistake or a way to strand everybody else
/// behind a key nobody holds.
/// </remarks>
[Fact]
public async Task ARekeyByAMemberWhoHoldsNoKey_IsRefused()
{
var owner = await EnrolledClientAsync("keyless-owner", "klowner@example.com");
var member = await EnrolledClientAsync("keyless-member", "klmember@example.com");
var team = await CreateTeamAsync(owner, "Keyless");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "klmember@example.com");
// Admin, so permission is not what stops them: what stops them is holding no key.
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
var response = await member.PostContractAsync(
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation: 2));
await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
}
/// <remarks>
/// Sharing a rotated vault means handing over its history too, so a grant for a generation the vault
/// has moved past is accepted. One for a generation it has not reached is not: nothing is sealed
/// under it, and accepting it would let a client move the vault forward outside the one transaction
/// that is allowed to.
/// </remarks>
[Fact]
public async Task AGrantForAnEarlierGeneration_IsAcceptedAndOneForALaterOneIsNot()
{
var owner = await EnrolledClientAsync("gen-owner", "genowner@example.com");
var member = await EnrolledClientAsync("gen-member", "genmember@example.com");
var team = await CreateTeamAsync(owner, "Generations");
var vaultId = await CreateVaultAsync(owner, team.TeamId);
var entry = await LookupAsync(owner, "genmember@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
await RekeyAsync(owner, vaultId, generation: 2);
foreach (var generation in (uint[])[1, 2])
{
var accepted = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation));
accepted.StatusCode.ShouldBe(HttpStatusCode.NoContent);
}
var ahead = await owner.PostContractAsync(
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 3));
await ShouldBeProblemAsync(ahead, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
// Both grants are live at once, which is what lets the recipient read the vault's history and
// its present. A single row per recipient would have made one of them overwrite the other.
var me = await ReadAsync<MeResponse>(member, MeUrl);
var summary = me.Vaults.Single(vault => vault.VaultId == vaultId);
summary.KeyGeneration.ShouldBe(2u);
summary.PriorKeyWraps.ShouldNotBeNull().ShouldHaveSingleItem().KeyGeneration.ShouldBe(1u);
}
// ---- The directory and the key log ----
/// <remarks>
@@ -1171,6 +1363,40 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
return vault.VaultId;
}
/// <remarks>
/// The wrap is the right shape and nothing more, for the reason <see cref="CreateVaultAsync"/> gives:
/// the server stores it opaquely, so a real seal here would be exercising the crypto library.
/// </remarks>
private static RekeyVaultRequest RekeyRequest(uint generation) =>
new(
KeyGeneration: generation,
WrappedVaultKey: new byte[110],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch);
private static IssueVaultGrantRequest GrantRequest(DirectoryEntry entry, uint generation) =>
new(
entry.UserId,
entry.Fingerprint,
KeyGeneration: generation,
WrappedVaultKey: new byte[110],
KeyLogHead: new byte[32],
GrantSignature: new byte[64],
GrantedAt: DateTimeOffset.UnixEpoch);
private static async Task<VaultSummary> RekeyAsync(
HttpClient client,
Guid vaultId,
uint generation)
{
var response = await client.PostContractAsync(
$"/api/v1/vaults/{vaultId}/rekey", RekeyRequest(generation));
response.EnsureSuccessStatusCode();
return (await response.Content.ReadContractAsync<VaultSummary>())!;
}
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
{
var address = addresses.GetValueOrDefault(email, email);
@@ -227,6 +227,12 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
Guid userId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public void Dispose()
{
@@ -26,7 +26,14 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
private readonly List<TeamSummary> teams = [];
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
private readonly Dictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> grants = [];
/// <remarks>
/// Keyed by generation as well as by recipient, because the real table is: a rotation leaves a
/// member holding one grant per generation, and a fake that kept one per person would quietly model
/// sharing the history as overwriting it — which is the bug this half of the feature exists to
/// avoid.
/// </remarks>
private readonly Dictionary<(Guid VaultId, Guid UserId, uint KeyGeneration), IssueVaultGrantRequest>
grants = [];
private readonly List<KeyLogRecord> keyLog = [];
private readonly List<DirectoryEntry> directory = [];
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
@@ -51,8 +58,29 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
/// <inheritdoc />
public IVaultGrantApi Grants => this;
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
/// <summary>
/// Grants this fake has been asked to record, newest generation per recipient.
/// </summary>
/// <remarks>
/// Flattened to one entry per recipient because that is the question most tests are asking — can
/// this person open the vault as it stands. <see cref="GenerationsGranted"/> is for the ones asking
/// whether they were also given its history.
/// </remarks>
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants =>
grants
.GroupBy(entry => (entry.Key.VaultId, entry.Key.UserId))
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(entry => entry.Key.KeyGeneration).First().Value);
/// <summary>Which generations of one vault's key a recipient has been wrapped, oldest first.</summary>
internal IReadOnlyList<uint> GenerationsGranted(Guid vaultId, Guid userId) =>
[
.. grants.Keys
.Where(key => key.VaultId == vaultId && key.UserId == userId)
.Select(key => key.KeyGeneration)
.Order(),
];
/// <summary>
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
@@ -451,11 +479,17 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
// Every grant they held from this team goes with them, as the real service revokes them in the
// same transaction. A fake that removed the membership and left the grants would let a test
// "prove" a revocation that had not happened.
foreach (var vaultId in teamVaults.Values
.Where(vault => vault.TeamId == teamId)
.Select(vault => vault.VaultId))
var theirs = grants.Keys
.Where(key => key.UserId == userId
&& teamVaults.TryGetValue(key.VaultId, out var vault)
&& vault.TeamId == teamId)
.ToList();
// Every generation, not only the newest. A revocation that left the history behind would let
// them go on reading everything written before the rotation that follows.
foreach (var key in theirs)
{
grants.Remove((vaultId, userId));
grants.Remove(key);
}
Recount(teamId);
@@ -491,6 +525,17 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
teamVaults[vault.VaultId] = vault;
// The creator's own grant, as the real create records it in the same transaction. Without it a
// rotation here would report no earlier wraps and the vault's first generation would vanish.
grants[(vault.VaultId, UserId, 1)] = new IssueVaultGrantRequest(
UserId,
RecipientKeyFingerprint: new byte[32],
KeyGeneration: 1,
request.WrappedVaultKey,
KeyLogHead: new byte[32],
request.GrantSignature,
request.GrantedAt);
Recount(teamId);
return Task.FromResult(vault);
@@ -539,16 +584,20 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
CancellationToken cancellationToken) =>
Task.FromResult(new VaultGrantsResponse(
vaultId,
KeyGeneration: 1,
KeyGeneration: Generation(vaultId),
RekeyRequired: false,
Grants:
[
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
new VaultGrantSummary(
entry.Key.UserId,
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
// One row per holder rather than per grant, as the real listing shows a member once
// and lets the generation say whether their key is current.
.. grants
.Where(entry => entry.Key.VaultId == vaultId)
.GroupBy(entry => entry.Key.UserId)
.Select(group => new VaultGrantSummary(
group.Key,
directory.Find(candidate => candidate.UserId == group.Key)?.Email,
null,
KeyGeneration: 1,
KeyGeneration: group.Max(entry => entry.Key.KeyGeneration),
VaultGrantState.Active,
UserId,
DateTimeOffset.UnixEpoch,
@@ -561,17 +610,88 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
IssueVaultGrantRequest request,
CancellationToken cancellationToken)
{
grants[(vaultId, request.RecipientUserId)] = request;
grants[(vaultId, request.RecipientUserId, request.KeyGeneration)] = request;
return Task.CompletedTask;
}
/// <inheritdoc />
/// <remarks>
/// Models the one part of a rotation that is the server's: the generation advances, the caller's own
/// grant for it is recorded, and everything older is left standing so the vault's stored items go on
/// opening. What comes back is what the real endpoint returns — the vault at its new generation,
/// with the caller's earlier wraps attached.
/// </remarks>
public Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken)
{
if (!teamVaults.TryGetValue(vaultId, out var vault))
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.NotFound, code: null, "No such vault.");
}
if (request.KeyGeneration != vault.KeyGeneration + 1)
{
throw new DodoSshApiException(
System.Net.HttpStatusCode.BadRequest,
ProblemCodes.InvalidVaultGrant,
$"This vault is at key generation {vault.KeyGeneration}.");
}
grants[(vaultId, UserId, request.KeyGeneration)] = new IssueVaultGrantRequest(
UserId,
RecipientKeyFingerprint: new byte[32],
request.KeyGeneration,
request.WrappedVaultKey,
KeyLogHead: new byte[32],
request.GrantSignature,
request.GrantedAt);
var prior = grants
.Where(entry => entry.Key.VaultId == vaultId
&& entry.Key.UserId == UserId
&& entry.Key.KeyGeneration < request.KeyGeneration)
.OrderBy(entry => entry.Key.KeyGeneration)
.Select(entry => new VaultKeyWrap(entry.Key.KeyGeneration, entry.Value.WrappedVaultKey))
.ToList();
var rotated = vault with
{
KeyGeneration = request.KeyGeneration,
WrappedVaultKey = request.WrappedVaultKey,
RekeyRequired = false,
PriorKeyWraps = prior,
};
teamVaults[vaultId] = rotated;
return Task.FromResult(rotated);
}
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) =>
Task.FromResult(grants.Remove((vaultId, userId)));
CancellationToken cancellationToken)
{
var theirs = grants.Keys
.Where(key => key.VaultId == vaultId && key.UserId == userId)
.ToList();
foreach (var key in theirs)
{
grants.Remove(key);
}
return Task.FromResult(theirs.Count > 0);
}
/// <summary>The generation a vault currently stands at.</summary>
private uint Generation(Guid vaultId) =>
teamVaults.TryGetValue(vaultId, out var vault) ? vault.KeyGeneration : 1;
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
@@ -98,12 +98,12 @@ public sealed class TeamSharingTests : IAsyncLifetime
}
/// <remarks>
/// The whole point of a team, in one test. Note what the status line says after the add and before
/// the share: adding somebody grants them nothing readable, and the interface has to say so rather
/// than let a user believe the credential is already with their colleague.
/// The whole point of a team, in one test. Adding somebody wraps every team vault this machine can
/// open to them, so the status line names what they were given rather than what is still owed —
/// and the grant is on the server before the add has finished reporting.
/// </remarks>
[Fact]
public async Task CreatingATeamAndSharingItsVault_WrapsTheKeyToTheOtherMember()
public async Task AddingAMember_WrapsEveryTeamVaultThisMachineHoldsToThem()
{
await UnlockedAsync();
@@ -115,11 +115,121 @@ public sealed class TeamSharingTests : IAsyncLifetime
await CreateVaultAsync(teams, "Platform secrets");
teams.Vaults.Count.ShouldBe(1, teams.Status);
var vaultId = teams.Vaults[0].VaultId;
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.Members.Count.ShouldBe(2, teams.Status);
teams.Status.ShouldContain("cannot read anything yet");
server.IssuedGrants.ShouldContainKey(
(vaultId, colleague),
"adding somebody to a team is what shares its vaults with them");
teams.Status.ShouldContain("Platform secrets");
}
/// <remarks>
/// <para>
/// The other half of the same idea. Removing somebody withdraws their grants — which only blocks
/// future reads — so the vault is rotated in the same breath and the new key goes to the people who
/// are left. From that moment nothing written is readable to the person who went.
/// </para>
/// <para>
/// The remaining member is given the earlier generation as well as the new one, which is what keeps
/// the vault's existing items readable to them: a rotation re-keys the vault, not its contents.
/// </para>
/// </remarks>
[Fact]
public async Task RemovingAMember_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
{
await UnlockedAsync();
var teams = shell.Teams;
var leaving = server.AddAccount("bob@example.com", "Bob Example");
var staying = server.AddAccount("carol@example.com", "Carol Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
var vaultId = teams.Vaults[0].VaultId;
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
{
teams.InviteEmail = address;
await teams.AddMemberCommand.ExecuteAsync(null);
}
teams.Members.Count.ShouldBe(3, teams.Status);
teams.SelectedMember = teams.Members.Single(member => member.UserId == leaving);
await teams.RemoveMemberCommand.ExecuteAsync(null);
teams.Status.ShouldContain("Rotated", customMessage: teams.Status);
teams.Status.ShouldContain("Platform secrets");
// Gone entirely, at every generation. A revocation that left the history behind would leave them
// able to read everything written before they went, from a copy of the ciphertext.
server.GenerationsGranted(vaultId, leaving).ShouldBeEmpty();
// And the member who stayed holds both: the new key for what comes next, the old one for what
// is already stored under it.
server.GenerationsGranted(vaultId, staying).ShouldBe([1u, 2u]);
}
/// <remarks>
/// Somebody added after a rotation is given every generation the sharing machine holds, not only the
/// newest. A vault shared as one key would open to a list of items that will not decrypt, which
/// reads as corruption rather than as the missing grant it is.
/// </remarks>
[Fact]
public async Task AddingAMemberToARotatedVault_HandsThemItsHistoryAsWell()
{
await UnlockedAsync();
var teams = shell.Teams;
var first = server.AddAccount("bob@example.com", "Bob Example");
var second = server.AddAccount("carol@example.com", "Carol Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
var vaultId = teams.Vaults[0].VaultId;
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
// Removing them is what rotates the vault, so the next person to be added arrives at a vault
// with a history rather than one that has only ever had a single key.
teams.SelectedMember = teams.Members.Single(member => member.UserId == first);
await teams.RemoveMemberCommand.ExecuteAsync(null);
teams.InviteEmail = "carol@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], teams.Status);
}
/// <remarks>
/// The manual path still works and is still worth having: a vault whose key this machine did not
/// hold when somebody was added is shared by pressing the button once it does. Re-wrapping to
/// somebody who already holds the key is the same call, and the server replaces the row rather than
/// adding a second one.
/// </remarks>
[Fact]
public async Task SharingAVaultByHand_WrapsTheKeyAndSaysWhatItCannotPromise()
{
await UnlockedAsync();
var teams = shell.Teams;
var colleague = server.AddAccount("bob@example.com", "Bob Example");
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
teams.InviteEmail = "bob@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
@@ -158,17 +268,25 @@ public sealed class TeamSharingTests : IAsyncLifetime
await CreateTeamAsync(teams, "Platform", "platform");
await CreateVaultAsync(teams, "Platform secrets");
// Before the add, because the add now shares. Both routes to a wrap have to refuse, and a test
// that corrupted the log afterwards would be asserting about the second one only.
server.CorruptKeyLog = true;
teams.InviteEmail = "mallory@example.com";
await teams.AddMemberCommand.ExecuteAsync(null);
var vaultId = teams.Vaults[0].VaultId;
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
teams.Status.ShouldContain("Could not share");
teams.Status.ShouldContain("key log");
teams.SelectedMember = teams.Members.Single(member => member.UserId == colleague);
teams.SelectedVault = teams.Vaults[0];
server.CorruptKeyLog = true;
await teams.ShareVaultCommand.ExecuteAsync(null);
server.IssuedGrants.ShouldBeEmpty();
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
teams.Status.ShouldContain("Did not share");
teams.Status.ShouldContain("key log");
}
@@ -303,9 +421,13 @@ public sealed class TeamSharingTests : IAsyncLifetime
teams.SelectedVault = null;
teams.SelectedVault = teams.Vaults[0];
var holder = teams.Grants.ShouldHaveSingleItem();
// Two, and the second one matters: the creator's own grant is recorded when the vault is made,
// so a list that showed only the people it was shared with would be describing a vault its
// owner cannot open.
teams.Grants.Count.ShouldBe(2, teams.Status);
var holder = teams.Grants.Single(row => row.UserId == colleague);
holder.UserId.ShouldBe(colleague);
holder.IsLive.ShouldBeTrue(teams.Status);
holder.State.ShouldBe("holds a key");
}
@@ -0,0 +1,238 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Holding more than one generation of a vault's key at once.
/// </summary>
/// <remarks>
/// A rotation does not re-encrypt what is already stored, so a rotated vault holds items sealed under
/// two or three different keys and every read has to choose the one the item names. These are the tests
/// that say so: the alternative — one key per vault — reads a rotated vault's whole history as corrupt,
/// which is a data-loss bug that looks exactly like a decryption failure.
/// </remarks>
public sealed class VaultKeyringTests : IDisposable
{
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid HostId = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f");
private readonly UserSecretBundle bundle =
UserSecretBundle.Create(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000));
/// <inheritdoc />
public void Dispose() => bundle.Dispose();
[Fact]
public void AVaultWithNoHistory_HoldsExactlyOneGeneration()
{
var (vault, _) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.GenerationsHeld(VaultId).ShouldBe([1u]);
keyring.CanRead(VaultId).ShouldBeTrue();
keyring.Unopened.ShouldBeEmpty();
}
[Fact]
public void ARotatedVault_OpensEveryGenerationItWasGranted()
{
var (vault, keys) = Rotated(currentGeneration: 3);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u, 3u]);
foreach (var (generation, key) in keys)
{
keyring.TryGetAt(VaultId, generation, out var held).ShouldBeTrue();
held.ToArray().ShouldBe(key);
}
}
/// <remarks>
/// Writes go under the newest key, always. Sealing a new item under a superseded one would produce
/// an item that nobody who joined after the rotation can read, and the author would have no way to
/// tell — their own keyring still holds the old key.
/// </remarks>
[Fact]
public void TheCurrentGeneration_IsTheNewestOneAndNotTheOldest()
{
var (vault, keys) = Rotated(currentGeneration: 3);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.TryGet(VaultId, out var current, out var generation).ShouldBeTrue();
generation.ShouldBe(3u);
current.ToArray().ShouldBe(keys[3u]);
}
/// <remarks>
/// The state a member is left in between somebody rotating a vault and somebody wrapping the new key
/// to them. They can still read what was there — their old grants stand — and they must not be able
/// to write, because anything they wrote would be sealed under a key the vault has moved past.
/// </remarks>
[Fact]
public void AMemberAwaitingTheNewKey_ReadsTheHistoryAndCannotWrite()
{
var (vault, keys) = Rotated(currentGeneration: 2);
var awaiting = vault with { WrappedVaultKey = null };
using var keyring = VaultKeyring.Open(bundle, [awaiting]);
keyring.CanRead(VaultId).ShouldBeFalse();
keyring.TryGet(VaultId, out _, out _).ShouldBeFalse();
keyring.Unopened.ShouldBe([VaultId]);
keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
first.ToArray().ShouldBe(keys[1u]);
}
/// <remarks>
/// What the rotating client itself does: it generates the next key, the server accepts it, and the
/// keyring takes it without losing the one the vault's existing items are sealed under.
/// </remarks>
[Fact]
public void AdoptingANewGeneration_KeepsTheOneBeforeIt()
{
var (vault, keys) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
var next = VaultKeys.Create();
keyring.Adopt(VaultId, next, keyGeneration: 2);
keyring.TryGet(VaultId, out _, out var generation).ShouldBeTrue();
generation.ShouldBe(2u);
keyring.GenerationsHeld(VaultId).ShouldBe([1u, 2u]);
keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
first.ToArray().ShouldBe(keys[1u]);
}
/// <remarks>
/// The whole point, at the layer that pays for it: an item written before a rotation still opens
/// after one. Sealed and opened through the real cipher, so the AAD's generation binding is
/// exercised rather than assumed.
/// </remarks>
[Fact]
public void AnItemSealedBeforeARotation_StillOpensAfterIt()
{
var (vault, _) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue();
var host = new HostSecret { Label = "web-01", Hostname = "web-01.example", Username = "ops" };
var payload = HostCipher.Seal(host, vaultKey.Span, HostId, generation, itemVersion: 1);
keyring.Adopt(VaultId, VaultKeys.Create(), keyGeneration: 2);
// Chosen by the payload's own generation, which is what every read path does.
keyring.TryGetAt(VaultId, payload.KeyGeneration, out var itemKey).ShouldBeTrue();
HostCipher.TryOpen(payload, itemKey.Span, HostId, itemVersion: 1)
.ShouldNotBeNull()
.Host.Label.ShouldBe("web-01");
// And the current key does not open it, which is why holding only that one would be a loss.
keyring.TryGet(VaultId, out var newest, out _).ShouldBeTrue();
HostCipher.TryOpen(payload, newest.Span, HostId, itemVersion: 1).ShouldBeNull();
}
/// <remarks>
/// What another client rotating the vault looks like from here: the key this session holds is
/// suddenly the previous generation. It goes on opening what it wrote, and it must stop being the
/// one new items are sealed under — an item written under a superseded key is readable to its
/// author and to nobody else, with nothing to show that anything went wrong.
/// </remarks>
[Fact]
public void AVaultRotatedElsewhere_StopsBeingWritableAndStaysReadable()
{
var (vault, keys) = Rotated(currentGeneration: 1);
using var keyring = VaultKeyring.Open(bundle, [vault]);
keyring.CanRead(VaultId).ShouldBeTrue();
// What RefreshVaultsAsync does when the server reports a generation this session has no grant
// for: the admit fails, and the vault is marked unreadable.
keyring.MarkUnreadable(VaultId);
keyring.CanRead(VaultId).ShouldBeFalse();
keyring.TryGet(VaultId, out _, out _).ShouldBeFalse();
keyring.TryGetAt(VaultId, 1, out var first).ShouldBeTrue();
first.ToArray().ShouldBe(keys[1u]);
}
/// <remarks>
/// A wrap that will not open is one unusable grant, not a broken vault. Skipping it leaves the
/// generations that did open readable; refusing them all would take the whole vault down over one
/// bad row.
/// </remarks>
[Fact]
public void AnUnopenableHistoricWrap_IsSkippedRatherThanFatal()
{
var (vault, _) = Rotated(currentGeneration: 2);
var corrupted = vault with
{
PriorKeyWraps = [new VaultKeyWrap(1, new byte[110])],
};
using var keyring = VaultKeyring.Open(bundle, [corrupted]);
keyring.CanRead(VaultId).ShouldBeTrue();
keyring.GenerationsHeld(VaultId).ShouldBe([2u]);
keyring.TryGetAt(VaultId, 1, out _).ShouldBeFalse();
}
/// <summary>
/// A vault at <paramref name="currentGeneration"/>, with a distinct key wrapped for every generation
/// up to it.
/// </summary>
private (StoredVault Vault, Dictionary<uint, byte[]> Keys) Rotated(uint currentGeneration)
{
var keys = new Dictionary<uint, byte[]>();
var prior = new List<VaultKeyWrap>();
byte[]? current = null;
for (var generation = 1u; generation <= currentGeneration; generation++)
{
var key = VaultKeys.Create();
var wrapped = VaultKeys.WrapTo(key, bundle.EncryptionPublicKey, VaultId, generation);
keys[generation] = key;
if (generation == currentGeneration)
{
current = wrapped;
}
else
{
prior.Add(new VaultKeyWrap(generation, wrapped));
}
}
var vault = new StoredVault(
VaultId,
"Platform secrets",
IsPersonal: false,
TeamId: Guid.CreateVersion7(),
currentGeneration,
Permissions: 31,
current,
RekeyRequired: false,
prior);
return (vault, keys);
}
}