Merge branch 'claude/team-key-share-rotate-4b2619'
ci / build and test (push) Successful in 1m33s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 21s

Two conflicts, and the second is worth recording. main's M4 bullet gained the
Android signing decision while this branch rewrote the M5 line either side of it;
both are kept.

The other is an ADR number collision: two sessions each took 0010, one for vault
key rotation and one for Android distribution, and both are now on main. ADR
numbers are identifiers — "see ADR 0010" appears in code comments as well as in
prose — so leaving two would make every such reference ambiguous. The rotation
ADR landed first and is referenced from crypto.md, the gaps document, ADR 0009
and the sync code; the Android one is referenced from README and android-port.md.
So the later and cheaper one moves: 0010-android-distribution.md is now ADR 0011,
with its title and both references updated. Nothing about either decision changes.
This commit is contained in:
2026-08-04 10:23:46 +02:00
14 changed files with 928 additions and 72 deletions
+15 -12
View File
@@ -308,11 +308,13 @@ Four limits, stated rather than discovered:
can open, and hands each new key to the members who are left — so nothing written from that point on is 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 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. that matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
- **A rotation re-keys the vault, not what is already in it.** Existing items stay sealed under the - **A rotation moves the vault's contents too, and says so when it could not.** The key changes first, in
generation they were written with, and everybody still in the team keeps those keys as well as the new one server transaction; then every item already stored is re-sealed under it, so the key somebody left
one — which is what stops a rotation making the vault's own history unreadable. It also means somebody with opens nothing that is still here. Existing items keep working throughout — everybody still in the
who left with a copy of the old key could still open old ciphertext they later got hold of. Re-sealing team holds the older keys as well as the new one, which is what stops a half-finished rotation making a
stored items under the new key is the remaining half; see [ADR 0010](docs/adr/0010-vault-key-rotation.md). vault unreadable, and what makes the pass safe to interrupt and run again. An item somebody else was
editing at that moment is left for the next pass, and the message tells you which of the two you got.
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 - **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 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 you, wrapping every generation it holds so the new member can read the vault back to its first item. A
@@ -617,25 +619,26 @@ keychain plus a terminal — and the spike that gates all of it.
adding machine can open to them — every generation of each, so they can read the vault's history and not 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 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 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 rotation buys is exact: everything written from then on is unreadable to the person who left. The items
already stored keep the generation they were sealed under and are not re-encrypted — that half is still already stored are then re-sealed under the new key as well, item by item and resumably — which is safe
outstanding, and it is safe to add later precisely because a vault at mixed generations stays readable. to do incrementally precisely because a vault at mixed generations stays readable. A change queued before
the rotation is re-sealed as it is pushed, so nothing reaches the server under a superseded key at all.
See [ADR 0010](docs/adr/0010-vault-key-rotation.md). 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 **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 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 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 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, key grants, because removing the outgoing owner would revoke them and rotate every team vault, which is a
which is a much larger act than the one being asked for. much larger act than the one being asked for.
- **M4 — hardening and ops**, packaging, self-hosting guide. *Decided ahead of the work, because the first - **M4 — hardening and ops**, packaging, self-hosting guide. *Decided ahead of the work, because the first
release takes it irreversibly:* who signs the client and where it comes from. release takes it irreversibly:* who signs the client and where it comes from.
[ADR 0010](docs/adr/0010-android-distribution.md) puts the release key with the project rather than with [ADR 0011](docs/adr/0011-android-distribution.md) puts the release key with the project rather than with
a store, and rules out the arrangement a self-hosted product reaches for by default — the deployment a store, and rules out the arrangement a self-hosted product reaches for by default — the deployment
serving the client binary, which hands it to the one party the whole trust model is about. An installed serving the client binary, which hands it to the one party the whole trust model is about. An installed
Android app can only ever be updated by a package signed with the same key, so this is the first Android app can only ever be updated by a package signed with the same key, so this is the first
release's decision to make and nobody else's afterwards. release's decision to make and nobody else's afterwards.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys. - **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence ## Licence
+38 -16
View File
@@ -69,25 +69,47 @@ member is the mirror image: every team vault this machine can open is wrapped to
add. Both report per vault, including what they could not do — a vault whose key this machine does 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. not hold is skipped and stays flagged, because somebody else has to finish it.
## What this deliberately does not do ## The second half: re-sealing what is already stored
**It does not re-encrypt what is already stored.** After a rotation the vault's existing items remain > **Added 2026-08-04.** This was deferred when the decision above was taken, and is now built. The
sealed under the generations they were written with. The person who left keeps whatever plaintext > reasoning that made it safe to defer is what made it cheap to add, so it is recorded here rather
they already pulled — that is the non-retroactive limit ADR 0001 records and no design here changes > than in an ADR of its own.
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 A rotation on its own re-keys the vault and not its contents, which leaves one gap: somebody who left
rotation onwards is unreadable to them.** Nothing about the past changes. The product says that with a copy of the old key could still open old ciphertext they later got hold of. `VaultResealer`
rather than the reassuring version, and the honest remediation for a departure is still to rotate the closes it by walking the vault and rewriting each item under the current key, as an ordinary upsert
credentials themselves. against the version the server holds.
Re-sealing the vault's existing items under the new key is the remaining half and is deferred. It is Four properties, each of which is a decision:
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 - **It never decodes the plaintext.** An item is opened and the *same bytes* are sealed again under a
a vault that still works. Building it the other way round — bumping the generation only once every fresh data key. No codec, no merge, no schema version — so an item written by a newer client
item had been re-sealed — would have needed the whole vault to move in one transaction, which is a survives untouched, where re-encoding it through this build's codec would silently drop the fields
request-size limit dressed as an architecture. this build has no concept of. It is also why one pass covers every item type, including types added
after it was written.
- **It is resumable, and needs no transaction.** Each item is one upsert, so a pass that dies half way
leaves a vault at mixed generations — which is a state that reads perfectly well, because that is
precisely what the decision above bought. Running it again picks up what is left.
- **A conflict is counted, not merged.** The pass changes no content, so there is nothing to merge:
an item somebody else wrote meanwhile is left at their version and re-sealed on the next pass.
- **A queued local edit is left alone, and re-sealed on the way out instead.** Rewriting it here would
overwrite the user's unpushed work with the version the server holds. Instead `SyncEngine` re-seals
a queued payload whose generation is stale as it dispatches it, and writes the revision back to the
outbox first so a retry sends the same bytes. That closes the one hole a pass over *stored* items
cannot see: a change made before the rotation and pushed after it would otherwise put a brand-new
item into the vault under the key the departed member holds.
The pass runs as the last step of a rotation, after a sync — a mirror that is behind produces a batch
of conflicts rather than a re-sealed vault. The interface reports which of the two guarantees was
reached, because they are different: a vault fully re-sealed is closed to the person who left, and one
where items were left behind is closed only to what happens next.
## What this still does not do
**It does not reach what they already pulled.** The person who left keeps whatever plaintext is on
their machine — that is the non-retroactive limit ADR 0001 records and no design here changes it. The
honest remediation for a departure is still to rotate the credentials themselves, and the product says
so rather than the reassuring version.
## Alternatives rejected ## Alternatives rejected
@@ -1,4 +1,4 @@
# ADR 0010 — Distributing the Android client, and who holds the release key # ADR 0011 — Distributing the Android client, and who holds the release key
- Status: accepted - Status: accepted
- Date: 2026-08-03 - Date: 2026-08-03
+1 -1
View File
@@ -618,7 +618,7 @@ worth reading is which way each went and why, not that the list is empty.
API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived API 28 and 29 still cost one branch each in `BiometricGate`, because allowed-authenticator lists arrived
in 30. in 30.
- ~~**How it is distributed, and what that does to the supply-chain story.**~~ **✅ Settled in - ~~**How it is distributed, and what that does to the supply-chain story.**~~ **✅ Settled in
[ADR 0010](adr/0010-android-distribution.md): the project holds the release key, the deployment never [ADR 0011](adr/0011-android-distribution.md): the project holds the release key, the deployment never
serves the client, and Play is deferred.** The question was whether Play App Signing — Google generating serves the client, and Play is deferred.** The question was whether Play App Signing — Google generating
and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR
takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play
+7 -3
View File
@@ -229,9 +229,13 @@ This is the load-bearing structural choice. Because every wrap protects the *sam
> at once and every read chooses the key its item names. That is why a member's grants for earlier > 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 > 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 > 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 > whole history as tag failures.
> not yet built — see [ADR 0010](adr/0010-vault-key-rotation.md) for the guarantee this does and does >
> not buy. > Moving those items onto the new key is a **separate pass** (`VaultResealer`), run last and resumable
> because a vault at mixed generations is readable. It opens each item and seals the same plaintext
> again under a fresh data key — the envelope is re-made rather than only the wrap, because the AAD
> binds `keyGeneration` into both. The plaintext is never decoded, so an item written by a newer client
> crosses a rotation untouched. See [ADR 0010](adr/0010-vault-key-rotation.md).
Per-item keys wrapped *to individual users* — which is what would make per-item ACLs 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 cryptographic rather than server-enforced — are deferred to M5. The `content_key_id` column
+1 -1
View File
@@ -413,6 +413,7 @@ answerable by anybody willing to create a team first. It simply gets claimed soo
| 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. | | 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. | | 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). | | Removing a member rotates the vaults | server + client | `POST /api/v1/vaults/{id}/rekey` advances the generation and records the caller's new grant in one transaction — the server contributes the moment and no cryptography. The client then wraps the new key to the members who remain. Grants for earlier generations are kept, or the vault's stored items would become unreadable to everybody. See [ADR 0010](adr/0010-vault-key-rotation.md). |
| Re-sealing a rotated vault's stored items | client | `VaultResealer`, the last step of a rotation: every item is opened and the same plaintext sealed again under the new key, in batches, against the version the server holds. The plaintext is never decoded, so an item written by a newer client crosses a rotation untouched. Resumable, because a vault at mixed generations is readable — a pass that stops half way leaves a working vault and is re-run. A change queued before the rotation is re-sealed by the push path instead, so nothing reaches the server under a superseded key at all. |
| Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. | | Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. |
| Ownership transfer | server | `POST /api/v1/teams/{id}/owner`, owner only. One transaction: the named member becomes owner and the outgoing owner becomes an admin. Not two role changes — ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody. The outgoing owner is demoted rather than removed, because removing them would revoke their vault key grants and rotate every team vault, which is a far larger act than the one being asked for. | | 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. | | `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. |
@@ -426,7 +427,6 @@ 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. | | 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. | | 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. | | `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
| 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 > **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 > `features: ["teams"]` *unconditionally* (`MetaEndpoints.cs`). It was meaningless when nothing implemented
@@ -295,6 +295,31 @@ public sealed partial class VaultSession : IAsyncDisposable
return engine.SyncAsync(vaultId, cancellationToken); return engine.SyncAsync(vaultId, cancellationToken);
} }
/// <summary>
/// Moves one vault's stored items onto its current key.
/// </summary>
/// <param name="api">The transport. Supplied per call, as <see cref="SyncAsync"/> takes its own.</param>
/// <param name="vaultId">The vault to re-seal.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// What a rotation leaves to be finished. Rotating re-keys the vault and not its contents, so until
/// this has run the items already stored are still sealed under keys a departed member may hold. It
/// is resumable, so a pass that fails part way is re-run rather than recovered — see
/// <see cref="VaultResealer"/>.
/// </remarks>
public Task<ResealReport> ResealVaultAsync(
ISyncApi api,
Guid vaultId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var resealer = new VaultResealer(api, Items, Outbox, keyring, clock, options);
return resealer.ResealAsync(vaultId, cancellationToken);
}
/// <summary> /// <summary>
/// Runs one synchronisation pass over every vault this session can read. /// Runs one synchronisation pass over every vault this session can read.
/// </summary> /// </summary>
+43 -13
View File
@@ -55,16 +55,25 @@ public sealed record VaultShareReport(
/// to discover. /// to discover.
/// </param> /// </param>
/// <param name="Failure">Why the rotation itself did not happen, when it did not.</param> /// <param name="Failure">Why the rotation itself did not happen, when it did not.</param>
/// <param name="Reseal">
/// What moving the vault's stored items onto the new key achieved, or null when the rotation did not
/// get that far. A rotation without this has re-keyed the vault and not its contents, which is a
/// different guarantee — see <see cref="VaultResealer"/>.
/// </param>
public sealed record VaultRekeyReport( public sealed record VaultRekeyReport(
Guid VaultId, Guid VaultId,
string Name, string Name,
uint KeyGeneration, uint KeyGeneration,
IReadOnlyList<Guid> Shared, IReadOnlyList<Guid> Shared,
IReadOnlyList<(Guid UserId, string Reason)> NotShared, IReadOnlyList<(Guid UserId, string Reason)> NotShared,
Exception? Failure) Exception? Failure,
ResealReport? Reseal = null)
{ {
/// <summary>Whether the vault moved to a new key.</summary> /// <summary>Whether the vault moved to a new key.</summary>
public bool Rotated => Failure is null && KeyGeneration > 0; public bool Rotated => Failure is null && KeyGeneration > 0;
/// <summary>Whether everything in the vault is now sealed under that new key.</summary>
public bool Sealed => Reseal is { Complete: true };
} }
/// <summary> /// <summary>
@@ -226,6 +235,9 @@ public sealed partial class VaultSession
/// </summary> /// </summary>
/// <param name="grants">The grant calls.</param> /// <param name="grants">The grant calls.</param>
/// <param name="directory">The directory and the key log that makes it checkable.</param> /// <param name="directory">The directory and the key log that makes it checkable.</param>
/// <param name="sync">
/// The synchronisation calls, for the last step: moving what is already stored onto the new key.
/// </param>
/// <param name="vaultId">The vault to rotate.</param> /// <param name="vaultId">The vault to rotate.</param>
/// <param name="recipients"> /// <param name="recipients">
/// Who should hold the new key. The caller's own id may be in here and is ignored: this session /// Who should hold the new key. The caller's own id may be in here and is ignored: this session
@@ -234,23 +246,30 @@ public sealed partial class VaultSession
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Two acts, and only the first is atomic.</b> The generation advances in one server transaction, /// <b>Three acts, and only the first is atomic.</b> The generation advances in one server
/// so there is no moment at which two clients disagree about which key is current. Wrapping it to /// transaction, so there is no moment at which two clients disagree about which key is current.
/// each remaining member is a separate call per member, each verified against the key log the same /// Wrapping it to each remaining member is a separate call per member, each verified against the key
/// way an ordinary share is — and any of them can fail. A member who was missed holds the vault's /// log the same way an ordinary share is — and any of them can fail. A member who was missed holds
/// history and cannot read anything written since, which the report says so the interface can too. /// the vault's history and cannot read anything written since, which the report says so the
/// interface can too.
/// </para> /// </para>
/// <para> /// <para>
/// <b>What a rotation is worth, stated honestly.</b> Nothing already stored is re-encrypted — only a /// <b>The third act is re-sealing what is already there</b>, and it is what makes the rotation worth
/// client holding both keys could, and that is deferred work. So this does not take back what the /// the name: until it has run, the vault's stored items are still sealed under keys the departed
/// departed member already has, and it does not re-seal the vault's history against the key they may /// member may have kept. It runs last for a reason — it needs the new key, and it is the only step
/// have kept. What it does is make everything written from now on unreadable to them. Retroactive /// that can be interrupted without leaving anything broken, because a vault at mixed generations
/// revocation is not achievable; rotate the credentials themselves. See ADR 0001. /// stays readable to everybody holding the grants. A pass that stops half way is re-run.
/// </para>
/// <para>
/// <b>What none of it can do</b> is take back what the departed member already pulled onto their own
/// machine. Retroactive revocation is not achievable; rotate the credentials themselves. See
/// ADR 0001.
/// </para> /// </para>
/// </remarks> /// </remarks>
public async Task<VaultRekeyReport> RekeyVaultAsync( public async Task<VaultRekeyReport> RekeyVaultAsync(
IVaultGrantApi grants, IVaultGrantApi grants,
IDirectoryApi directory, IDirectoryApi directory,
ISyncApi sync,
Guid vaultId, Guid vaultId,
IReadOnlyList<Guid> recipients, IReadOnlyList<Guid> recipients,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -258,6 +277,7 @@ public sealed partial class VaultSession
ObjectDisposedException.ThrowIf(disposed, this); ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants); ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory); ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(sync);
ArgumentNullException.ThrowIfNull(recipients); ArgumentNullException.ThrowIfNull(recipients);
if (!keyring.TryGet(vaultId, out _, out var keyGeneration)) if (!keyring.TryGet(vaultId, out _, out var keyGeneration))
@@ -297,8 +317,16 @@ public sealed partial class VaultSession
} }
} }
// Synced before re-sealing, and it is not tidiness. The pass rewrites each item against the
// version the server holds, so a mirror that is behind produces a batch of conflicts instead of
// a re-sealed vault — and the sync also carries out anything queued here, which the push path
// re-seals on the way rather than leaving to be found later.
await SyncAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);
var resealed = await ResealVaultAsync(sync, vaultId, cancellationToken).ConfigureAwait(false);
return new VaultRekeyReport( return new VaultRekeyReport(
vaultId, name, summary.KeyGeneration, shared, missed, Failure: null); vaultId, name, summary.KeyGeneration, shared, missed, Failure: null, resealed);
} }
/// <summary>Generates the next vault key, records it, and takes it into the keyring.</summary> /// <summary>Generates the next vault key, records it, and takes it into the keyring.</summary>
@@ -423,6 +451,7 @@ public sealed partial class VaultSession
public async Task<IReadOnlyList<VaultRekeyReport>> RekeyTeamVaultsAsync( public async Task<IReadOnlyList<VaultRekeyReport>> RekeyTeamVaultsAsync(
IVaultGrantApi grants, IVaultGrantApi grants,
IDirectoryApi directory, IDirectoryApi directory,
ISyncApi sync,
Guid teamId, Guid teamId,
IReadOnlyList<Guid> recipients, IReadOnlyList<Guid> recipients,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -430,6 +459,7 @@ public sealed partial class VaultSession
ObjectDisposedException.ThrowIf(disposed, this); ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants); ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory); ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(sync);
ArgumentNullException.ThrowIfNull(recipients); ArgumentNullException.ThrowIfNull(recipients);
var reports = new List<VaultRekeyReport>(); var reports = new List<VaultRekeyReport>();
@@ -440,7 +470,7 @@ public sealed partial class VaultSession
{ {
reports.Add( reports.Add(
await RekeyVaultAsync( await RekeyVaultAsync(
grants, directory, vault.VaultId, recipients, cancellationToken) grants, directory, sync, vault.VaultId, recipients, cancellationToken)
.ConfigureAwait(false)); .ConfigureAwait(false));
} }
catch (Exception exception) when (exception is not OperationCanceledException) catch (Exception exception) when (exception is not OperationCanceledException)
@@ -1086,7 +1086,7 @@ internal sealed partial class TeamsViewModel(
var reports = await open var reports = await open
.RekeyTeamVaultsAsync( .RekeyTeamVaultsAsync(
server.Grants, server.Directory, team.TeamId, remaining, cancellationToken) server.Grants, server.Directory, server.Sync, team.TeamId, remaining, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
if (reports.Count == 0) if (reports.Count == 0)
@@ -1102,29 +1102,7 @@ internal sealed partial class TeamsViewModel(
if (rotated.Count > 0) if (rotated.Count > 0)
{ {
// Says what a rotation is and is not worth, because the word promises more than it can sentences.AddRange(Describe(rotated));
// 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) if (failed.Count > 0)
@@ -1136,6 +1114,42 @@ internal sealed partial class TeamsViewModel(
return string.Join(" ", sentences); return string.Join(" ", sentences);
} }
/// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
private IEnumerable<string> Describe(List<VaultRekeyReport> rotated)
{
yield return
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
+ "written from now on is readable to them.";
// Two different promises, so two different sentences. A vault whose items were all moved onto
// the new key is closed to them completely; one where some were left is closed to what happens
// next, and the difference is not the interface's to blur.
var sealedUp = rotated.Count(report => report.Sealed);
yield return sealedUp == rotated.Count
? "Everything already stored was re-sealed under the new key too, so their old key opens "
+ "nothing."
: $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new "
+ "key; the rest still hold items under the old one and will be picked up next time. "
+ "Rotate the credentials that mattered either way.";
// 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)
{
yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or "
+ "they will stop seeing changes.";
}
}
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary> /// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
private string Name(Guid userId) => private string Name(Guid userId) =>
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString(); Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
+66 -1
View File
@@ -350,13 +350,16 @@ public sealed class SyncEngine
foreach (var operation in pending) foreach (var operation in pending)
{ {
var payload = await CurrentAsync(vaultId, operation, cancellationToken)
.ConfigureAwait(false);
operations.Add(new SyncPushOperation( operations.Add(new SyncPushOperation(
operation.OperationId, operation.OperationId,
operation.EntityType, operation.EntityType,
operation.EntityId, operation.EntityId,
operation.Operation, operation.Operation,
operation.ExpectedVersion, operation.ExpectedVersion,
operation.Payload, payload,
operation.Fields)); operation.Fields));
byOperationId[operation.OperationId] = operation; byOperationId[operation.OperationId] = operation;
@@ -393,6 +396,68 @@ public sealed class SyncEngine
pending.Count, conflicted, pending.Count == options.MaxOperationsPerPush); pending.Count, conflicted, pending.Count == options.MaxOperationsPerPush);
} }
/// <summary>
/// The payload to send, re-sealed under the current key if it was queued before a rotation.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing may reach the server under a superseded generation, and this is where that is
/// enforced.</b> A change queued offline is sealed under whatever key was current when it was made,
/// and a rotation can land in between — so sending it as it stands would store a brand-new item
/// under the key the person who was just removed still holds. The vault's own re-sealing pass cannot
/// help: it runs over what the server holds, and this has not been sent yet.
/// </para>
/// <para>
/// The revised payload is written back to the outbox before it goes, so a push whose answer is lost
/// is retried as the same bytes. Re-sealing on each attempt instead would produce a different
/// envelope every time, which is harmless on the wire and would leave the queued row disagreeing
/// with what the server may already have accepted.
/// </para>
/// </remarks>
private async Task<EncryptedPayload?> CurrentAsync(
Guid vaultId,
PendingOperation operation,
CancellationToken cancellationToken)
{
if (operation.Payload is not { } payload
|| !keyring.TryGet(vaultId, out _, out var generation)
|| payload.KeyGeneration >= generation)
{
return operation.Payload;
}
var version = SyncVersions.NextVersion(operation.ExpectedVersion);
var resealed = PayloadReseal.TryReseal(
keyring,
vaultId,
operation.EntityType,
operation.EntityId,
payload,
openAtVersion: version,
sealAtVersion: version);
if (resealed is null)
{
// The generation this change was queued under is one this session no longer holds. It goes
// as it stands: the server takes it either way, and a queued edit that cannot be re-sealed
// is still the user's work.
return payload;
}
await outbox.ReviseAsync(
operation.Sequence,
operation.Operation,
operation.ExpectedVersion,
resealed,
operation.Fields,
operation.Ancestor,
cancellationToken)
.ConfigureAwait(false);
return resealed;
}
/// <returns>Whether this answer warrants another push round.</returns> /// <returns>Whether this answer warrants another push round.</returns>
private async Task<bool> HandleAsync( private async Task<bool> HandleAsync(
Guid vaultId, Guid vaultId,
+414
View File
@@ -0,0 +1,414 @@
using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>What one re-sealing pass did.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="KeyGeneration">The generation everything was moved to.</param>
/// <param name="Resealed">Items now sealed under the current key.</param>
/// <param name="Deferred">
/// Items left alone because a local edit is queued for them. Not a failure: a queued change is re-sealed
/// as it is pushed, so these arrive at the current generation by another route.
/// </param>
/// <param name="Unreadable">
/// Items whose own generation this session holds no key for. They stay where they are — the alternative
/// is discarding an item nobody can read yet, which is the one outcome that cannot be undone.
/// </param>
/// <param name="Contested">
/// Items somebody else wrote while this pass was running. The server refused the version, and the next
/// pass picks them up against the version it left behind.
/// </param>
public sealed record ResealReport(
Guid VaultId,
uint KeyGeneration,
int Resealed,
int Deferred,
int Unreadable,
int Contested)
{
/// <summary>Whether every item in the vault is now sealed under its current key.</summary>
/// <remarks>
/// Deferred items count as finished. They are queued changes, and a queued change cannot reach the
/// server under a superseded key — <see cref="SyncEngine"/> re-seals it on the way out.
/// </remarks>
public bool Complete => Unreadable == 0 && Contested == 0;
/// <summary>Whether anything moved.</summary>
public bool MovedAnything => Resealed > 0;
}
/// <summary>
/// Moves a rotated vault's stored items onto its current key.
/// </summary>
/// <remarks>
/// <para>
/// <b>What a rotation on its own does not do.</b> Advancing a vault's generation re-keys the vault and
/// not its contents: every item stays sealed under the generation it was written with, readable by
/// anybody holding that generation's grant. That is what keeps a rotation cheap and safe — see
/// ADR 0010 — and it leaves one gap, which is this pass. Somebody who left with a copy of the old key
/// could still open old ciphertext they later got hold of. Once this has run, they cannot: every item
/// is sealed under a key issued after they went.
/// </para>
/// <para>
/// <b>The plaintext is never decoded.</b> An item is opened, and the same bytes are sealed again under
/// a fresh data key — no codec, no merge, no schema version. So an item written by a newer client
/// survives this untouched, where re-encoding it through this build's codec would silently drop the
/// fields this build has no concept of. It also means nothing here needs to know what an item *is*,
/// which is why one pass covers every type including the ones added later.
/// </para>
/// <para>
/// <b>Resumable by construction, because a vault at mixed generations is readable.</b> Each item is one
/// ordinary upsert against the version the server holds, so a pass that dies half way leaves a working
/// vault, and running it again picks up what is left. Nothing here is a transaction and nothing needs
/// to be.
/// </para>
/// </remarks>
public sealed class VaultResealer
{
private readonly ISyncApi api;
private readonly ItemStore items;
private readonly OutboxStore outbox;
private readonly VaultKeyring keyring;
private readonly TimeProvider clock;
private readonly SyncOptions options;
/// <summary>Creates the pass.</summary>
public VaultResealer(
ISyncApi api,
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
TimeProvider clock,
SyncOptions? options = null)
{
ArgumentNullException.ThrowIfNull(api);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(outbox);
ArgumentNullException.ThrowIfNull(keyring);
ArgumentNullException.ThrowIfNull(clock);
this.api = api;
this.items = items;
this.outbox = outbox;
this.keyring = keyring;
this.clock = clock;
this.options = options ?? SyncOptions.Default;
}
/// <summary>Re-seals everything in one vault that is not already on its current key.</summary>
/// <param name="vaultId">The vault.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// Answers with a report of zero for a vault this session cannot write to, rather than throwing.
/// Being rotated past and not yet re-wrapped is the ordinary state for a member between somebody
/// else's rotation and their own re-grant, and it is not this pass's business to complain about it.
/// </remarks>
public async Task<ResealReport> ResealAsync(Guid vaultId, CancellationToken cancellationToken)
{
if (!keyring.TryGet(vaultId, out _, out var generation))
{
return new ResealReport(vaultId, KeyGeneration: 0, 0, 0, 0, 0);
}
var queued = await QueuedAsync(vaultId, cancellationToken).ConfigureAwait(false);
var tally = new Tally();
var batch = new List<Pending>(options.MaxOperationsPerPush);
foreach (var entityType in ItemKinds.SyncedTypes)
{
var stored = await items
.ListAsync(vaultId, entityType, includeDeleted: false, cancellationToken)
.ConfigureAwait(false);
foreach (var item in stored.Where(item => Behind(item, generation)))
{
if (queued.Contains((entityType, item.EntityId)))
{
tally.Deferred++;
continue;
}
if (Move(vaultId, item) is not { } moved)
{
tally.Unreadable++;
continue;
}
batch.Add(moved);
if (batch.Count == options.MaxOperationsPerPush)
{
await SendAsync(vaultId, batch, tally, cancellationToken).ConfigureAwait(false);
}
}
}
await SendAsync(vaultId, batch, tally, cancellationToken).ConfigureAwait(false);
return new ResealReport(
vaultId, generation, tally.Resealed, tally.Deferred, tally.Unreadable, tally.Contested);
}
/// <summary>Whether an item is still sealed under a key the vault has moved past.</summary>
private static bool Behind(StoredItem item, uint generation) =>
item.Payload is { } payload && payload.KeyGeneration < generation;
/// <summary>Re-seals one item, or answers null when this session cannot open it.</summary>
private Pending? Move(Guid vaultId, StoredItem item)
{
var version = SyncVersions.NextVersion(item.Version);
var payload = PayloadReseal.TryReseal(
keyring, vaultId, item.EntityType, item.EntityId, item.Payload!, item.Version, version);
return payload is null ? null : new Pending(item, payload, version);
}
/// <summary>Sends a batch if there is one, and empties it.</summary>
private async Task SendAsync(
Guid vaultId,
List<Pending> batch,
Tally tally,
CancellationToken cancellationToken)
{
if (batch.Count == 0)
{
return;
}
var outcome = await PushAsync(vaultId, batch, cancellationToken).ConfigureAwait(false);
tally.Resealed += outcome.Applied;
tally.Contested += outcome.Contested;
batch.Clear();
}
/// <summary>The running counts, so the loop above stays one screen long.</summary>
private sealed class Tally
{
internal int Resealed { get; set; }
internal int Deferred { get; set; }
internal int Unreadable { get; set; }
internal int Contested { get; set; }
}
/// <summary>One item, re-sealed and waiting to be sent.</summary>
private sealed record Pending(StoredItem Item, EncryptedPayload Payload, int Version)
{
internal Guid OperationId { get; } = Guid.CreateVersion7();
}
/// <summary>What one batch achieved.</summary>
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]
private readonly record struct PushOutcome(int Applied, int Contested);
/// <summary>
/// Sends one batch and mirrors what the server accepted.
/// </summary>
/// <remarks>
/// <para>
/// The mirror is written here rather than left to the next pull, so that a pass followed immediately
/// by another does not re-seal everything a second time. It is the same row with the same plaintext
/// under a new key, so there is nothing for a reader to notice.
/// </para>
/// <para>
/// A conflict is counted and skipped. There is nothing to merge — this pass changes no content — and
/// re-reading the item at the version the other client left is exactly what the next pass does.
/// </para>
/// </remarks>
private async Task<PushOutcome> PushAsync(
Guid vaultId,
List<Pending> batch,
CancellationToken cancellationToken)
{
var operations = batch
.Select(pending => new SyncPushOperation(
pending.OperationId,
pending.Item.EntityType,
pending.Item.EntityId,
SyncOperation.Upsert,
pending.Item.Version,
pending.Payload,
pending.Item.Fields))
.ToList();
var response = await api
.SyncPushAsync(vaultId, new SyncPushRequest(operations), cancellationToken)
.ConfigureAwait(false);
var applied = 0;
var contested = 0;
foreach (var result in response.Results)
{
if (batch.Find(pending => pending.OperationId == result.OperationId) is not { } sent)
{
continue;
}
if (result.Status is not (SyncOperationStatus.Applied or SyncOperationStatus.Duplicate))
{
contested++;
continue;
}
applied++;
await items.SaveAsync(
sent.Item with
{
Version = result.Version ?? sent.Version,
ChangeSequence = result.ChangeSequence ?? sent.Item.ChangeSequence,
Payload = sent.Payload,
UpdatedAt = clock.GetUtcNow(),
},
cancellationToken)
.ConfigureAwait(false);
}
return new PushOutcome(applied, contested);
}
/// <summary>The items a local edit is already queued for.</summary>
private async Task<HashSet<(SyncEntityType Type, Guid EntityId)>> QueuedAsync(
Guid vaultId,
CancellationToken cancellationToken)
{
var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false);
return [.. pending.Select(operation => (operation.EntityType, operation.EntityId))];
}
}
/// <summary>
/// Moving one payload from the key it was sealed under to the one in force now.
/// </summary>
/// <remarks>
/// Used from two places, and both of them matter: the pass above, which walks a rotated vault, and the
/// push path, which cannot be allowed to send a change queued before a rotation under the key it was
/// queued with. Between them they are the guarantee that nothing reaches the server under a superseded
/// generation.
/// </remarks>
internal static class PayloadReseal
{
/// <summary>
/// Re-seals a payload under the vault's current key.
/// </summary>
/// <param name="keyring">The open keyring.</param>
/// <param name="vaultId">The vault.</param>
/// <param name="entityType">What kind of item this is; the AAD binds its resource type.</param>
/// <param name="entityId">The item.</param>
/// <param name="payload">The payload as it stands, sealed under an earlier generation.</param>
/// <param name="openAtVersion">The item version <paramref name="payload"/> is bound to.</param>
/// <param name="sealAtVersion">The version the result will be bound to.</param>
/// <returns>
/// The re-sealed payload, or <see langword="null"/> when this session cannot open the original —
/// which means one item stays where it is, and says nothing about the rest of the vault.
/// </returns>
/// <remarks>
/// A fresh data key, not the original one re-wrapped. The two cost the same here, because the AAD
/// binds the generation into the payload as well as into the key wrap and the envelope has to be
/// re-made either way — and a new key per version is the rule the whole item format is built on.
/// </remarks>
internal static EncryptedPayload? TryReseal(
VaultKeyring keyring,
Guid vaultId,
SyncEntityType entityType,
Guid entityId,
EncryptedPayload payload,
int openAtVersion,
int sealAtVersion)
{
if (!keyring.TryGetAt(vaultId, payload.KeyGeneration, out var previous)
|| !keyring.TryGet(vaultId, out var current, out var generation))
{
return null;
}
var resource = AadResourceTypes.For(entityType);
var dataKey = ItemKeys.TryUnwrapDataKey(
previous.Span,
payload.WrappedDataKey,
resource,
entityId,
payload.KeyGeneration,
(uint)openAtVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)openAtVersion);
if (plaintext is null)
{
return null;
}
try
{
return Seal(
plaintext, current.Span, resource, entityId, generation, (uint)sealAtVersion);
}
finally
{
// The one place in this file that holds an item's plaintext, and it holds every kind of
// item there is — a private key, a password, a snippet with a token pasted into it.
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
private static EncryptedPayload Seal(
ReadOnlySpan<byte> plaintext,
ReadOnlySpan<byte> vaultKey,
CryptoSpec.AadResourceType resource,
Guid entityId,
uint keyGeneration,
uint itemVersion)
{
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, resource, entityId, dataKeyId, keyGeneration, itemVersion);
var wrapped = ItemKeys.WrapDataKey(
dataKey, vaultKey, resource, entityId, keyGeneration, itemVersion);
return new EncryptedPayload(
envelope, wrapped, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -169,6 +169,11 @@ public sealed class TeamSharingTests : IAsyncLifetime
teams.Status.ShouldContain("Rotated", customMessage: teams.Status); teams.Status.ShouldContain("Rotated", customMessage: teams.Status);
teams.Status.ShouldContain("Platform secrets"); teams.Status.ShouldContain("Platform secrets");
// The last act of a rotation is moving what is already stored onto the new key. Proven by the
// bytes in DodoSSH.Client.Sync.Tests; what this asserts is that the shell asks for it at all,
// and says which of the two guarantees the user has ended up with.
teams.Status.ShouldContain("re-sealed under the new key", customMessage: teams.Status);
// Gone entirely, at every generation. A revocation that left the history behind would leave them // 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. // able to read everything written before they went, from a copy of the ciphertext.
server.GenerationsGranted(vaultId, leaving).ShouldBeEmpty(); server.GenerationsGranted(vaultId, leaving).ShouldBeEmpty();
@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain; using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage; using DodoSSH.Client.Storage;
using DodoSSH.Crypto; using DodoSSH.Crypto;
@@ -16,6 +17,9 @@ internal sealed class SyncDevice : IDisposable
private readonly ClientCacheFactory factory; private readonly ClientCacheFactory factory;
private readonly LocalCacheProtector protector; private readonly LocalCacheProtector protector;
private readonly FakeVaultServer server;
private readonly SyncOptions options;
private SyncDevice( private SyncDevice(
string name, string name,
ClientCacheFactory factory, ClientCacheFactory factory,
@@ -27,6 +31,8 @@ internal sealed class SyncDevice : IDisposable
Name = name; Name = name;
this.factory = factory; this.factory = factory;
this.protector = protector; this.protector = protector;
this.server = server;
this.options = options;
Keyring = keyring; Keyring = keyring;
Items = new ItemStore(factory, protector); Items = new ItemStore(factory, protector);
@@ -96,6 +102,15 @@ internal sealed class SyncDevice : IDisposable
internal Task<SyncReport> SyncAsync() => internal Task<SyncReport> SyncAsync() =>
Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); Engine.SyncAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
/// <summary>Moves everything this machine can see onto the vault's current key.</summary>
/// <remarks>
/// Built per call rather than held, as the engine is: it carries no state between passes, and one
/// per call is what the session does.
/// </remarks>
internal Task<ResealReport> ResealAsync() =>
new VaultResealer(server, Items, Outbox, Keyring, TimeProvider.System, options)
.ResealAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
internal Task<ItemListing<HostSecret>> ListAsync() => internal Task<ItemListing<HostSecret>> ListAsync() =>
Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); Hosts.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken);
@@ -274,6 +289,41 @@ internal sealed class SyncHarness : IDisposable
} }
} }
/// <summary>
/// Rotates the vault: a new key, taken by both machines, and a server that says so.
/// </summary>
/// <returns>The key the vault has just moved off, so a test can prove it no longer opens anything.</returns>
/// <remarks>
/// <para>
/// Stands in for the server call the real rotation makes. What matters here is the state it leaves —
/// a vault whose current generation is one past everything stored in it — and the grant round trip
/// that produces that state is <c>DodoSSH.Api.Tests</c>'s subject, not this suite's.
/// </para>
/// <para>
/// Each keyring gets its own copy of the bytes, because a keyring owns what it is handed and zeroes
/// it on disposal; sharing one array would leave the second machine holding a zeroed key at the end
/// of a test and produce failures that look like a decryption bug.
/// </para>
/// </remarks>
internal byte[] Rotate()
{
var generation = Server.KeyGeneration + 1;
First.Keyring.TryGetAt(VaultId, Server.KeyGeneration, out var previous).ShouldBeTrue();
var superseded = previous.ToArray();
var key = VaultKeys.Create();
First.Keyring.Adopt(VaultId, [.. key], generation);
Second.Keyring.Adopt(VaultId, [.. key], generation);
CryptographicOperations.ZeroMemory(key);
Server.KeyGeneration = generation;
return superseded;
}
/// <summary>Brings both devices up to date, twice, so the result is a settled state.</summary> /// <summary>Brings both devices up to date, twice, so the result is a settled state.</summary>
/// <remarks> /// <remarks>
/// Twice because one pass per device is not enough for a change made on one to be merged on the /// Twice because one pass per device is not enough for a change made on one to be merged on the
@@ -0,0 +1,224 @@
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Moving a rotated vault's stored items onto its current key.
/// </summary>
/// <remarks>
/// <para>
/// A rotation re-keys the vault and not its contents, which is what makes it cheap and safe (ADR 0010)
/// and what leaves this pass to be run. The claim it has to earn is narrow and testable: after it, the
/// key the vault has moved off opens nothing. Every test here that says "resealed" also checks that,
/// because a pass that re-wrapped everything under the same key would report exactly the same numbers.
/// </para>
/// <para>
/// The other half is the push path. A change queued before a rotation is sealed under the old key, and
/// sending it as it stands would put a brand-new item into the vault under the key the person who was
/// just removed still holds — the one hole a pass over stored items cannot see.
/// </para>
/// </remarks>
public sealed class VaultResealTests
{
[Fact]
public async Task ARotatedVault_MovesItsStoredItemsOntoTheNewKey()
{
using var harness = await SyncHarness.CreateAsync();
var web = await harness.First.CreateAsync(SyncHarness.Host("web-01"));
var db = await harness.First.CreateAsync(SyncHarness.Host("db-01"));
await harness.SettleAsync();
var superseded = harness.Rotate();
var report = await harness.First.ResealAsync();
report.Resealed.ShouldBe(2);
report.Complete.ShouldBeTrue();
report.KeyGeneration.ShouldBe(2u);
foreach (var entityId in (Guid[])[web, db])
{
var row = harness.Server.Find(entityId).ShouldNotBeNull();
row.Payload.KeyGeneration.ShouldBe(2u);
// The point of the whole pass: the key somebody left with opens nothing here any more.
HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull();
}
// And the vault still reads as itself — the plaintext was carried across, not re-encoded.
var hosts = await harness.First.HostsSortedAsync();
hosts.Select(host => host.Label).ShouldBe(["db-01", "web-01"]);
}
/// <remarks>
/// The pass is run after every rotation and can be run again at any time, so "nothing left to do"
/// has to be cheap and silent rather than a second round of writes. A pass that re-sealed on every
/// call would churn the vault's version numbers and hand every other client a pull per item.
/// </remarks>
[Fact]
public async Task ASecondPass_FindsNothingLeftToDo()
{
using var harness = await SyncHarness.CreateAsync();
await harness.First.CreateAsync(SyncHarness.Host("web-01"));
await harness.SettleAsync();
harness.Rotate();
(await harness.First.ResealAsync()).Resealed.ShouldBe(1);
var again = await harness.First.ResealAsync();
again.Resealed.ShouldBe(0);
again.Complete.ShouldBeTrue();
harness.Server.PushCount.ShouldBe(2, "an empty pass must not send a batch at all");
}
/// <remarks>
/// An item with an edit waiting to go is left alone by the pass and re-sealed by the push instead.
/// Doing it here as well would overwrite the user's queued work with the version the server holds,
/// which is the one thing a re-keying pass must never do.
/// </remarks>
[Fact]
public async Task AQueuedEdit_IsLeftToThePushPathAndStillLandsUnderTheNewKey()
{
using var harness = await SyncHarness.CreateAsync();
var entityId = await harness.First.CreateAsync(SyncHarness.Host("web-01"));
await harness.SettleAsync();
// Queued while the old key was current, and not yet pushed.
await harness.First.UpdateAsync(entityId, SyncHarness.Host("web-01", notes: "moved rack"));
var superseded = harness.Rotate();
var report = await harness.First.ResealAsync();
report.Deferred.ShouldBe(1);
report.Resealed.ShouldBe(0);
report.Complete.ShouldBeTrue("a queued change is not something this pass has left undone");
await harness.First.SyncAsync();
var row = harness.Server.Find(entityId).ShouldNotBeNull();
row.Payload.KeyGeneration.ShouldBe(2u);
HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull();
// The edit itself survived the re-sealing, which is the half that would be easy to lose.
var seen = await harness.Second.SyncAsync();
seen.Pulled.ShouldBeGreaterThan(0);
(await harness.Second.FindAsync(entityId)).Secret.Notes.ShouldBe("moved rack");
}
/// <remarks>
/// Somebody else writing an item mid-pass is not a failure and not a merge — there is nothing to
/// merge, since this pass changes no content. It is counted, left where it is, and picked up by the
/// next pass against the version they left behind. That is the whole of the resumability claim.
/// </remarks>
[Fact]
public async Task AnItemWrittenElsewhereMeanwhile_IsCountedAndPickedUpNextTime()
{
using var harness = await SyncHarness.CreateAsync();
var entityId = await harness.First.CreateAsync(SyncHarness.Host("web-01"));
await harness.SettleAsync();
var superseded = harness.Rotate();
// A third machine that has not heard about the rotation yet: it writes version 2 under the key
// it still believes is current. That is the item this pass has to find and move, and sealing it
// by hand is the only way to produce one — every client in this harness now holds the new key.
var held = harness.Server.Find(entityId).ShouldNotBeNull();
harness.Server.ExternalUpsert(
entityId,
HostCipher.Seal(
SyncHarness.Host("web-01", notes: "renamed elsewhere"),
superseded,
entityId,
keyGeneration: 1,
itemVersion: held.Version + 1),
held.Fields);
var contested = await harness.First.ResealAsync();
contested.Contested.ShouldBe(1);
contested.Resealed.ShouldBe(0);
contested.Complete.ShouldBeFalse();
// Read what they wrote, then run the pass again: nothing to recover, nothing to decide.
await harness.First.SyncAsync();
var second = await harness.First.ResealAsync();
second.Resealed.ShouldBe(1);
second.Complete.ShouldBeTrue();
var row = harness.Server.Find(entityId).ShouldNotBeNull();
row.Payload.KeyGeneration.ShouldBe(2u);
HostCipher.TryOpen(row.Payload, superseded, entityId, row.Version).ShouldBeNull();
}
/// <remarks>
/// Every synced type, not the one the tests happen to use most. The pass is written over the item
/// store rather than over the repositories precisely so that a type added later is covered without
/// anybody remembering to add it — and this is the test that would notice if it stopped being true.
/// </remarks>
[Fact]
public async Task EveryKindOfItem_MovesOntoTheNewKey()
{
using var harness = await SyncHarness.CreateAsync();
await harness.First.CreateAsync(SyncHarness.Host("web-01"));
await harness.First.CreateKeyAsync(SyncHarness.Key("deploy"));
await harness.First.CreateCredentialAsync(SyncHarness.Credential("registry"));
await harness.First.CreateKnownHostAsync(SyncHarness.KnownHost("db.internal"));
await harness.SettleAsync();
harness.Rotate();
var report = await harness.First.ResealAsync();
report.Resealed.ShouldBe(4);
report.Complete.ShouldBeTrue();
// Read back through the repositories, so this asserts the items are usable and not merely
// rewritten: a pass that produced ciphertext nobody could open would pass every count above.
(await harness.First.ListAsync()).Unreadable.ShouldBe(0);
(await harness.First.ListKeysAsync()).Unreadable.ShouldBe(0);
(await harness.First.ListCredentialsAsync()).Unreadable.ShouldBe(0);
(await harness.First.ListKnownHostsAsync()).Unreadable.ShouldBe(0);
}
/// <remarks>
/// A member who has been rotated past and not yet re-wrapped holds the history and no current key.
/// They must not attempt this: there is nothing to seal under, and the honest answer is a report of
/// zero rather than an exception on a background pass nobody asked for.
/// </remarks>
[Fact]
public async Task AMachineWithNoCurrentKey_DoesNothingRatherThanFailing()
{
using var harness = await SyncHarness.CreateAsync();
await harness.First.CreateAsync(SyncHarness.Host("web-01"));
await harness.SettleAsync();
// What RefreshVaultsAsync does when the server reports a generation this machine has no grant
// for: the vault is marked unreadable and the write target goes with it.
harness.First.Keyring.MarkUnreadable(SyncHarness.VaultId);
var report = await harness.First.ResealAsync();
report.KeyGeneration.ShouldBe(0u);
report.Resealed.ShouldBe(0);
harness.Server.PushCount.ShouldBe(1, "nothing was sent");
}
}