Public Access
Merge branch 'main' into the vaults screen, and let it rotate keys too
Main built vault key rotation while this branch was reshaping the screen that would drive it, so the two met in the same three files. Every other conflict was textual and resolved by taking both; these are the ones where a decision had to be made. **The view model.** Main taught TeamsViewModel three things and this branch had renamed and rewritten it into VaultsViewModel. All three are ported rather than dropped, because each is a behaviour rather than wording: adding somebody now wraps the vault to them on the spot instead of leaving SHARE KEY to be pressed, removing somebody rotates the vault and hands the new key to whoever is left, and a share reports how many generations were wrapped. The session calls they reach — ShareTeamVaultsAsync and RekeyTeamVaultsAsync — are scoped to a membership list rather than to one vault, and they are called that way here rather than narrowed: adding somebody is a change to the list, so every vault the list carries is one they can now fetch. This screen makes lists that carry one vault, so the sentences name one; where a list carries several, naming them all is the honest report, and the members section already says the list is shared. AddMemberAsync ran two lines over the length limit once the sharing was in it, so the calls behind it moved to AddOrInviteAsync and the three-way refusal to WhyNobodyCanBeAdded — the command reads as its guards now, which is what it was before the sharing arrived. **The tests.** Main's four new cases are ported to the vault-first API, including the one that matters most: the tampered key log is corrupted *before* the add, because the add is now a route to a wrap and a test that corrupted it afterwards would be asserting about the manual route only. SelectingAVault_ListsWhoHoldsAKey now expects two holders rather than one — main's fake records the creator's own self-grant, and a key-holder list that omitted it would show the one person who can certainly open a new vault as somebody who cannot. **The README.** The limits list is six rather than four or five: main's rotation entries and this branch's "a vault cannot be deleted" describe different things and both are true. "The rekey is flagged, never performed" is gone, since it is now performed, and M3 reads *Done* rather than *Done, except rekey*. One thing worth writing down that neither side had. An invitation claimed at sign-in still leaves the key owed, where an add does not: at the moment an invitation is issued there is no account and no published key to wrap to, and the claim happens on the invitee's machine, which holds nothing. Manual check 12.1 says so, because a reader who knows adding shares would otherwise read that step as stale. 1561 tests pass.
This commit is contained in:
@@ -279,6 +279,11 @@ jobs:
|
|||||||
# Packaging rather than only compiling, because the two failures this head is most exposed to
|
# Packaging rather than only compiling, because the two failures this head is most exposed to
|
||||||
# are both link-time: a native library with no android ABI, and a managed assembly that
|
# are both link-time: a native library with no android ABI, and a managed assembly that
|
||||||
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
||||||
|
#
|
||||||
|
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
|
||||||
|
# docs/adr/0010-android-distribution.md puts the release key on a machine that is not a runner,
|
||||||
|
# because a signing key reachable from a workflow is a key held by everyone who can change one.
|
||||||
|
# This APK is a build check. It is not something anybody installs.
|
||||||
- name: package
|
- name: package
|
||||||
run: >
|
run: >
|
||||||
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
dotnet build src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj
|
||||||
|
|||||||
@@ -186,10 +186,15 @@ signing in again brings it all back; the count in the confirmation is the one th
|
|||||||
else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine
|
else. Your session at the identity provider is *not* ended — DodoSSH has no way to end it — so on a machine
|
||||||
that is not yours, sign out there too.
|
that is not yours, sign out there too.
|
||||||
|
|
||||||
Two of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: password
|
**Neither a password nor a passphrase has to be typed twice**, and both ways out of that are opt-in. A
|
||||||
authentication asks for the password every time, because nothing in the interface can create a vault
|
password typed to connect is typed once: tick **Remember this password** under the box and it is saved to
|
||||||
credential yet (they do sync — there is just no editor for one); and unlock asks for the passphrase on every
|
your keychain and bound to that host the moment the remote accepts it — or add one outright with **+
|
||||||
launch, because no device key is registered.
|
PASSWORD** on the Vault screen. And unlock can be a Windows confirmation instead of the passphrase:
|
||||||
|
Preferences → *This machine* → **REGISTER** keeps this machine's device key in the TPM, so a later launch
|
||||||
|
offers **USE WINDOWS HELLO** on the unlock card. A machine with no TPM — and any desktop that is not
|
||||||
|
Windows — is offered neither button and keeps asking for the passphrase, which Preferences says out loud
|
||||||
|
rather than leaving you to notice. The passphrase never stops working either way: a declined confirmation
|
||||||
|
leaves the box exactly where it was.
|
||||||
|
|
||||||
### Moving files
|
### Moving files
|
||||||
|
|
||||||
@@ -297,19 +302,31 @@ rather than only in the ID token or the userinfo response.
|
|||||||
they become owner and you become an admin, in a single transaction. Not two role changes — promoting first
|
they become owner and you become an admin, in a single transaction. Not two role changes — promoting first
|
||||||
leaves it owned twice, demoting first leaves it owned by nobody, and there is nobody with the authority to
|
leaves it 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
|
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 the vault for rekey, and somebody handing a vault over
|
key grants; removing you would revoke them and rotate the vault, and somebody handing a vault over is
|
||||||
is usually staying in it.
|
usually staying in it.
|
||||||
|
|
||||||
Five limits, stated rather than discovered:
|
Six limits, stated rather than discovered:
|
||||||
|
|
||||||
- **A vault cannot be deleted.** Nothing in this product removes one, and the server refuses to archive the
|
- **A vault cannot be deleted.** Nothing in this product removes one, and the server refuses to archive the
|
||||||
membership list behind a vault that still exists — a shared vault is readable *because* of membership, so
|
membership list behind a vault that still exists — a shared vault is readable *because* of membership, so
|
||||||
archiving it would take the vault away from everybody holding a key, including you, quietly and all at
|
archiving it would take the vault away from everybody holding a key, including you, quietly and all at
|
||||||
once. The screen says so where you would otherwise go looking for the button.
|
once. The screen says so where you would otherwise go looking for the button.
|
||||||
- **Removing a member is not retroactive.** It revokes their grants and flags the vault for rekey, and
|
- **Removing a member is not retroactive.** It revokes their grants, rotates every vault behind that
|
||||||
blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials that
|
membership list your machine can open, and hands each new key to the members who are left — so nothing
|
||||||
matter — that is the actual remediation, and it is why there is no button labelled anything stronger.
|
written from that point on is readable to them. Everything they already pulled is still on their machine.
|
||||||
- **The rekey is flagged, never performed.** See the milestone note above.
|
Rotate the SSH credentials that matter — that is the actual remediation, and it is why there is no button
|
||||||
|
labelled anything stronger.
|
||||||
|
- **A rotation moves the vault's contents too, and says so when it could not.** The key changes first, in
|
||||||
|
one server transaction; then every item already stored is re-sealed under it, so the key somebody left
|
||||||
|
with opens nothing that is still here. Existing items keep working throughout — everybody still in the
|
||||||
|
vault holds the older keys as well as the new one, which is what stops a half-finished rotation making a
|
||||||
|
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 somebody 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 shared vault's host is recorded and
|
- **Host key trust stays in your personal vault.** A pin approved for a shared vault's host is recorded and
|
||||||
used from your own vault, not the shared one, so a colleague cannot pre-approve a fingerprint that your
|
used from your own vault, not the shared one, so a colleague 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 shared
|
client will then trust silently for a host you defined. The cost is that each member approves a shared
|
||||||
@@ -372,8 +389,20 @@ design's blue-tinted `#E3E7F4`, and the type scale is a point larger.
|
|||||||
|
|
||||||
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
|
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
|
||||||
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
|
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
|
||||||
put in one — moving a file *in* from the phone needs the system document picker and is the next piece of
|
put in one. So the way in is **ADD FILES**, which is the system document picker: point at a document
|
||||||
work rather than a thing the screen pretends to do. Hosts and groups are made and corrected here now, from
|
wherever it lives and it goes to the directory showing, rather than choosing on the left and pressing an
|
||||||
|
arrow. What Android hands back is a `content://` URI with no path behind it and no promise of a seek, so
|
||||||
|
the document is copied into the app's own cache and the copy is what the queue moves — which is what lets
|
||||||
|
a stopped upload resume from where it stopped. The copy is deleted when the transfer finishes, kept while
|
||||||
|
it is stopped so RESUME has something to read, and swept at the next launch.
|
||||||
|
|
||||||
|
**SAVE FILE is the way back out**, and it is the system's save picker for the same reason: there is nowhere
|
||||||
|
this application could put a file that you would then be able to open. You choose where it goes before the
|
||||||
|
transfer starts, the download runs into the cache, and the finished bytes are copied out to the document you
|
||||||
|
chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the
|
||||||
|
picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The
|
||||||
|
alternative is a picker that appears minutes later over whatever you moved on to — and often while the app
|
||||||
|
is in the background, where Android will not show one at all. Hosts and groups are made and corrected here now, from
|
||||||
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
|
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
|
||||||
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
|
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
|
||||||
created on the desktop and sync down, and the phone will delete an item — behind the same counted
|
created on the desktop and sync down, and the phone will delete an item — behind the same counted
|
||||||
@@ -518,10 +547,15 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
host key approved at that shell's prompt reaching the second machine as well. See
|
host key approved at that shell's prompt reaching the second machine as well. See
|
||||||
[End-to-end verification](#end-to-end-verification).
|
[End-to-end verification](#end-to-end-verification).
|
||||||
|
|
||||||
Known gaps in the client, stated rather than implied by the interface: nothing in the interface can create
|
The two gaps this milestone shipped with have both closed since. A vault credential can be created — from
|
||||||
a vault credential yet, so password authentication still asks for the password each time — SSH keys *are*
|
the Vault screen, or from the REMEMBER tick beside the connect password, which saves it and binds the host
|
||||||
editable, and binding one to a host is the way to connect without typing anything; and no device key is
|
to it once the remote has accepted — so password authentication asks once rather than every time. And a
|
||||||
registered, so the passphrase is needed on every launch until the OS keystore is wired.
|
device key is registered where the machine can hold one: Windows keeps it in the TPM under a CNG policy
|
||||||
|
that makes the consent dialog a condition of *using* the key rather than a prompt this application draws,
|
||||||
|
which is stronger than [ADR 0007](docs/adr/0007-device-key-protection.md) originally described and is why
|
||||||
|
that ADR was corrected. What is left is the floor rather than a gap: a machine with no TPM, or a desktop
|
||||||
|
that is not Windows, gets a store that reports itself unavailable and keeps asking for the passphrase —
|
||||||
|
the honest answer rather than a degraded one.
|
||||||
|
|
||||||
Host key trust *is* in the vault, which is what makes trust-on-first-use worth having: a fingerprint
|
Host key trust *is* in the vault, which is what makes trust-on-first-use worth having: a fingerprint
|
||||||
approved on one machine is approved on all of them and survives a restart, and the server cannot drop a
|
approved on one machine is approved on all of them and survives a restart, and the server cannot drop a
|
||||||
@@ -582,9 +616,9 @@ 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
|
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
|
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.
|
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
|
||||||
- **M3 — shared vaults**, sharing, ACLs. *Done, except rekey.* Membership with roles, a public-key
|
- **M3 — shared vaults**, sharing, ACLs. *Done.* Membership with roles, a public-key directory, the
|
||||||
directory, the append-only key log served for clients to verify against, shared vaults, and vault key
|
append-only key log served for clients to verify against, shared vaults, and vault key grants
|
||||||
grants wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
|
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
|
membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs
|
||||||
every vault it holds a key for, and a real VAULTS screen replaces the placeholder. The screen is
|
every vault it holds a key for, and a real VAULTS screen replaces the placeholder. The screen is
|
||||||
vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the
|
vault-shaped rather than team-shaped: naming a vault makes the membership list that carries it, so the
|
||||||
@@ -595,20 +629,30 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
knowing before you rely on it; the reasoning is in
|
knowing before you rely on it; the reasoning is in
|
||||||
[ADR 0009](docs/adr/0009-team-access-model.md).
|
[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
|
**Membership changes now move the keys, not just the flag.** Adding somebody wraps every team vault the
|
||||||
every team vault `RekeyRequired`, and nothing acts on that flag. A rekey re-wraps every item's data key
|
adding machine can open to them — every generation of each, so they can read the vault's history and not
|
||||||
under a fresh vault key and can only be performed by a client that holds the current one; that is M5's
|
only what happens next. Removing somebody revokes their grants, advances each vault it can open to a
|
||||||
key rotation. Until it lands the flag is what the interface reads to say a rotation is owed, which is
|
fresh key generation in one server transaction, and wraps that key to the members who remain. What a
|
||||||
more honest than a button that only appears to do it.
|
rotation buys is exact: everything written from then on is unreadable to the person who left. The items
|
||||||
|
already stored are then re-sealed under the new key as well, item by item and resumably — which is safe
|
||||||
|
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).
|
||||||
|
|
||||||
**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.
|
- **M4 — hardening and ops**, packaging, self-hosting guide. *Decided ahead of the work, because the first
|
||||||
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.
|
release takes it irreversibly:* who signs the client and where it comes from.
|
||||||
|
[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
|
||||||
|
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
|
||||||
|
release's decision to make and nobody else's afterwards.
|
||||||
|
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- **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.
|
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:
|
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
|
- **No `v_user_vault_permission` view.** ADR-adjacent notes and the old `VaultAccessService` remark
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## The second half: re-sealing what is already stored
|
||||||
|
|
||||||
|
> **Added 2026-08-04.** This was deferred when the decision above was taken, and is now built. The
|
||||||
|
> reasoning that made it safe to defer is what made it cheap to add, so it is recorded here rather
|
||||||
|
> than in an ADR of its own.
|
||||||
|
|
||||||
|
A rotation on its own re-keys the vault and not its contents, which leaves one gap: somebody who left
|
||||||
|
with a copy of the old key could still open old ciphertext they later got hold of. `VaultResealer`
|
||||||
|
closes it by walking the vault and rewriting each item under the current key, as an ordinary upsert
|
||||||
|
against the version the server holds.
|
||||||
|
|
||||||
|
Four properties, each of which is a decision:
|
||||||
|
|
||||||
|
- **It never decodes the plaintext.** 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 untouched, where re-encoding it through this build's codec would silently drop the fields
|
||||||
|
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
|
||||||
|
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# ADR 0011 — Distributing the Android client, and who holds the release key
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-03
|
||||||
|
- Builds on: [ADR 0001](0001-e2ee-trust-model.md)
|
||||||
|
- Settles: the second open question in [`docs/android-port.md`](../android-port.md#still-open)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[ADR 0001](0001-e2ee-trust-model.md) ends on the hole it cannot close with cryptography: **an operator
|
||||||
|
who wants the secrets attacks the client, not the crypto**, and what that costs is *release signing with
|
||||||
|
a key not held by the server*, and eventually reproducible builds. Until now that sentence had nothing
|
||||||
|
to bind to. The desktop head is not packaged at all — packaging is M4 — and the Android head is a debug
|
||||||
|
artefact: CI runs `-t:SignAndroidPackage` with no keystore, so it is signed with the debug key .NET for
|
||||||
|
Android falls back to when `AndroidKeyStore` is false, and that APK is a build check rather than
|
||||||
|
something anyone installs.
|
||||||
|
|
||||||
|
The first release changes that, and it does so **irreversibly**, which is why this is decided here
|
||||||
|
rather than at upload time. Two Android facts make it a one-way door:
|
||||||
|
|
||||||
|
- **An installed app can only be updated by a package signed with the same key.** The signing key is
|
||||||
|
the app's identity for its whole life; changing it means every existing user uninstalls first, losing
|
||||||
|
their local cache and re-enrolling. (v3 signature rotation exists, but the lineage has to be created
|
||||||
|
*before* it is needed, by the key it is rotating away from.)
|
||||||
|
- **A new app on Google Play must ship as an App Bundle, which means Play App Signing**, so Google
|
||||||
|
generates and holds the key that signs what users install; the developer holds an upload key only.
|
||||||
|
There is no un-enrolling. For an app that already exists outside Play, the only way to keep one
|
||||||
|
package id across both channels is to *hand Google the existing key*.
|
||||||
|
|
||||||
|
So "publish on Play" and "hold our own key" are not two settings. They are two package identities, and
|
||||||
|
the first release picks one.
|
||||||
|
|
||||||
|
The third party changes but the shape does not: **whoever can sign an update can ship one person a
|
||||||
|
build that copies the passphrase.** The vault's encryption is irrelevant to that attack — the client is
|
||||||
|
where the plaintext is, by construction (ADR 0001, `Connect` cannot be a security boundary). So this ADR
|
||||||
|
is about *which* parties are in that position, not about removing them, and there are three candidates:
|
||||||
|
the deployment operator, DodoTech, and Google.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**DodoTech holds the release key, the deployment never serves the client, and Play is a separate
|
||||||
|
decision that has not been taken.**
|
||||||
|
|
||||||
|
1. **One release key, held by the project, kept offline, and never in CI.** Release signing is a
|
||||||
|
deliberate manual step on a machine that is not a runner. CI keeps doing exactly what it does now —
|
||||||
|
packaging with the debug key to catch link-time failures — and must never gain a keystore secret or
|
||||||
|
an `AndroidKeyStore=true`. A signing key in CI is a key held by whoever can push a workflow file,
|
||||||
|
which for a public repository is a wider set than it looks.
|
||||||
|
|
||||||
|
2. **The APK is published on the project's own release page, and a DodoSSH deployment never distributes
|
||||||
|
it.** This is the refusal that carries the security content, and it is the one a self-hosted product
|
||||||
|
gets wrong by default: a "download the app" link on your own server is convenient, obvious, and hands
|
||||||
|
the client binary to the exact party ADR 0001 models as the adversary. The operator may tell people
|
||||||
|
where to get it. They may not be the place it comes from.
|
||||||
|
|
||||||
|
The same rule reaches the update path. A version check pointed at the deployment lets the operator
|
||||||
|
pin a chosen user to a known-vulnerable build by withholding the answer — a weaker attack than
|
||||||
|
signing one, and available without any key at all. If an update check is ever added it points at the
|
||||||
|
project's domain, and the first release simply has none: the release page is the channel and the
|
||||||
|
README says so.
|
||||||
|
|
||||||
|
3. **Play App Signing is not entered, and cannot be entered by accident.** Not because Google is a worse
|
||||||
|
custodian than DodoTech — on the mechanics it is a better one, since the key lives in Google's
|
||||||
|
infrastructure rather than on a laptop, and the reach and auto-update story is not close. It is
|
||||||
|
declined because of what it costs *this* product specifically: the buyers named in ADR 0001 are teams
|
||||||
|
who refuse to put infrastructure credentials in a SaaS, and telling them the client that holds their
|
||||||
|
plaintext is signed by a key the vendor cannot see is the same answer they already rejected, one
|
||||||
|
layer down. A targeted signed build compelled by a lawful order or produced from a compromised
|
||||||
|
console account is the archetype of the attack ADR 0001 calls the largest practical hole.
|
||||||
|
|
||||||
|
Deferring is cheap and reversing is not, so the default falls the deferrable way. Revisiting is a
|
||||||
|
second ADR, and it has two honest exits: hand Google the existing key and keep one identity, or take
|
||||||
|
a distinct package id and accept two apps. Both are worse decisions to discover than to take.
|
||||||
|
|
||||||
|
4. **Reproducible builds are the goal that makes all of the above matter less, and they are not
|
||||||
|
achievable today.** A build a third party can reproduce from source turns the signing key from a
|
||||||
|
trusted authority into a convenience — anyone can check that the published APK is the published
|
||||||
|
source. .NET for Android is not there: dex output, AOT images and archive timestamps are not
|
||||||
|
bit-reproducible across machines in practice. It stays the standing goal ADR 0001 names, recorded
|
||||||
|
here as the thing that would let point 3 be reconsidered on the merits rather than on custody.
|
||||||
|
|
||||||
|
5. **F-Droid is not a channel.** Its build server compiles from source and signs with its own key, which
|
||||||
|
would be a genuinely better transparency story — but it has no support for a .NET workload plus an
|
||||||
|
Android SDK toolchain, and this head needs both. Not refused; unavailable.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**The reach cost is real and should not be talked down.** Installing means enabling installation from
|
||||||
|
the browser or file manager, per source, on Android 8 and later — a permission the platform frames as
|
||||||
|
dangerous, correctly. There is no discovery, no automatic update, and no Play channel for a corporate
|
||||||
|
MDM to deploy from, which for a product sold to teams is the sharpest edge of this decision. What
|
||||||
|
partially answers it is that this client is installed by people who already run their own identity
|
||||||
|
provider and their own vault server; sideloading is not the strangest thing they will do that week.
|
||||||
|
|
||||||
|
**The key becomes a single point of failure with no recovery.** Losing it means existing installs can
|
||||||
|
never be updated again — not a bad update, *no* update — and the only way out is a new package id and a
|
||||||
|
manual migration. It is therefore backed up offline in more than one place, and a v3 rotation lineage is
|
||||||
|
created at the first release rather than at the first emergency, because a lineage can only be signed by
|
||||||
|
the key it replaces.
|
||||||
|
|
||||||
|
**The attack ADR 0001 names is narrowed, not removed.** DodoTech can still ship one user a malicious
|
||||||
|
build. What changes is that the deployment operator — the party the threat model is actually about, and
|
||||||
|
the one with a motive to read their own team's credentials — cannot, and that a compelled or breached
|
||||||
|
third-party store is not in the path either. That is the whole of what this decision buys, and it is
|
||||||
|
worth stating at that size rather than larger.
|
||||||
|
|
||||||
|
**M4's desktop packaging inherits rule 2 and not the rest.** Windows and macOS have no equivalent of
|
||||||
|
Play App Signing in the mandatory sense: Authenticode and Developer ID both leave the private key with
|
||||||
|
the developer, and Apple's notarization is a scan rather than a signature over the shipped binary. The
|
||||||
|
custody question is therefore easy there; the "not served by the deployment" rule is the part that
|
||||||
|
carries over, and it carries over unchanged.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- **Play as the primary channel, sideloading as the fallback.** This is the arrangement most Android
|
||||||
|
products land on, and it does not survive contact with rule 2's reasoning: it is the same shape —
|
||||||
|
the binary that holds the plaintext arriving through a party who can be compelled — with a larger and
|
||||||
|
better-resourced party in the middle. Better mechanics, same class.
|
||||||
|
- **Shipping the APK from the DodoSSH server it will talk to**, so a new phone gets the client from the
|
||||||
|
deployment it is enrolling against. Genuinely the nicest onboarding available, and it makes the
|
||||||
|
operator the distributor of the client that holds their team's credentials. Refused outright, and
|
||||||
|
named here because it will be proposed again.
|
||||||
|
- **A release key held by CI so tagging cuts a release.** The convenience is the point of CI and the
|
||||||
|
key is the point of this ADR; where they collide the key wins. Signing an artefact is one command, run
|
||||||
|
rarely, by a person.
|
||||||
+44
-13
@@ -28,7 +28,9 @@ verified is that it compiles, links, packages, and carries the right natives.
|
|||||||
|
|
||||||
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
|
**The shape agreed:** a **phone-first** client that is the keychain plus a **terminal**, with sessions and
|
||||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||||
is **one remote pane** with Android's document picker for moving files in and out.
|
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
||||||
|
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
||||||
|
with the foreground service now counting transfers as well as shells.
|
||||||
|
|
||||||
**What was actually checked**, so the rest can be read with the right amount of trust:
|
**What was actually checked**, so the rest can be read with the right amount of trust:
|
||||||
|
|
||||||
@@ -132,7 +134,9 @@ mean the transfer queue's local half (`LocalDirectory`, the drive list, the brea
|
|||||||
code.
|
code.
|
||||||
|
|
||||||
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
|
Note what *does* carry: `FileTransferQueue` itself, and `IRemoteFileStore` — Phase 6 already proved that
|
||||||
seam holds two very different remotes, and a `Uri`-backed Android document would be a third.
|
seam holds two very different remotes, and a `Uri`-backed Android document would be a third. (**⚠️ That last
|
||||||
|
clause is wrong, and the build is what corrected it**: a document URI cannot resume, so what shipped stages a
|
||||||
|
copy and hands over a path. See the note under the decision below.)
|
||||||
|
|
||||||
**Decided: one remote pane and the document picker**, and out of the first scope. See
|
**Decided: one remote pane and the document picker**, and out of the first scope. See
|
||||||
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
|
[Decisions](#file-transfer-when-it-comes-one-pane-and-the-document-picker).
|
||||||
@@ -256,6 +260,24 @@ and `IRemoteFileStore` both carry over unchanged. Phase 6 already put a bucket b
|
|||||||
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
|
an SFTP host, so a picker-granted document is a third implementation of a seam that has been exercised twice.
|
||||||
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
|
What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, the breadcrumb trail.
|
||||||
|
|
||||||
|
> **⚠️ Corrected by the build. Both directions shipped, and neither as a third `IRemoteFileStore`.** A document
|
||||||
|
> URI cannot sit behind that interface honestly: the queue seeks, because an upload resumes from the byte the
|
||||||
|
> last attempt reached, and a `content://` stream promises no seek, no stable length, and no grant that
|
||||||
|
> survives the document being edited underneath it. So `DocumentStaging` copies the chosen document into the
|
||||||
|
> application's cache and hands the queue an ordinary path — which cost one class in the head and *nothing*
|
||||||
|
> in the shared layers, rather than a third implementation of a seam and every resume rule rewritten to cope
|
||||||
|
> with a stream that cannot rewind. The copy is deleted when the transfer completes, kept while it is stopped
|
||||||
|
> so RESUME can read it, and swept at the next launch.
|
||||||
|
>
|
||||||
|
> **Outbound is the mirror image, with one decision the inbound half did not have to take: when to ask.** The
|
||||||
|
> save picker is raised *before* the transfer, so `QueueDeliveredDownload` runs into the same staging
|
||||||
|
> directory and hands the finished bytes to a callback the head supplied. Asking afterwards would put the
|
||||||
|
> picker minutes away from the button that caused it and, on a phone, frequently in the background — where
|
||||||
|
> Android will not show one. The cost is that `ACTION_CREATE_DOCUMENT` creates its file when it is
|
||||||
|
> dismissed, so a download that then fails leaves an empty one; that is said on the screen and in the
|
||||||
|
> README rather than left to be found. A delivery that fails keeps the staged bytes for the sweep instead of
|
||||||
|
> deleting the one copy of something that was just fetched over somebody's network.
|
||||||
|
|
||||||
### Sessions survive backgrounding, via a foreground service
|
### Sessions survive backgrounding, via a foreground service
|
||||||
|
|
||||||
A persistent notification for as long as a shell or a transfer is live.
|
A persistent notification for as long as a shell or a transfer is live.
|
||||||
@@ -577,9 +599,11 @@ What is left, in the order it matters:
|
|||||||
|
|
||||||
- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
|
- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
|
||||||
ever been launched on hardware or an emulator. Everything below is reasoning from the code.
|
ever been launched on hardware or an emulator. Everything below is reasoning from the code.
|
||||||
- **The document picker.** The files screen browses a remote, downloads from it and runs the queue; putting
|
- ~~**The save picker — the other half of file transfer.**~~ **Built**, along with the half before it: both
|
||||||
a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped
|
pickers go through Avalonia's storage provider and both stage through the cache. See the correction under
|
||||||
source, which is the decision recorded above and the obvious next piece of work.
|
the decision above for the shape and for the one thing about it a person will notice — an empty file where
|
||||||
|
a failed download was pointed. What is *not* built is a folder picker for several downloads at once: the
|
||||||
|
save picker names one destination, so SAVE FILE takes the selected row.
|
||||||
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
|
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
|
||||||
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
|
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
|
||||||
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
||||||
@@ -590,7 +614,9 @@ What is left, in the order it matters:
|
|||||||
|
|
||||||
## Still open
|
## Still open
|
||||||
|
|
||||||
Neither of these blocks the spike, and both want answering before there is anything to release.
|
Neither of these blocked the spike, and both wanted answering before there was anything to release. Both
|
||||||
|
now have answers, kept here under the questions that produced them — the heading stays because what is
|
||||||
|
worth reading is which way each went and why, not that the list is empty.
|
||||||
|
|
||||||
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
|
- ~~**Which Android versions.**~~ **✅ Settled: minSdk 28, targetSdk 36**, and the reasoning divided in two.
|
||||||
|
|
||||||
@@ -606,13 +632,18 @@ Neither of these blocks the spike, and both want answering before there is anyth
|
|||||||
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
|
devices that mostly cannot hold a hardware-backed key anyway — which is the one thing the store is for.
|
||||||
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.** ADR 0001 says plainly that an
|
- ~~**How it is distributed, and what that does to the supply-chain story.**~~ **✅ Settled in
|
||||||
operator who wants the secrets attacks the client rather than the crypto, and that release signing with a
|
[ADR 0011](adr/0011-android-distribution.md): the project holds the release key, the deployment never
|
||||||
key **not held by the server** is what that costs. Play App Signing means Google holds the release key.
|
serves the client, and Play is deferred.** The question was whether Play App Signing — Google generating
|
||||||
That is not necessarily wrong — it is a different, and in some ways better-audited, trust arrangement —
|
and holding the release key — is a change to the security property ADR 0001 documents. It is, and the ADR
|
||||||
but it is a change to a documented security property of this product, and it should be reasoned about in
|
takes it as a one-way door rather than a setting: a new Play app must use App Bundles and therefore Play
|
||||||
an ADR rather than discovered at upload time. Sideloading a self-signed APK preserves the current story and
|
App Signing, an installed app can only be updated by a package signed with the same key, so the first
|
||||||
costs reach.
|
release picks one identity for good.
|
||||||
|
|
||||||
|
Two things follow for this head *now*, before there is anything to release. CI keeps signing with the
|
||||||
|
debug key and must never gain a keystore secret. And no download link for the APK may ever be served by a
|
||||||
|
DodoSSH deployment — that hands the client binary to the party the threat model is about, which is a
|
||||||
|
worse arrangement than either of the two the question was originally between.
|
||||||
|
|
||||||
## Smaller things, decided by default
|
## Smaller things, decided by default
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -216,13 +216,27 @@ This is the load-bearing structural choice. Because every wrap protects the *sam
|
|||||||
|
|
||||||
### Why a per-item DataKey
|
### Why a per-item DataKey
|
||||||
|
|
||||||
1. **Cheap rotation.** Rotating a vault key re-wraps N × 32-byte data keys and never touches
|
1. **Cheap rotation.** Re-keying N items 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.
|
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.
|
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.
|
3. **Nonce hygiene.** Each key encrypts about one message.
|
||||||
4. **Versioning.** A new item version gets a new data key, so prior ciphertext stays
|
4. **Versioning.** A new item version gets a new data key, so prior ciphertext stays
|
||||||
independently decryptable for history and undo.
|
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.
|
||||||
|
>
|
||||||
|
> 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
|
||||||
exists from the first migration so that lands without a migration. Until then, **an item ACL
|
exists from the first migration so that lands without a migration. Until then, **an item ACL
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. |
|
> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. |
|
||||||
> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. |
|
> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. |
|
||||||
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
|
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
|
||||||
> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`, and `LocalPath` starts at `SpecialFolder.UserProfile`, which on Android is the application's own private directory: a download would report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. |
|
> | SFTP: the transfer card, and moving files at all | ◆ **Both directions ship, and neither is the button the design drew.** There is no local pane for an UPLOAD arrow to point away from, so the gestures are the platform's two pickers: ADD FILES is `ACTION_OPEN_DOCUMENT` — "point at a document wherever it lives" — and SAVE FILE is `ACTION_CREATE_DOCUMENT`, asked *before* the transfer because a picker raised on completion arrives over whatever the person moved on to, and often while the app is backgrounded and Android will not show one. Both stage through the cache, for the same reason: a `content://` URI has no path, no trustworthy length and no promised seek, and the queue needs all three to resume. `QueueDownloads` — the desktop's path, writing into `LocalPath` — is unused here, because `LocalPath` on Android is the app's own private directory. The queue is drawn now, since this head can fill it. |
|
||||||
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
|
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
|
||||||
> | A `+` floating button on **HOSTS** | **Shipped in v3.** It was omitted twice, on the grounds that it would open a host editor this head had not got — and that was true until one existed. It raises a sheet rather than a form, because "add" on this screen is two operations: a host, or a group to file hosts under. |
|
> | A `+` floating button on **HOSTS** | **Shipped in v3.** It was omitted twice, on the grounds that it would open a host editor this head had not got — and that was true until one existed. It raises a sheet rather than a form, because "add" on this screen is two operations: a host, or a group to file hosts under. |
|
||||||
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
|
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
|
||||||
@@ -429,9 +429,12 @@ 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. |
|
| 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. |
|
| 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. |
|
| 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). |
|
||||||
|
| 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 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. |
|
| `LAST ACTIVE` | server | Real, and coarse on purpose. `UserAccount.LastSeenAtUtc` is now refreshed on ordinary authenticated requests, at most once per account per hour: writing it per request would put an UPDATE on the hot path of every authenticated call and start losing races on `user_account`'s own concurrency token. So the column answers "this week or not", which is the granularity the question is actually asked at, and is shown coarsely rather than to the minute. |
|
||||||
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. |
|
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. |
|
||||||
|
|
||||||
@@ -443,7 +446,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. |
|
||||||
| 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. |
|
|
||||||
|
|
||||||
> **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
|
||||||
|
|||||||
+124
-1
@@ -1158,7 +1158,11 @@ follows is about what happens the first time it does.
|
|||||||
and Bob is now in the members table.
|
and Bob is now in the members table.
|
||||||
6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is
|
6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is
|
||||||
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
|
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
|
||||||
opens.
|
opens. This is where an invitation differs from an add and the difference is not a defect: adding an
|
||||||
|
account the directory already knows wraps the key on the spot, because the adding machine has it and
|
||||||
|
the recipient has a published key to wrap to. An invitation has neither at the moment it is issued —
|
||||||
|
there is no account yet — and the claim happens on Bob's machine, which holds nothing. So the key is
|
||||||
|
still owed, and somebody has to hand it over.
|
||||||
|
|
||||||
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
|
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
|
||||||
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
|
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
|
||||||
@@ -1342,3 +1346,122 @@ rather than two. There is no confirmation prompt, deliberately.
|
|||||||
|
|
||||||
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
||||||
half that matters when the handset is the thing that was lost.
|
half that matters when the handset is the thing that was lost.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 14 — Moving files to and from the phone's remote
|
||||||
|
|
||||||
|
Every check here needs a real Android device or emulator, a host with SFTP or a bucket, and at least one
|
||||||
|
document on the phone to send. What is automated is what can be: `TransferQueueingTests` says a staged
|
||||||
|
upload obeys the same rules as any other and that a delivered download refuses a directory before the
|
||||||
|
picker's damage is done. What cannot be automated is everything below — the two system pickers are another
|
||||||
|
application, and the staging copies, the delivery, the notification and the resume are all things only a
|
||||||
|
running phone does.
|
||||||
|
|
||||||
|
### 14.1 ADD FILES opens the system picker, and takes more than one
|
||||||
|
|
||||||
|
Connect to a host on SFTP, navigate somewhere writable, press **ADD FILES**, and choose two documents in one
|
||||||
|
go — long-press to multi-select in Android's picker.
|
||||||
|
|
||||||
|
**Pass:** two rows appear in the queue with the names the picker showed, and both land in the directory the
|
||||||
|
breadcrumb names. The pane's listing shows them after **↻**.
|
||||||
|
|
||||||
|
**Failure means:** one row from a two-document pick is `PickAsync` losing the rest, and two rows with one
|
||||||
|
name is the per-file staging directory having gone — that is the overwrite `DocumentStaging` documents, and
|
||||||
|
it silently uploads the same bytes twice.
|
||||||
|
|
||||||
|
### 14.2 The name that arrives is the name that was picked · **the one most likely to be wrong**
|
||||||
|
|
||||||
|
Pick a document whose display name has a space and a non-Latin character in it, and one from a cloud
|
||||||
|
provider — Drive, or the Downloads shortcut — rather than local storage.
|
||||||
|
|
||||||
|
**Pass:** the file on the host is called what the picker called it. A cloud document uploads too, or fails
|
||||||
|
with the provider's own message in the status line rather than a crash.
|
||||||
|
|
||||||
|
**Failure means:** a mangled name is `SafeName` over-reaching. A name that reaches the host with a `/` in it
|
||||||
|
is `SafeName` under-reaching, and that one writes to a path nobody chose. A cloud document that hangs is
|
||||||
|
the copy being made on the interface thread — the whole reason `CopyInAsync` leaves it.
|
||||||
|
|
||||||
|
### 14.3 The queue is bounded, and the buttons stay reachable
|
||||||
|
|
||||||
|
Queue five or six files at once, on a small phone if there is one.
|
||||||
|
|
||||||
|
**Pass:** the queue scrolls inside its own region and **ADD FILES**, **DELETE** and **CLOSE** are all still
|
||||||
|
on screen. Every button is a thumb's size.
|
||||||
|
|
||||||
|
**Failure means:** buttons pushed off the bottom is the `MaxHeight` gone from the queue's scroller, and it
|
||||||
|
makes the screen unusable exactly when somebody has queued the most work.
|
||||||
|
|
||||||
|
### 14.4 A stopped upload resumes rather than starting again
|
||||||
|
|
||||||
|
Start a large upload, press **STOP** part way, then press **RESUME**.
|
||||||
|
|
||||||
|
**Pass:** it carries on from roughly where it stopped rather than from zero — the progress text is the thing
|
||||||
|
to read.
|
||||||
|
|
||||||
|
**Failure means:** restarting from zero means the staged copy was deleted at the stop, which is precisely
|
||||||
|
what `QueueStagedUploads` does not do and why it does not. A failure saying the file cannot be found is the
|
||||||
|
same bug, one step further along.
|
||||||
|
|
||||||
|
### 14.5 The copies do not accumulate · **the one nothing else would catch**
|
||||||
|
|
||||||
|
Note the app's storage in Android Settings → Apps → DodoSSH → Storage. Upload a large file, let it finish,
|
||||||
|
and look again. Then stop an upload part way, leave it stopped, force-stop the app and relaunch it.
|
||||||
|
|
||||||
|
**Pass:** storage returns to about what it was after the successful upload — the copy is deleted the moment
|
||||||
|
the transfer completes. After the stopped one, the cache is bigger while the app stays open (the copy is
|
||||||
|
being kept for RESUME) and back to its old size after the relaunch, which is `DocumentStaging.Sweep`.
|
||||||
|
|
||||||
|
**Failure means:** growth after a successful upload is `ReleaseStaged` not firing, and every file sent
|
||||||
|
leaves a second copy on the phone until Android reclaims the cache. Growth that survives a relaunch is the
|
||||||
|
sweep not running.
|
||||||
|
|
||||||
|
### 14.6 The notification is up while it transfers, and gone afterwards
|
||||||
|
|
||||||
|
Queue several files in each direction, put the phone to sleep with the screen off, and wait.
|
||||||
|
|
||||||
|
**Pass:** the foreground notification is up, the transfers finish while the screen is off, and the
|
||||||
|
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
|
||||||
|
is what it was already for.
|
||||||
|
|
||||||
|
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
||||||
|
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
||||||
|
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire.
|
||||||
|
|
||||||
|
### 14.7 SAVE FILE writes where you pointed it, and the file opens
|
||||||
|
|
||||||
|
Select a file on the host — something with a viewer, an image or a PDF — press **SAVE FILE**, and put it
|
||||||
|
somewhere reachable: Downloads, or a folder in Drive. When the transfer finishes, open it from the phone's
|
||||||
|
own Files app.
|
||||||
|
|
||||||
|
**Pass:** the status line says it was saved, the file is where the picker was pointed under the name shown
|
||||||
|
there, and it opens with the right contents. The queue row says DONE.
|
||||||
|
|
||||||
|
**Failure means:** a row that says DONE with nothing at the destination is `DeliverAsync` never running —
|
||||||
|
the delivery is registered per transfer id, and losing it makes the download look like a success while the
|
||||||
|
bytes sit in a cache nobody can reach. A file that is there but empty or truncated is the copy out, not the
|
||||||
|
transfer: check the `SetLength(0)` and that the write stream is being disposed before the status is written.
|
||||||
|
|
||||||
|
### 14.8 The button is dead until a file is chosen, and refuses a directory
|
||||||
|
|
||||||
|
With nothing selected, look at **SAVE FILE**. Then select a directory row.
|
||||||
|
|
||||||
|
**Pass:** disabled in both cases — it needs a connected remote and a selected *file*, which is the desktop's
|
||||||
|
own `CanDownload`.
|
||||||
|
|
||||||
|
**Failure means:** an enabled button over a directory reaches `QueueDeliveredDownload`'s refusal, which is
|
||||||
|
the right answer arriving too late: the save picker has already created an empty file, so the person is left
|
||||||
|
with a file they did not want and a message saying nothing happened.
|
||||||
|
|
||||||
|
### 14.9 A download that fails leaves the empty file it warned about
|
||||||
|
|
||||||
|
Point SAVE FILE at a destination for a large file, then break the transfer — turn off Wi-Fi and mobile data
|
||||||
|
while it runs.
|
||||||
|
|
||||||
|
**Pass:** the row goes to FAILED with the reason, the status line does not claim it was saved, and there is
|
||||||
|
an empty file at the destination. Reconnect, press **RETRY**, and the same destination fills in — the
|
||||||
|
delivery survives the failure because it is held against the transfer rather than the attempt.
|
||||||
|
|
||||||
|
**Failure means:** a retry that succeeds but leaves the destination empty is the delivery having been
|
||||||
|
dropped on the failure. An error saying the staged file is missing is the copy having been deleted at the
|
||||||
|
stop, which is what `QueueDeliveredDownload` documents it does not do.
|
||||||
|
|||||||
@@ -81,15 +81,25 @@ internal sealed class IdentityService(DodoDbContext database, IVaultAccessServic
|
|||||||
{
|
{
|
||||||
var vault = access.Vault!;
|
var vault = access.Vault!;
|
||||||
|
|
||||||
// The grant must match both the current key generation and the exact identity key it
|
// The grant must match the exact identity key it was wrapped to. One left over from a
|
||||||
// was wrapped to. A grant left over from a superseded key is not merely stale — the
|
// superseded identity key is not merely stale — the client's current private key cannot
|
||||||
// client's current private key cannot open it, so offering it would produce a tag
|
// open it, so offering it would produce a tag failure the user reads as data corruption.
|
||||||
// failure the user reads as data corruption.
|
var mine = grants
|
||||||
var grant = grants.Find(g =>
|
.Where(g => g.VaultId == vault.Id
|
||||||
g.VaultId == vault.Id
|
|
||||||
&& g.KeyGeneration == vault.KeyGeneration
|
|
||||||
&& key is not null
|
&& key is not null
|
||||||
&& g.RecipientKeyFingerprint.AsSpan().SequenceEqual(key.FingerprintSha256));
|
&& 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(
|
summaries.Add(new VaultSummary(
|
||||||
VaultId: vault.Id,
|
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.
|
// re-wrap it; the client has to say so rather than showing an empty vault.
|
||||||
WrappedVaultKey: grant?.WrappedKey,
|
WrappedVaultKey: grant?.WrappedKey,
|
||||||
|
|
||||||
RekeyRequired: vault.RekeyRequired));
|
RekeyRequired: vault.RekeyRequired,
|
||||||
|
PriorKeyWraps: prior));
|
||||||
}
|
}
|
||||||
|
|
||||||
return summaries;
|
return summaries;
|
||||||
|
|||||||
@@ -76,6 +76,21 @@ internal static partial class TeamLog
|
|||||||
internal static partial void GrantRevoked(
|
internal static partial void GrantRevoked(
|
||||||
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
|
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(
|
[LoggerMessage(
|
||||||
EventId = 2108,
|
EventId = 2108,
|
||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
|
|||||||
@@ -186,6 +186,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>
|
/// <summary>Withdraws a member's key to this vault.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device
|
/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device
|
||||||
|
|||||||
@@ -217,6 +217,13 @@ internal sealed class VaultGrantService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lists who can open a vault.</summary>
|
/// <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(
|
internal async Task<VaultGrantsResponse> ListGrantsAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -228,13 +235,25 @@ internal sealed class VaultGrantService(
|
|||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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(
|
return new VaultGrantsResponse(
|
||||||
VaultId: vault.Id,
|
VaultId: vault.Id,
|
||||||
KeyGeneration: (uint)vault.KeyGeneration,
|
KeyGeneration: (uint)vault.KeyGeneration,
|
||||||
RekeyRequired: vault.RekeyRequired,
|
RekeyRequired: vault.RekeyRequired,
|
||||||
Grants:
|
Grants:
|
||||||
[
|
[
|
||||||
.. grants.Select(g => new VaultGrantSummary(
|
.. holders.Select(g => new VaultGrantSummary(
|
||||||
g.RecipientUserId!.Value,
|
g.RecipientUserId!.Value,
|
||||||
g.RecipientUser?.Email,
|
g.RecipientUser?.Email,
|
||||||
g.RecipientUser?.DisplayName,
|
g.RecipientUser?.DisplayName,
|
||||||
@@ -248,10 +267,18 @@ internal sealed class VaultGrantService(
|
|||||||
|
|
||||||
/// <summary>Wraps a vault key to another member.</summary>
|
/// <summary>Wraps a vault key to another member.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Re-issuing to a recipient who already holds a live grant replaces it in place rather than
|
/// <para>
|
||||||
/// inserting a second row, because the unique index permits exactly one live grant per recipient
|
/// Re-issuing to a recipient who already holds a live grant <em>for that generation</em> replaces it
|
||||||
/// per generation — and because the operation somebody is actually performing when they do this
|
/// in place rather than inserting a second row, because the unique index permits exactly one live
|
||||||
/// is "wrap it again", after a rotation or a botched first attempt.
|
/// 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>
|
/// </remarks>
|
||||||
internal async Task IssueGrantAsync(
|
internal async Task IssueGrantAsync(
|
||||||
UserAccount actor,
|
UserAccount actor,
|
||||||
@@ -264,10 +291,12 @@ internal sealed class VaultGrantService(
|
|||||||
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
|
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var generation = (int)request.KeyGeneration;
|
||||||
|
|
||||||
var existing = await database.VaultKeyGrants
|
var existing = await database.VaultKeyGrants
|
||||||
.SingleOrDefaultAsync(
|
.SingleOrDefaultAsync(
|
||||||
g => g.VaultId == vault.Id
|
g => g.VaultId == vault.Id
|
||||||
&& g.KeyGeneration == vault.KeyGeneration
|
&& g.KeyGeneration == generation
|
||||||
&& g.RecipientUserId == request.RecipientUserId
|
&& g.RecipientUserId == request.RecipientUserId
|
||||||
&& g.RevokedAtUtc == null,
|
&& g.RevokedAtUtc == null,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
@@ -277,7 +306,7 @@ internal sealed class VaultGrantService(
|
|||||||
{
|
{
|
||||||
Id = Guid.CreateVersion7(),
|
Id = Guid.CreateVersion7(),
|
||||||
VaultId = vault.Id,
|
VaultId = vault.Id,
|
||||||
KeyGeneration = vault.KeyGeneration,
|
KeyGeneration = generation,
|
||||||
Kind = GrantKind.Member,
|
Kind = GrantKind.Member,
|
||||||
RecipientUserId = request.RecipientUserId,
|
RecipientUserId = request.RecipientUserId,
|
||||||
CreatedAtUtc = clock.GetUtcNow(),
|
CreatedAtUtc = clock.GetUtcNow(),
|
||||||
@@ -304,7 +333,7 @@ internal sealed class VaultGrantService(
|
|||||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
TeamLog.GrantIssued(
|
TeamLog.GrantIssued(
|
||||||
logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id);
|
logger, vault.Id, generation, request.RecipientUserId, actor.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -335,7 +364,11 @@ internal sealed class VaultGrantService(
|
|||||||
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
|
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
|
||||||
RequireDigest(request.KeyLogHead, "key log head");
|
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(
|
throw new VaultGrantInvalidException(
|
||||||
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
|
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
|
||||||
@@ -374,6 +407,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>
|
/// <summary>
|
||||||
/// Withdraws a member's key grant.
|
/// Withdraws a member's key grant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ internal static class EndpointRegistration
|
|||||||
typeof(ListVaultGrantsEndpoint),
|
typeof(ListVaultGrantsEndpoint),
|
||||||
typeof(IssueVaultGrantEndpoint),
|
typeof(IssueVaultGrantEndpoint),
|
||||||
typeof(RevokeVaultGrantEndpoint),
|
typeof(RevokeVaultGrantEndpoint),
|
||||||
|
typeof(RekeyVaultEndpoint),
|
||||||
|
|
||||||
// Registered as each feature lands:
|
// Registered as each feature lands:
|
||||||
// Identity — key rotation, passphrase change
|
// Identity — key rotation, passphrase change
|
||||||
// Vaults — rekey, per-item ACLs
|
// Vaults — per-item ACLs
|
||||||
// Relay — tickets and the WebSocket
|
// Relay — tickets and the WebSocket
|
||||||
// Audit, Admin
|
// Audit, Admin
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,27 +74,37 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
|
|
||||||
workspace.Start();
|
workspace.Start();
|
||||||
|
|
||||||
|
// Before anything can queue a transfer, which is the only moment at which emptying this is
|
||||||
|
// provably safe. What it clears is the copy a stopped upload leaves behind on purpose — kept so
|
||||||
|
// RESUME has something to read — and whatever a process death interrupted. See DocumentStaging.
|
||||||
|
DocumentStaging.Sweep();
|
||||||
|
|
||||||
|
var viewModel = ComposeShell(paths, caches, workspace, knownHosts, connections);
|
||||||
|
|
||||||
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
||||||
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
||||||
//
|
//
|
||||||
// Still zero transfers, and the reason moved rather than went away. v2 built the files screen, so
|
// The transfer count is real now that the document picker gives this head a way to start one, and
|
||||||
// this head can now browse a remote — but it cannot start a transfer, because both directions need
|
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
|
||||||
// the system document picker that scoped storage forces and that is not built (see FilesScreen).
|
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
|
||||||
// So the count is zero because the queue provably cannot have anything in it, not because nothing
|
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
|
||||||
// was wired. This is still the seam it arrives through: when the picker lands, this reads the
|
// moves five files.
|
||||||
// queue and Refresh() gets called as transfers start and finish.
|
//
|
||||||
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
||||||
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
|
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
|
||||||
// because it is subscribed to the workspace, which lives as long as the process.
|
// because it is subscribed to the workspace, which lives as long as the process.
|
||||||
//
|
var keepAlive = new SessionKeepAlive(
|
||||||
// Refresh() is called once here. Calling it again when a shell opens is what the terminal screen
|
workspace,
|
||||||
// will wire, and there is nothing to wire it to yet — the workspace announces sessions ending on
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
|
||||||
// its own, which is the half that would otherwise leave a notification up over nothing.
|
|
||||||
var keepAlive = new SessionKeepAlive(workspace, activeTransfers: () => 0);
|
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||||
|
// announces transfers appearing and finishing. Without this the notification would come up when an
|
||||||
|
// upload started and stay up after it finished, which is the failure this class exists to prevent.
|
||||||
|
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
keepAlive.Refresh();
|
keepAlive.Refresh();
|
||||||
|
|
||||||
return new PhoneShell { DataContext = ComposeShell(paths, caches, workspace, knownHosts, connections) };
|
return new PhoneShell { DataContext = viewModel };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns documents chosen in the system picker into ordinary local files the transfer queue can upload.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The way in, and the only one this head has.</b> Android has no browsable local filesystem for a
|
||||||
|
/// second pane to show — the decision docs/android-port.md took before any of this was built — so a file
|
||||||
|
/// leaves this phone by being pointed at in the system picker, which hands back a <c>content://</c> URI
|
||||||
|
/// belonging to whichever app owns the document.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Copied rather than streamed, and that is a requirement rather than a shortcut.</b> A document URI has
|
||||||
|
/// no path behind it, its stream is not promised to be seekable, and the grant that opens it can be revoked
|
||||||
|
/// or the document edited while an upload is in flight. <c>FileTransferQueue</c> needs all three of the
|
||||||
|
/// things that costs: a path, a length it can trust, and a seek so a resumed upload starts from the byte
|
||||||
|
/// the last attempt reached. So the document is copied into this application's own cache first and the copy
|
||||||
|
/// is what gets queued — a real file, behaving like every other thing in that queue.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One directory per file, named by a UUIDv7.</b> Two documents chosen in one go can have the same
|
||||||
|
/// display name, and two picks a minute apart certainly can; a shared staging directory would make the
|
||||||
|
/// second copy overwrite the first, which is a data-loss bug that only shows up when somebody uploads two
|
||||||
|
/// files called <c>config</c>. The directory is the uniqueness, so the file inside it can keep the name the
|
||||||
|
/// picker gave it — which is the name the remote end gets, because <c>QueueUploads</c> takes it from the
|
||||||
|
/// path.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class DocumentStaging
|
||||||
|
{
|
||||||
|
/// <summary>Everything staged in either direction, under one directory so a sweep is one call.</summary>
|
||||||
|
private static string Root =>
|
||||||
|
Path.Combine(PhoneEnvironment.CacheDirectory, "staging");
|
||||||
|
|
||||||
|
/// <summary>A path in the staging area for a file of this name, with the directory made.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>One directory per file, named by a UUIDv7</b> — see the type's own remarks for why the uniqueness
|
||||||
|
/// is the directory rather than the name. Used by both directions: a document copied in for upload, and
|
||||||
|
/// a download on its way out to the document the save picker made.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string NewStagingPath(string? name)
|
||||||
|
{
|
||||||
|
var folder = Path.Combine(Root, Guid.CreateVersion7().ToString("n"));
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
|
return Path.Combine(folder, SafeName(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks for documents and copies each one into the cache, returning the paths of the copies.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Multiple by design: the queue moves one file at a time, but choosing them is a trip out to another
|
||||||
|
/// application and back, and making somebody take that trip once per file is the kind of thing a phone
|
||||||
|
/// is judged on. An empty list means the picker was dismissed, which is not an error and is reported as
|
||||||
|
/// nothing having happened rather than as a failure.
|
||||||
|
/// </remarks>
|
||||||
|
internal static async Task<IReadOnlyList<string>> PickAsync(
|
||||||
|
TopLevel top,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(top);
|
||||||
|
|
||||||
|
var chosen = await top.StorageProvider
|
||||||
|
.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Files to upload",
|
||||||
|
AllowMultiple = true,
|
||||||
|
})
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (chosen.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var staged = new List<string>(chosen.Count);
|
||||||
|
|
||||||
|
foreach (var document in chosen)
|
||||||
|
{
|
||||||
|
staged.Add(await CopyInAsync(document, cancellationToken).ConfigureAwait(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks where a download should end up, and returns the document the picker made.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>ACTION_CREATE_DOCUMENT</c>, which is the only way a file leaves this application: everything else
|
||||||
|
/// on this phone is either the app's own private storage or somewhere it has no permission to write.
|
||||||
|
/// The suggested name is the remote file's, because that is the name the person was looking at when
|
||||||
|
/// they pressed the button, and they can change it in the picker like any other save.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The document exists as soon as this returns</b> — the picker creates it, empty, and a download
|
||||||
|
/// that then fails leaves that empty file behind. Nothing here can prevent it: the alternative is
|
||||||
|
/// raising the picker after the transfer, minutes later, over whatever the person moved on to, and on
|
||||||
|
/// Android often while the application is backgrounded and cannot show one at all.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static async Task<IStorageFile?> PickDestinationAsync(TopLevel top, string name)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(top);
|
||||||
|
|
||||||
|
return await top.StorageProvider
|
||||||
|
.SaveFilePickerAsync(new FilePickerSaveOptions
|
||||||
|
{
|
||||||
|
Title = "Save file",
|
||||||
|
SuggestedFileName = name,
|
||||||
|
ShowOverwritePrompt = true,
|
||||||
|
})
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies a finished download out to the document the picker made.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The write is truncating rather than appending, which matters on a retry: the picker's document is
|
||||||
|
/// created when it is dismissed and a second attempt writes over the empty — or partly written — file
|
||||||
|
/// rather than after it.
|
||||||
|
/// </remarks>
|
||||||
|
internal static async Task DeliverAsync(IStorageFile destination, string localPath)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
|
|
||||||
|
var source = new FileStream(
|
||||||
|
localPath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true);
|
||||||
|
|
||||||
|
await using (source.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var target = await destination.OpenWriteAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using (target.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (target.CanSeek)
|
||||||
|
{
|
||||||
|
target.SetLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await source.CopyToAsync(target).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes everything left in the staging directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called once at composition, before anything can have queued a transfer, which is what makes deleting
|
||||||
|
/// the lot safe: at that moment nothing in there belongs to a transfer that could still want it. What it
|
||||||
|
/// is for is the residue a stopped upload leaves deliberately — the copy is kept so RESUME has something
|
||||||
|
/// to read — a download whose delivery failed, and whatever a process death left behind mid-copy.
|
||||||
|
/// </remarks>
|
||||||
|
internal static void Sweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(Root))
|
||||||
|
{
|
||||||
|
Directory.Delete(Root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// A cache that could not be emptied is not a reason to refuse to start. Android reclaims this
|
||||||
|
// directory itself when the device runs short of storage.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> CopyInAsync(IStorageFile document, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = NewStagingPath(document.Name);
|
||||||
|
|
||||||
|
// Off the interface thread from here down — this is a byte copy of something that can be a hundred
|
||||||
|
// megabytes, and nothing in it touches a control. The caller's own await is what comes back to the
|
||||||
|
// interface thread to queue the result. Hence the two-step disposal: configuring the await on a
|
||||||
|
// using declaration would leave the variable a ConfiguredAsyncDisposable rather than a stream.
|
||||||
|
var source = await document.OpenReadAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using (source.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var target = new FileStream(
|
||||||
|
path, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true);
|
||||||
|
|
||||||
|
await using (target.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The display name reduced to something that can be a file name here and a name on the remote.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A picker's display name comes from whichever application owns the document and is not obliged to be
|
||||||
|
/// a valid file name — it can carry a separator, which without this would write outside the directory
|
||||||
|
/// staging just made, and would then be joined onto the remote path by <c>QueueUploads</c>. Both are
|
||||||
|
/// worth refusing at the one point where the name enters this application.
|
||||||
|
/// </remarks>
|
||||||
|
private static string SafeName(string? name)
|
||||||
|
{
|
||||||
|
var trimmed = Path.GetFileName(name ?? string.Empty).Trim();
|
||||||
|
|
||||||
|
if (trimmed.Length == 0 || trimmed is "." or "..")
|
||||||
|
{
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('_', trimmed.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,18 @@ internal static class PhoneEnvironment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Where this phone keeps copies that only have to survive the thing that made them.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>cacheDir</c> — per-app like <see cref="Paths"/>, and unlike it, reclaimable: Android deletes from
|
||||||
|
/// here when the device runs short of storage. That is the right trade for the upload staging in
|
||||||
|
/// <see cref="DocumentStaging"/>, whose files are worthless the moment their transfer finishes, and it
|
||||||
|
/// is why the profile is not kept here. The cost is stated rather than hidden: a file reclaimed under
|
||||||
|
/// storage pressure while its upload is still running fails that upload.
|
||||||
|
/// </remarks>
|
||||||
|
public static string CacheDirectory =>
|
||||||
|
Require().CacheDir?.AbsolutePath
|
||||||
|
?? throw new InvalidOperationException("Android returned no cacheDir for this application.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The activity currently on screen, or null while the app is backgrounded.
|
/// The activity currently on screen, or null while the app is backgrounded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -19,17 +19,28 @@
|
|||||||
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
|
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
|
||||||
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
|
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
|
||||||
|
|
||||||
◆ **And that is why neither DOWNLOAD nor UPLOAD is on this screen.** Both commands exist and both work;
|
◆ **ADD FILES is the way in, and it is the system document picker rather than an UPLOAD button.** There
|
||||||
what they work *against* is the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`,
|
is nothing local to select from, so the gesture cannot be "choose on the left, press the arrow": it is
|
||||||
and `LocalPath` starts at `LocalDirectory.Home` — `SpecialFolder.UserProfile`, which on Android is the
|
"point at a document wherever it lives, and it goes to the directory showing". What Android hands back
|
||||||
application's own private directory. A download would report success and put the file somewhere the
|
is a `content://` URI, so `DocumentStaging` copies it into this application's cache and queues the copy —
|
||||||
person who asked for it cannot open it, which is worse than not offering it: a refusal is visible and a
|
the queue needs a path, a length and a seek, and a document URI promises none of the three. See that
|
||||||
file in `/data/user/0/…` is not. The way in and out is the system document picker, which is the shape
|
class for why the copy is a requirement rather than a shortcut, and `QueueStagedUploads` for when it is
|
||||||
docs/android-port.md decided on and is the next piece of work.
|
deleted again.
|
||||||
|
|
||||||
So what ships is browsing a remote, and the two remote-side operations that need nothing local —
|
◆ **SAVE FILE is the way out, and it is the save picker rather than a DOWNLOAD button.** `QueueDownloads`
|
||||||
opening a directory and deleting. The queue is drawn because a transfer can still be running when this
|
writes to `Path.Combine(LocalPath, name)`, and `LocalPath` on Android is the application's own private
|
||||||
screen is opened; it is simply not something this head can start yet.
|
directory — a download that way would report success and leave the file where the person who asked for
|
||||||
|
it cannot open it. So this head does not use it: `QueueDeliveredDownload` runs the transfer into the
|
||||||
|
cache and hands the finished bytes to the document `ACTION_CREATE_DOCUMENT` made.
|
||||||
|
|
||||||
|
The destination is chosen *before* the transfer, which is a decision with a visible cost — the picker
|
||||||
|
creates the document when it is dismissed, so a download that then fails leaves an empty file where it
|
||||||
|
was pointed. The alternative is a picker raised minutes later, over whatever the person moved on to and
|
||||||
|
frequently while this application is backgrounded, where Android will not show one at all.
|
||||||
|
|
||||||
|
So what ships is browsing a remote and moving files both ways, plus the two remote-side operations that
|
||||||
|
need nothing local — opening a directory and deleting. The queue is drawn under the actions, because
|
||||||
|
this head can now fill it.
|
||||||
|
|
||||||
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
|
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
|
||||||
and it makes its own trust decision — the host records a second login. So this screen carries its own
|
and it makes its own trust decision — the host records a second login. So this screen carries its own
|
||||||
@@ -255,6 +266,70 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
||||||
|
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
||||||
|
phone is how a screen becomes unusable rather than merely tall.
|
||||||
|
-->
|
||||||
|
<ScrollViewer MaxHeight="164" IsVisible="{Binding HasTransfers}"
|
||||||
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="14,*,Auto" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12" Text="{Binding Arrow}"
|
||||||
|
VerticalAlignment="Center" Foreground="{StaticResource AccentText}" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Margin="8,0" Spacing="3">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Name}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<ProgressBar Height="3" Minimum="0" Maximum="100" Value="{Binding Percent}"
|
||||||
|
Foreground="{StaticResource Accent}"
|
||||||
|
Background="{StaticResource Raised}" />
|
||||||
|
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One button per row, never two: whichever of the three applies to the state it is in.
|
||||||
|
A phone row has space for a name, a bar and one 44-pixel target, and the three are
|
||||||
|
mutually exclusive by construction — IsRunning and CanRetry cannot both hold.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
||||||
|
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="STOP"
|
||||||
|
IsVisible="{Binding IsRunning}"
|
||||||
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="{Binding RetryLabel}"
|
||||||
|
IsVisible="{Binding CanRetry}"
|
||||||
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||||
|
CommandParameter="{Binding}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Button Classes="secondary" Height="40" Content="CLEAR FINISHED" HorizontalAlignment="Stretch"
|
||||||
|
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The two directions, on their own row above the pair below — because DELETE is the button on this
|
||||||
|
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses
|
||||||
|
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this
|
||||||
|
screen exists for on a phone.
|
||||||
|
|
||||||
|
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the
|
||||||
|
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||||
|
a file is selected and something is connected — and is reused rather than restated here.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||||
|
IsEnabled="{Binding CanDownload}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
||||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||||
@@ -263,18 +338,11 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||||
Text="Copying files to and from this phone needs the system document picker, which is not built yet — see the note at the top of this screen. Browsing, opening and deleting work." />
|
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||||
|
|
||||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||||
Text="{Binding Status}" />
|
Text="{Binding Status}" />
|
||||||
|
|
||||||
<!--
|
|
||||||
There is no queue on this screen, and that follows from the note at the top rather than being a
|
|
||||||
separate decision: nothing here can enqueue a transfer, so a queue would be a region that is
|
|
||||||
empty for every possible state of the application. It comes back with the document picker, along
|
|
||||||
with the two buttons that would fill it.
|
|
||||||
-->
|
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Avalonia.Input;
|
|||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
using DodoSSH.Client.Android.Platform;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Views;
|
namespace DodoSSH.Client.Android.Views;
|
||||||
@@ -29,8 +30,9 @@ internal sealed partial class FilesScreen : UserControl
|
|||||||
/// fires after the list has moved its selection, which is what lets this read it.
|
/// fires after the list has moved its selection, which is what lets this read it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A file is left selected rather than opened. There is nothing this head could do with it — see the
|
/// A file is left selected rather than downloaded, and that is deliberate now rather than forced: the
|
||||||
/// note about the document picker at the top of the screen — and the actions below act on the selection.
|
/// actions below act on the selection, and a tap that started a transfer would make selecting a row to
|
||||||
|
/// read its size the same gesture as fetching it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
||||||
@@ -40,4 +42,93 @@ internal sealed partial class FilesScreen : UserControl
|
|||||||
transfers.OpenRemoteCommand.Execute(null);
|
transfers.OpenRemoteCommand.Execute(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks documents in the system picker and queues them for upload.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// In the head rather than in the shared view model, for the reason every other platform difference is:
|
||||||
|
/// the picker is Android's, the staging directory is this application's cache, and the desktop reaches
|
||||||
|
/// its local files by browsing a pane that does not exist here. What crosses back into shared code is
|
||||||
|
/// what the queue understands — paths — through <see cref="TransfersViewModel.QueueStagedUploads"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Failures land in the screen's own status line</b>, which is where every other refusal on this
|
||||||
|
/// screen already is. The picker itself is a trip out to another application, and it can come back with
|
||||||
|
/// a document that has since been deleted or a grant that was revoked; the exception's message is more
|
||||||
|
/// use than "the upload failed", and a phone has nowhere else to put it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async void OnAddFiles(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var staged = await DocumentStaging.PickAsync(top, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (staged.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transfers.QueueStagedUploads(staged);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
transfers.Status = $"Those files could not be read: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks where the chosen remote file should be saved, then queues it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The other direction, and the asymmetry with <see cref="OnAddFiles"/> is the platform's rather than
|
||||||
|
/// this screen's: coming in, several documents can be pointed at in one trip; going out, the save picker
|
||||||
|
/// names one destination, so this acts on the selected row. Asking for five destinations in a row to
|
||||||
|
/// download five files would be a worse screen than pressing the button five times.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Nothing is queued when the picker is dismissed. The document it makes when it is *not* dismissed
|
||||||
|
/// exists from that moment, which is why the queueing follows immediately — see
|
||||||
|
/// <c>DocumentStaging.PickDestinationAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async void OnSaveFile(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transfers.SelectedRemoteEntry is not { IsFile: true } row)
|
||||||
|
{
|
||||||
|
transfers.Status = "Choose a file on the host to save.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await DocumentStaging.PickDestinationAsync(top, row.Name).ConfigureAwait(true)
|
||||||
|
is not { } destination)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transfers.QueueDeliveredDownload(
|
||||||
|
row,
|
||||||
|
DocumentStaging.NewStagingPath(row.Name),
|
||||||
|
path => DocumentStaging.DeliverAsync(destination, path));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
transfers.Status = $"That file could not be saved: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,21 @@ public interface IVaultGrantApi
|
|||||||
IssueVaultGrantRequest request,
|
IssueVaultGrantRequest request,
|
||||||
CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Withdraws a member's key to this vault.
|
/// Withdraws a member's key to this vault.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -606,6 +621,18 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
|||||||
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
|
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
|
||||||
cancellationToken);
|
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 />
|
/// <inheritdoc />
|
||||||
public Task<bool> RevokeVaultGrantAsync(
|
public Task<bool> RevokeVaultGrantAsync(
|
||||||
Guid vaultId,
|
Guid vaultId,
|
||||||
|
|||||||
@@ -196,5 +196,6 @@ public sealed class AccountProvisioner(
|
|||||||
summary.KeyGeneration,
|
summary.KeyGeneration,
|
||||||
summary.Permissions,
|
summary.Permissions,
|
||||||
summary.WrappedVaultKey,
|
summary.WrappedVaultKey,
|
||||||
summary.RekeyRequired);
|
summary.RekeyRequired,
|
||||||
|
summary.PriorKeyWraps);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -14,10 +14,67 @@ namespace DodoSSH.Client.Session;
|
|||||||
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
|
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Message">One line for a person. Never contains key material.</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(
|
public sealed record ShareOutcome(
|
||||||
bool Shared,
|
bool Shared,
|
||||||
RecipientVerification Verification,
|
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>
|
||||||
|
/// <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(
|
||||||
|
Guid VaultId,
|
||||||
|
string Name,
|
||||||
|
uint KeyGeneration,
|
||||||
|
IReadOnlyList<Guid> Shared,
|
||||||
|
IReadOnlyList<(Guid UserId, string Reason)> NotShared,
|
||||||
|
Exception? Failure,
|
||||||
|
ResealReport? Reseal = null)
|
||||||
|
{
|
||||||
|
/// <summary>Whether the vault moved to a new key.</summary>
|
||||||
|
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>
|
||||||
/// Sharing, from the side that holds the keys.
|
/// Sharing, from the side that holds the keys.
|
||||||
@@ -160,6 +217,13 @@ public sealed partial class VaultSession
|
|||||||
/// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
|
/// <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.
|
/// that is the only step that closes the gap, and the outcome message says so.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
public async Task<ShareOutcome> ShareVaultAsync(
|
public async Task<ShareOutcome> ShareVaultAsync(
|
||||||
IVaultGrantApi grants,
|
IVaultGrantApi grants,
|
||||||
@@ -172,7 +236,7 @@ public sealed partial class VaultSession
|
|||||||
ArgumentNullException.ThrowIfNull(grants);
|
ArgumentNullException.ThrowIfNull(grants);
|
||||||
ArgumentNullException.ThrowIfNull(directory);
|
ArgumentNullException.ThrowIfNull(directory);
|
||||||
|
|
||||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var keyGeneration))
|
if (!keyring.TryGet(vaultId, out _, out _))
|
||||||
{
|
{
|
||||||
throw new VaultUnreadableException(vaultId);
|
throw new VaultUnreadableException(vaultId);
|
||||||
}
|
}
|
||||||
@@ -189,17 +253,292 @@ public sealed partial class VaultSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
var recipient = verification.Recipient!;
|
var recipient = verification.Recipient!;
|
||||||
|
var generations = keyring.GenerationsHeld(vaultId);
|
||||||
|
|
||||||
await IssueAsync(grants, vaultId, vaultKey, keyGeneration, recipient, cancellationToken)
|
// 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);
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
return new ShareOutcome(
|
return new ShareOutcome(
|
||||||
true,
|
true,
|
||||||
verification,
|
verification,
|
||||||
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
|
"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="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="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>Three 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>The third act is re-sealing what is already there</b>, and it is what makes the rotation worth
|
||||||
|
/// the name: until it has run, the vault's stored items are still sealed under keys the departed
|
||||||
|
/// member may have kept. It runs last for a reason — it needs the new key, and it is the only step
|
||||||
|
/// that can be interrupted without leaving anything broken, because a vault at mixed generations
|
||||||
|
/// 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>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<VaultRekeyReport> RekeyVaultAsync(
|
||||||
|
IVaultGrantApi grants,
|
||||||
|
IDirectoryApi directory,
|
||||||
|
ISyncApi sync,
|
||||||
|
Guid vaultId,
|
||||||
|
IReadOnlyList<Guid> recipients,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
ArgumentNullException.ThrowIfNull(grants);
|
||||||
|
ArgumentNullException.ThrowIfNull(directory);
|
||||||
|
ArgumentNullException.ThrowIfNull(sync);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
vaultId, name, summary.KeyGeneration, shared, missed, Failure: null, resealed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
ISyncApi sync,
|
||||||
|
Guid teamId,
|
||||||
|
IReadOnlyList<Guid> recipients,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
ArgumentNullException.ThrowIfNull(grants);
|
||||||
|
ArgumentNullException.ThrowIfNull(directory);
|
||||||
|
ArgumentNullException.ThrowIfNull(sync);
|
||||||
|
ArgumentNullException.ThrowIfNull(recipients);
|
||||||
|
|
||||||
|
var reports = new List<VaultRekeyReport>();
|
||||||
|
|
||||||
|
foreach (var vault in TeamVaults(teamId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reports.Add(
|
||||||
|
await RekeyVaultAsync(
|
||||||
|
grants, directory, sync, 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>
|
/// <summary>
|
||||||
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
|
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -226,15 +565,19 @@ public sealed partial class VaultSession
|
|||||||
|
|
||||||
foreach (var vault in Vaults)
|
foreach (var vault in Vaults)
|
||||||
{
|
{
|
||||||
if (keyring.CanRead(vault.VaultId))
|
// 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
|
||||||
continue;
|
// 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))
|
if (keyring.TryAdmit(bundle, vault))
|
||||||
|
{
|
||||||
|
if (!readable)
|
||||||
{
|
{
|
||||||
admitted++;
|
admitted++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
keyring.MarkUnreadable(vault.VaultId);
|
keyring.MarkUnreadable(vault.VaultId);
|
||||||
@@ -336,5 +679,6 @@ public sealed partial class VaultSession
|
|||||||
summary.KeyGeneration,
|
summary.KeyGeneration,
|
||||||
summary.Permissions,
|
summary.Permissions,
|
||||||
summary.WrappedVaultKey,
|
summary.WrappedVaultKey,
|
||||||
summary.RekeyRequired);
|
summary.RekeyRequired,
|
||||||
|
summary.PriorKeyWraps);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,27 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
private readonly FileTransferQueue queue;
|
private readonly FileTransferQueue queue;
|
||||||
private readonly Action<Action> post;
|
private readonly Action<Action> post;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local files that exist only so this queue could move them — see <see cref="QueueStagedUploads"/> and
|
||||||
|
/// <see cref="QueueDeliveredDownload"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared case-insensitively because the paths come back through the queue's snapshots rather than
|
||||||
|
/// straight from the caller, and a comparison that a casing round trip could break would leak a file
|
||||||
|
/// per transfer on any head that ever normalises one.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly HashSet<string> staged = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What to do with a completed download whose real destination this layer cannot write to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Keyed by transfer rather than by path so a retry keeps its delivery: the queue reuses the id, and a
|
||||||
|
/// download that failed once and succeeded on the second attempt must still end up where the person
|
||||||
|
/// pointed. See <see cref="QueueDeliveredDownload"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly Dictionary<Guid, Func<string, Task>> deliveries = [];
|
||||||
|
|
||||||
private VaultViewModel? vault;
|
private VaultViewModel? vault;
|
||||||
private VaultKnownHostStore? knownHosts;
|
private VaultKnownHostStore? knownHosts;
|
||||||
private IRemoteFileStore? session;
|
private IRemoteFileStore? session;
|
||||||
@@ -522,6 +543,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Raised on the UI thread whenever a transfer appears or changes state.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For a head that has to tell the operating system what this process is doing — Android's foreground
|
||||||
|
/// service, which must be up for as long as bytes are moving and down afterwards. An event rather than
|
||||||
|
/// letting that head watch <see cref="Transfers"/> itself: the collection announces rows arriving and
|
||||||
|
/// leaving, and the transition that matters most is neither of those but a row going from RUNNING to
|
||||||
|
/// DONE without moving.
|
||||||
|
/// </remarks>
|
||||||
|
internal event EventHandler? ActivityChanged;
|
||||||
|
|
||||||
|
/// <summary>How many transfers are moving or waiting to move.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Queued counts as active. A queue with three files in it and one of them running is a process that
|
||||||
|
/// must not be stopped, and the two that have not started yet are exactly the ones a stop would lose.
|
||||||
|
/// </remarks>
|
||||||
|
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
|
||||||
|
|
||||||
internal bool HasTransfers => Transfers.Count > 0;
|
internal bool HasTransfers => Transfers.Count > 0;
|
||||||
|
|
||||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||||
@@ -982,6 +1020,143 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues copies that were made for this upload and belong to nothing else, so they are deleted once
|
||||||
|
/// the transfer no longer needs them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This exists for the phone, and the copy is not an implementation detail that could be avoided.</b>
|
||||||
|
/// Android hands a chosen document over as a <c>content://</c> URI with no path behind it and no promise
|
||||||
|
/// that the stream can be seeked — and this queue seeks, because an upload resumes from the byte the
|
||||||
|
/// last attempt reached. So the head copies the document into the application's own cache first and
|
||||||
|
/// hands over the copy, which is a real file that behaves like every other thing in this queue.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Released on success and on discard, never on failure.</b> A failed or stopped upload is offered a
|
||||||
|
/// RESUME or a RETRY, and both read the local file again — deleting it at the moment it stopped would
|
||||||
|
/// turn one visible failure into a second, stranger one. What is left after a failure is swept at the
|
||||||
|
/// next launch by the head that made it, which is the only place that knows where it put it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal void QueueStagedUploads(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
staged.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueUploads(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queues one download into a local file this application made, and hands the finished bytes to
|
||||||
|
/// something that knows where they were really meant to go.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The mirror of <see cref="QueueStagedUploads"/>, and it exists for the same reason.</b> A phone has
|
||||||
|
/// no directory a download could simply be written into: what the person chose is a document handed back
|
||||||
|
/// by the system's save picker, which this layer cannot open and the queue could not resume against. So
|
||||||
|
/// the transfer runs into the cache like any other, and <paramref name="deliver"/> — supplied by the head
|
||||||
|
/// that raised the picker — copies the result out once there is a result to copy.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The destination is chosen before the transfer starts, not after.</b> A picker raised on completion
|
||||||
|
/// would arrive minutes later over whatever the person had moved on to, and on a phone it would often
|
||||||
|
/// arrive while the application is in the background, where Android will not show it at all. The cost is
|
||||||
|
/// stated where a person will meet it: the save picker creates the document when it is dismissed, so a
|
||||||
|
/// download that then fails leaves an empty file where it was pointed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Delivery failure does not delete the bytes.</b> They were fetched over somebody's network and the
|
||||||
|
/// staged copy is all that is left of them; it stays for the next launch's sweep rather than being
|
||||||
|
/// thrown away at the one moment it is worth the most.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="row">The remote file to fetch.</param>
|
||||||
|
/// <param name="localPath">Where to stage it — a path the head owns and will sweep.</param>
|
||||||
|
/// <param name="deliver">Copies the staged file to wherever it was really meant to go.</param>
|
||||||
|
internal void QueueDeliveredDownload(
|
||||||
|
RemoteEntryRowViewModel row,
|
||||||
|
string localPath,
|
||||||
|
Func<string, Task> deliver)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
ArgumentNullException.ThrowIfNull(deliver);
|
||||||
|
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
Status = "Connect to a host first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.IsFile)
|
||||||
|
{
|
||||||
|
Status = "Only files can be transferred.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
staged.Add(localPath);
|
||||||
|
deliveries[queue.Enqueue(TransferDirection.Download, localPath, row.FullPath, row.Entry.Length)] =
|
||||||
|
deliver;
|
||||||
|
|
||||||
|
Status = $"Queued {row.Name} for download.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Fire-and-forget from the queue's own event, which cannot await: the transfer is over as far as the
|
||||||
|
/// queue is concerned, and what is left is a copy this class owns and a callback the head gave it. The
|
||||||
|
/// status line is the only report either way, which is the same place every other outcome on this screen
|
||||||
|
/// is reported.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task DeliverAsync(string localPath, Func<string, Task> deliver)
|
||||||
|
{
|
||||||
|
var name = Path.GetFileName(localPath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await deliver(localPath).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = $"Saved {name}.";
|
||||||
|
ReleaseStaged(localPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
Status = $"{name} was downloaded but could not be saved where you chose: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The directory goes only if it is empty, and that is the whole of the safety here: staging puts one
|
||||||
|
/// file in a directory of its own, so an empty parent is this transfer's and a parent with anything else
|
||||||
|
/// in it is not something this method is entitled to reason about. Failures are ignored rather than
|
||||||
|
/// reported — a cached copy that outlives its transfer is swept at the next launch, and there is nothing
|
||||||
|
/// a person could do with the news.
|
||||||
|
/// </remarks>
|
||||||
|
private void ReleaseStaged(string localPath)
|
||||||
|
{
|
||||||
|
if (!staged.Remove(localPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(localPath);
|
||||||
|
|
||||||
|
if (Path.GetDirectoryName(localPath) is { Length: > 0 } folder)
|
||||||
|
{
|
||||||
|
Directory.Delete(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
||||||
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
||||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
||||||
@@ -1061,6 +1236,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
||||||
{
|
{
|
||||||
Transfers.Remove(row);
|
Transfers.Remove(row);
|
||||||
|
|
||||||
|
// Discarding is the deliberate end of a stopped transfer — the row is gone and with it the
|
||||||
|
// RESUME the staged copy was being kept for, and any delivery that was waiting on it.
|
||||||
|
deliveries.Remove(row.Id);
|
||||||
|
ReleaseStaged(row.Transfer.LocalPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1388,13 +1568,33 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
||||||
post(() =>
|
post(() =>
|
||||||
{
|
{
|
||||||
|
// Completed only, and the reason is in QueueStagedUploads: a stopped upload still has a RESUME
|
||||||
|
// button that will read this file again.
|
||||||
|
if (e.Transfer.State is TransferState.Completed)
|
||||||
|
{
|
||||||
|
// A staged download is not finished when the queue says so — it is finished when the bytes
|
||||||
|
// reach the document the person picked, and only the head can put them there. So the copy
|
||||||
|
// is released by the delivery rather than here, or it would be deleted on the way.
|
||||||
|
if (deliveries.Remove(e.Transfer.Id, out var deliver))
|
||||||
|
{
|
||||||
|
_ = DeliverAsync(e.Transfer.LocalPath, deliver);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReleaseStaged(e.Transfer.LocalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||||
{
|
{
|
||||||
existing.Transfer = e.Transfer;
|
existing.Transfer = e.Transfer;
|
||||||
return;
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||||
}
|
}
|
||||||
|
|
||||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -7303,7 +7303,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (report.RekeyRequired)
|
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) + ".";
|
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
|
||||||
|
|||||||
@@ -904,12 +904,7 @@ internal sealed partial class VaultsViewModel(
|
|||||||
{
|
{
|
||||||
// Never silent. This command's failures used to be visible only as a flicker of the busy
|
// Never silent. This command's failures used to be visible only as a flicker of the busy
|
||||||
// flag, which reads as a button that does nothing at all.
|
// flag, which reads as a button that does nothing at all.
|
||||||
Status = connection() is null
|
Status = WhyNobodyCanBeAdded();
|
||||||
? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
|
|
||||||
: SelectedIsPersonal
|
|
||||||
? "Your personal vault is yours alone and cannot be shared. Make a vault for the "
|
|
||||||
+ "things you want to share, and put them in it."
|
|
||||||
: "Select a vault on the left first — somebody is added to one vault, not to all.";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,7 +916,24 @@ internal sealed partial class VaultsViewModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await RunAsync(async () =>
|
await RunAsync(() => AddOrInviteAsync(server, teamId, email, cancellationToken))
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which of the three reasons the ADD button had nothing to act on.</summary>
|
||||||
|
private string WhyNobodyCanBeAdded() => connection() is null
|
||||||
|
? "Offline. Adding somebody changes who the server will serve, so it needs a connection."
|
||||||
|
: SelectedIsPersonal
|
||||||
|
? "Your personal vault is yours alone and cannot be shared. Make a vault for the things you "
|
||||||
|
+ "want to share, and put them in it."
|
||||||
|
: "Select a vault on the left first — somebody is added to one vault, not to all.";
|
||||||
|
|
||||||
|
/// <summary>The calls behind <see cref="AddMemberAsync"/>, once its arguments are known good.</summary>
|
||||||
|
private async Task AddOrInviteAsync(
|
||||||
|
IVaultServer server,
|
||||||
|
Guid teamId,
|
||||||
|
string email,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
@@ -939,8 +951,7 @@ internal sealed partial class VaultsViewModel(
|
|||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (DodoSshApiException exception)
|
catch (DodoSshApiException exception)
|
||||||
when (string.Equals(
|
when (string.Equals(exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
|
||||||
exception.Code, ProblemCodes.NoSuchAccount, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
// The address really is unknown here, which only the server can say. This is the one
|
// The address really is unknown here, which only the server can say. This is the one
|
||||||
// route to an invitation, and it is now a fact rather than an inference from silence.
|
// route to an invitation, and it is now a fact rather than an inference from silence.
|
||||||
@@ -950,33 +961,138 @@ internal sealed partial class VaultsViewModel(
|
|||||||
|
|
||||||
InviteEmail = string.Empty;
|
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, teamId, member, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = Describe(member);
|
Status = Describe(member, shared);
|
||||||
}).ConfigureAwait(true);
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps every vault behind this membership list that this machine can open to somebody just added.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>What to tell the user about the keys, or null when there was nothing to say.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The membership list rather than the one vault, and that is not a slip: adding somebody is a
|
||||||
|
/// change to the list, so it is every vault the list carries that they can now fetch. This screen
|
||||||
|
/// makes lists that carry one vault, so the sentence names one — and where it does not, naming them
|
||||||
|
/// all is the honest report of what just happened.
|
||||||
|
/// </para>
|
||||||
|
/// <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 it, and this vault still needs sharing", which is a state somebody can act on.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<string?> ShareWithAsync(
|
||||||
|
IVaultServer server,
|
||||||
|
Guid teamId,
|
||||||
|
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, 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>"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>
|
/// <summary>
|
||||||
/// What just happened to the account that was added, and what is still owed them.
|
/// What just happened to the account that was added, and what is still owed them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both branches say out loud that nothing readable was granted, because the single most common
|
/// <para>
|
||||||
/// misunderstanding this design invites is that adding somebody gave them the vault. The unenrolled
|
/// The enrolled branch reports what the keys did, because that is the half of "adding somebody"
|
||||||
/// branch says more, and has to: their row will sit in the list saying it holds no key, and without
|
/// that this machine performs and the half that can partly fail. A vault that could not be wrapped
|
||||||
/// this somebody would read that as the addition having half-failed rather than as a colleague who
|
/// is named there rather than left to be noticed when they say they cannot open it.
|
||||||
/// has not finished setting their machine up. It is also the one case where SHARE KEY cannot be the
|
/// </para>
|
||||||
/// next step, so pointing at it would be pointing at a button that will refuse.
|
/// <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>
|
/// </remarks>
|
||||||
private static string Describe(TeamMemberSummary member)
|
private static string Describe(TeamMemberSummary member, string? shared)
|
||||||
{
|
{
|
||||||
var who = member.Email ?? member.DisplayName ?? "the account";
|
var who = member.Email ?? member.DisplayName ?? "the account";
|
||||||
|
|
||||||
return member.IsEnrolled
|
if (!member.IsEnrolled)
|
||||||
? $"Added {who}. They cannot read anything yet — press SHARE KEY to wrap this vault's key "
|
{
|
||||||
+ "to them."
|
return $"Added {who}. They have no key yet, so their row says so and this vault cannot be "
|
||||||
: $"Added {who}. They have no key yet, so their row says so and this vault cannot be shared "
|
+ "shared with them until they finish signing in on their own machine. The membership "
|
||||||
+ "with them until they finish signing in on their own machine. The membership is real "
|
+ "is real in the meantime.";
|
||||||
+ "in the meantime.";
|
}
|
||||||
|
|
||||||
|
return shared is null
|
||||||
|
? $"Added {who}. This machine holds no key to give them — press SHARE KEY from one that "
|
||||||
|
+ "does."
|
||||||
|
: $"Added {who}. {shared}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1158,7 +1274,14 @@ internal sealed partial class VaultsViewModel(
|
|||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Removes somebody, revoking every key grant they hold from this vault.</summary>
|
/// <summary>
|
||||||
|
/// Removes somebody, 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]
|
[RelayCommand]
|
||||||
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -1169,21 +1292,127 @@ internal sealed partial class VaultsViewModel(
|
|||||||
return;
|
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 RunAsync(async () =>
|
||||||
{
|
{
|
||||||
await server.Teams
|
await server.Teams
|
||||||
.RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken)
|
.RemoveTeamMemberAsync(teamId, member.UserId, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
var rotated = await RotateAfterRemovalAsync(server, teamId, remaining, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
|
// 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.
|
// and a message implying otherwise is the one thing this screen must not say. The rotation
|
||||||
Status = $"Removed {member.Name}. They can no longer fetch this vault, and anything they had "
|
// is described in the same breath for the same reason — it decides what happens next, not
|
||||||
+ "already downloaded is still on their machine — rotate the credentials that matter.";
|
// 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);
|
}).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rotates every vault behind this membership list that 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>
|
||||||
|
/// The list rather than the one vault, for the reason <see cref="ShareWithAsync"/> gives: removing
|
||||||
|
/// somebody is a change to the list, so every vault it carries is one they have just lost. A vault
|
||||||
|
/// this machine cannot open is not rotated and is not counted as a failure: its key belongs to
|
||||||
|
/// somebody else, the server has flagged it as owing a rekey, and its row says so until one of them
|
||||||
|
/// does it.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<string> RotateAfterRemovalAsync(
|
||||||
|
IVaultServer server,
|
||||||
|
Guid teamId,
|
||||||
|
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, server.Sync, 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 it, 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)
|
||||||
|
{
|
||||||
|
sentences.AddRange(Describe(rotated));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.Count > 0)
|
||||||
|
{
|
||||||
|
sentences.Add(
|
||||||
|
$"Could not rotate {Join(failed.Select(r => $"'{r.Name}' ({r.Failure?.Message})"))}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 vault and can still write, but
|
||||||
|
// until somebody wraps it to them they will find it 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>
|
||||||
|
private string Name(Guid userId) =>
|
||||||
|
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wraps the selected vault's key to the selected member.
|
/// Wraps the selected vault's key to the selected member.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1216,8 +1445,16 @@ internal sealed partial class VaultsViewModel(
|
|||||||
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
|
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.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
|
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}";
|
: $"Did not share '{vault.Name}': {outcome.Message}";
|
||||||
|
|
||||||
await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);
|
await LoadGrantsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|||||||
@@ -159,6 +159,31 @@ internal sealed class CachedVaultRow
|
|||||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
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>
|
/// <summary>
|
||||||
/// The last state of an item that the server confirmed.
|
/// The last state of an item that the server confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
|||||||
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
|
private static void ConfigureVaults(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
modelBuilder.Entity<CachedVaultRow>(entity =>
|
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("vault");
|
entity.ToTable("vault");
|
||||||
@@ -132,6 +133,18 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
|||||||
entity.Property(row => row.Name).IsRequired();
|
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) =>
|
private static void ConfigureItems(ModelBuilder modelBuilder) =>
|
||||||
modelBuilder.Entity<CachedItemRow>(entity =>
|
modelBuilder.Entity<CachedItemRow>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
+465
@@ -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);
|
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 =>
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("VaultId")
|
b.Property<Guid>("VaultId")
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ public sealed record StoredUnlockMaterial(
|
|||||||
/// <param name="Permissions">Effective permissions, as a flags value.</param>
|
/// <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="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="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(
|
public sealed record StoredVault(
|
||||||
Guid VaultId,
|
Guid VaultId,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -79,7 +84,8 @@ public sealed record StoredVault(
|
|||||||
uint KeyGeneration,
|
uint KeyGeneration,
|
||||||
int Permissions,
|
int Permissions,
|
||||||
byte[]? WrappedVaultKey,
|
byte[]? WrappedVaultKey,
|
||||||
bool RekeyRequired)
|
bool RekeyRequired,
|
||||||
|
IReadOnlyList<VaultKeyWrap>? PriorKeyWraps = null)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The <c>Write</c> bit of <see cref="Permissions"/>.
|
/// The <c>Write</c> bit of <see cref="Permissions"/>.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using DodoSSH.Contracts;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Storage;
|
namespace DodoSSH.Client.Storage;
|
||||||
@@ -25,7 +26,9 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
|||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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>
|
/// <summary>Reads one vault.</summary>
|
||||||
@@ -39,7 +42,19 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
|||||||
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
|
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.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>
|
/// <summary>
|
||||||
@@ -81,10 +96,20 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
|||||||
}
|
}
|
||||||
|
|
||||||
Apply(row, vault, now);
|
Apply(row, vault, now);
|
||||||
|
|
||||||
|
await ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
context.RemoveRange(existing.Values);
|
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);
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +141,8 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
|||||||
|
|
||||||
Apply(row, vault, clock.GetUtcNow());
|
Apply(row, vault, clock.GetUtcNow());
|
||||||
|
|
||||||
|
await ApplyWrapsAsync(context, vault, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +209,66 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
|||||||
row.UpdatedAtUtc = now;
|
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(
|
new(
|
||||||
row.VaultId,
|
row.VaultId,
|
||||||
row.Name,
|
row.Name,
|
||||||
@@ -191,5 +277,6 @@ public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, T
|
|||||||
row.KeyGeneration,
|
row.KeyGeneration,
|
||||||
row.Permissions,
|
row.Permissions,
|
||||||
row.WrappedVaultKey,
|
row.WrappedVaultKey,
|
||||||
row.RekeyRequired);
|
row.RekeyRequired,
|
||||||
|
priorWraps);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,14 +144,19 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pending);
|
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.";
|
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(
|
var local = kind.TryOpen(
|
||||||
pending.Payload,
|
pending.Payload,
|
||||||
vaultKey.Span,
|
queuedKey.Span,
|
||||||
pending.EntityId,
|
pending.EntityId,
|
||||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||||
|
|
||||||
@@ -253,8 +258,16 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
|
|
||||||
var (local, remoteSecret, vaultKey, generation) = opened.Value;
|
var (local, remoteSecret, vaultKey, generation) = opened.Value;
|
||||||
|
|
||||||
var ancestor = kind.TryOpen(
|
// The ancestor is the version the server last confirmed, so it carries its own generation —
|
||||||
pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version);
|
// 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)
|
if (ancestor is null)
|
||||||
{
|
{
|
||||||
@@ -313,10 +326,11 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var local = pending.Payload is null
|
var local = pending.Payload is null
|
||||||
|
|| !keyring.TryGetAt(vaultId, pending.Payload.KeyGeneration, out var queuedKey)
|
||||||
? null
|
? null
|
||||||
: kind.TryOpen(
|
: kind.TryOpen(
|
||||||
pending.Payload,
|
pending.Payload,
|
||||||
vaultKey.Span,
|
queuedKey.Span,
|
||||||
remote.EntityId,
|
remote.EntityId,
|
||||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||||
|
|
||||||
@@ -419,21 +433,26 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
{
|
{
|
||||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||||
|| pending.Payload is null
|
|| 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)
|
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return null;
|
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(
|
var local = kind.TryOpen(
|
||||||
pending.Payload,
|
pending.Payload,
|
||||||
vaultKey.Span,
|
queuedKey.Span,
|
||||||
remote.EntityId,
|
remote.EntityId,
|
||||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||||
|
|
||||||
var remoteSecret = kind.TryOpen(
|
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)
|
if (local is null || remoteSecret is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ internal sealed class VaultItemRepository<TSecret>(
|
|||||||
Guid vaultId,
|
Guid vaultId,
|
||||||
CancellationToken cancellationToken)
|
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);
|
throw new VaultUnreadableException(vaultId);
|
||||||
}
|
}
|
||||||
@@ -104,31 +107,17 @@ internal sealed class VaultItemRepository<TSecret>(
|
|||||||
{
|
{
|
||||||
if (pendingByEntity.Remove(item.EntityId, out var local))
|
if (pendingByEntity.Remove(item.EntityId, out var local))
|
||||||
{
|
{
|
||||||
AddPending(listed, ref unreadable, vaultKey, local);
|
AddPending(listed, ref unreadable, vaultId, local);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.IsDeleted || item.Payload is null)
|
AddMirrored(listed, ref unreadable, vaultId, item);
|
||||||
{
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whatever is left has no mirror row yet: items created here and not yet accepted.
|
// Whatever is left has no mirror row yet: items created here and not yet accepted.
|
||||||
foreach (var local in pendingByEntity.Values)
|
foreach (var local in pendingByEntity.Values)
|
||||||
{
|
{
|
||||||
AddPending(listed, ref unreadable, vaultKey, local);
|
AddPending(listed, ref unreadable, vaultId, local);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ItemListing<TSecret>(listed, unreadable);
|
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
|
// 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.
|
// holds the version the server will assign, and a mirror row holds the one it has.
|
||||||
var before = IsAudited
|
var before = IsAudited
|
||||||
? Open(vaultKey, entityId, pending, ancestor)
|
? Open(vaultId, entityId, pending, ancestor)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await outbox.QueueAsync(
|
await outbox.QueueAsync(
|
||||||
@@ -327,15 +316,10 @@ internal sealed class VaultItemRepository<TSecret>(
|
|||||||
PendingOperation? pending,
|
PendingOperation? pending,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!keyring.TryGet(vaultId, out var vaultKey, out _))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken)
|
var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return Open(vaultKey, entityId, pending, ancestor)?.Label;
|
return Open(vaultId, entityId, pending, ancestor)?.Label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// at the version the server <em>will</em> assign, and a mirror row holds the one it has.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private TSecret? Open(
|
private TSecret? Open(
|
||||||
ReadOnlyMemory<byte> vaultKey,
|
Guid vaultId,
|
||||||
Guid entityId,
|
Guid entityId,
|
||||||
PendingOperation? pending,
|
PendingOperation? pending,
|
||||||
StoredAncestor? ancestor)
|
StoredAncestor? ancestor)
|
||||||
{
|
{
|
||||||
if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued })
|
if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued })
|
||||||
{
|
{
|
||||||
return kind.TryOpen(
|
return keyring.TryGetAt(vaultId, queued.KeyGeneration, out var queuedKey)
|
||||||
|
? kind.TryOpen(
|
||||||
queued,
|
queued,
|
||||||
vaultKey.Span,
|
queuedKey.Span,
|
||||||
entityId,
|
entityId,
|
||||||
SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret;
|
SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ancestor is null
|
if (ancestor is null
|
||||||
? null
|
|| !keyring.TryGetAt(vaultId, ancestor.Payload.KeyGeneration, out var vaultKey))
|
||||||
: kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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(
|
private void AddPending(
|
||||||
List<VaultItem<TSecret>> listed,
|
List<VaultItem<TSecret>> listed,
|
||||||
ref int unreadable,
|
ref int unreadable,
|
||||||
ReadOnlyMemory<byte> vaultKey,
|
Guid vaultId,
|
||||||
PendingOperation local)
|
PendingOperation local)
|
||||||
{
|
{
|
||||||
if (local.Operation == SyncOperation.Delete)
|
if (local.Operation == SyncOperation.Delete)
|
||||||
@@ -419,6 +442,14 @@ internal sealed class VaultItemRepository<TSecret>(
|
|||||||
return;
|
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 version = SyncVersions.NextVersion(local.ExpectedVersion);
|
||||||
var opened = kind.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version);
|
var opened = kind.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
/// "the keys exist only while unlocked" a property of the code and not of everyone's discipline.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <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
|
/// 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
|
/// 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
|
/// 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>
|
/// </remarks>
|
||||||
public sealed class VaultKeyring : IDisposable
|
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 readonly Dictionary<Guid, uint> generations = [];
|
||||||
private bool disposed;
|
private bool disposed;
|
||||||
|
|
||||||
@@ -51,6 +58,12 @@ public sealed class VaultKeyring : IDisposable
|
|||||||
{
|
{
|
||||||
foreach (var vault in vaults)
|
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)
|
if (vault.WrappedVaultKey is null)
|
||||||
{
|
{
|
||||||
// The server said so itself: a grant awaiting re-wrap after a rekey.
|
// The server said so itself: a grant awaiting re-wrap after a rekey.
|
||||||
@@ -70,8 +83,7 @@ public sealed class VaultKeyring : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
keyring.keys[vault.VaultId] = key;
|
keyring.Adopt(vault.VaultId, key, vault.KeyGeneration);
|
||||||
keyring.generations[vault.VaultId] = vault.KeyGeneration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
keyring.Unopened = unopened;
|
keyring.Unopened = unopened;
|
||||||
@@ -94,26 +106,49 @@ public sealed class VaultKeyring : IDisposable
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="keyGeneration">The generation this key is for.</param>
|
/// <param name="keyGeneration">The generation this key is for.</param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Creating a team vault is the only case: the client generates the key, wraps it to itself and
|
/// <para>
|
||||||
/// sends the wrap, so the plaintext is already here and unwrapping the server's copy back would be
|
/// Two cases, and they are the same operation: creating a team vault, and rotating one. Both
|
||||||
/// a round trip to learn something this process just chose. Adopting it also means the new vault is
|
/// generate the key here, wrap it to this user and send the wrap, so the plaintext is already in
|
||||||
/// usable immediately rather than at the next unlock, which is what somebody who just pressed
|
/// this process and unwrapping the server's copy back would be a round trip to learn something it
|
||||||
/// "create" expects.
|
/// 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>
|
/// </remarks>
|
||||||
public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration)
|
public void Adopt(Guid vaultId, byte[] vaultKey, uint keyGeneration)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(disposed, this);
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
ArgumentNullException.ThrowIfNull(vaultKey);
|
ArgumentNullException.ThrowIfNull(vaultKey);
|
||||||
|
|
||||||
if (keys.TryGetValue(vaultId, out var previous))
|
Store(vaultId, vaultKey, keyGeneration);
|
||||||
{
|
|
||||||
CryptographicOperations.ZeroMemory(previous);
|
Promote(vaultId, keyGeneration);
|
||||||
}
|
}
|
||||||
|
|
||||||
keys[vaultId] = vaultKey;
|
/// <summary>
|
||||||
generations[vaultId] = keyGeneration;
|
/// 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);
|
||||||
|
|
||||||
Unopened = [.. Unopened.Where(id => id != vaultId)];
|
Store(vaultId, vaultKey, keyGeneration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -132,13 +167,23 @@ public sealed class VaultKeyring : IDisposable
|
|||||||
ArgumentNullException.ThrowIfNull(bundle);
|
ArgumentNullException.ThrowIfNull(bundle);
|
||||||
ArgumentNullException.ThrowIfNull(vault);
|
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)
|
if (vault.WrappedVaultKey is null)
|
||||||
{
|
{
|
||||||
return false;
|
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;
|
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>
|
/// <summary>Records that a vault cannot be read, so the interface can say so.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The counterpart of <see cref="TryAdmit"/> for the case where the grant did not open. Kept
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
public void MarkUnreadable(Guid vaultId)
|
public void MarkUnreadable(Guid vaultId)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(disposed, this);
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
|
||||||
|
generations.Remove(vaultId);
|
||||||
|
|
||||||
if (!Unopened.Contains(vaultId))
|
if (!Unopened.Contains(vaultId))
|
||||||
{
|
{
|
||||||
Unopened = [.. Unopened, vaultId];
|
Unopened = [.. Unopened, vaultId];
|
||||||
@@ -172,20 +229,27 @@ public sealed class VaultKeyring : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Borrows a vault's key.
|
/// Borrows a vault's current key: the one new items are sealed under.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The returned memory is the keyring's own buffer, not a copy, and is zeroed when the keyring is
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
public bool TryGet(Guid vaultId, out ReadOnlyMemory<byte> vaultKey, out uint keyGeneration)
|
public bool TryGet(Guid vaultId, out ReadOnlyMemory<byte> vaultKey, out uint keyGeneration)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(disposed, this);
|
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;
|
vaultKey = key;
|
||||||
keyGeneration = generations[vaultId];
|
keyGeneration = current;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +258,52 @@ public sealed class VaultKeyring : IDisposable
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Whether this vault can be read at all.</summary>
|
/// <summary>
|
||||||
public bool CanRead(Guid vaultId) => !disposed && keys.ContainsKey(vaultId);
|
/// 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 />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -207,14 +315,70 @@ public sealed class VaultKeyring : IDisposable
|
|||||||
|
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|
||||||
foreach (var key in keys.Values)
|
foreach (var held in keys.Values)
|
||||||
|
{
|
||||||
|
foreach (var key in held.Values)
|
||||||
{
|
{
|
||||||
CryptographicOperations.ZeroMemory(key);
|
CryptographicOperations.ZeroMemory(key);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
keys.Clear();
|
keys.Clear();
|
||||||
generations.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>
|
/// <summary>Thrown when an operation needs a vault key the keyring does not hold.</summary>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ namespace DodoSSH.Contracts;
|
|||||||
[JsonSerializable(typeof(CreateTeamVaultRequest))]
|
[JsonSerializable(typeof(CreateTeamVaultRequest))]
|
||||||
[JsonSerializable(typeof(UpdateVaultRequest))]
|
[JsonSerializable(typeof(UpdateVaultRequest))]
|
||||||
[JsonSerializable(typeof(IssueVaultGrantRequest))]
|
[JsonSerializable(typeof(IssueVaultGrantRequest))]
|
||||||
|
[JsonSerializable(typeof(RekeyVaultRequest))]
|
||||||
[JsonSerializable(typeof(VaultGrantsResponse))]
|
[JsonSerializable(typeof(VaultGrantsResponse))]
|
||||||
[JsonSerializable(typeof(KeyLogPage))]
|
[JsonSerializable(typeof(KeyLogPage))]
|
||||||
[JsonSerializable(typeof(SyncPullRequest))]
|
[JsonSerializable(typeof(SyncPullRequest))]
|
||||||
|
|||||||
@@ -209,6 +209,16 @@ public sealed record MeResponse(
|
|||||||
/// must complete it.
|
/// must complete it.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</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(
|
public sealed record VaultSummary(
|
||||||
Guid VaultId,
|
Guid VaultId,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -217,4 +227,14 @@ public sealed record VaultSummary(
|
|||||||
uint KeyGeneration,
|
uint KeyGeneration,
|
||||||
int Permissions,
|
int Permissions,
|
||||||
byte[]? WrappedVaultKey,
|
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);
|
||||||
|
|||||||
@@ -359,6 +359,19 @@ DodoSSH.Contracts.RegisterDeviceResponse.EnrolledAt.get -> System.DateTimeOffset
|
|||||||
DodoSSH.Contracts.RegisterDeviceResponse.EnrolledAt.init -> void
|
DodoSSH.Contracts.RegisterDeviceResponse.EnrolledAt.init -> void
|
||||||
DodoSSH.Contracts.RegisterDeviceResponse.Equals(DodoSSH.Contracts.RegisterDeviceResponse? other) -> bool
|
DodoSSH.Contracts.RegisterDeviceResponse.Equals(DodoSSH.Contracts.RegisterDeviceResponse? other) -> bool
|
||||||
DodoSSH.Contracts.RegisterDeviceResponse.RegisterDeviceResponse(System.Guid DeviceId, System.DateTimeOffset EnrolledAt) -> void
|
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
|
||||||
DodoSSH.Contracts.RelayConfiguration.<Clone>$() -> DodoSSH.Contracts.RelayConfiguration!
|
DodoSSH.Contracts.RelayConfiguration.<Clone>$() -> DodoSSH.Contracts.RelayConfiguration!
|
||||||
DodoSSH.Contracts.RelayConfiguration.Deconstruct(out bool Enabled, out System.Uri? WebSocketUrl) -> void
|
DodoSSH.Contracts.RelayConfiguration.Deconstruct(out bool Enabled, out System.Uri? WebSocketUrl) -> void
|
||||||
@@ -712,9 +725,18 @@ DodoSSH.Contracts.VaultGrantSummary.RevokedAt.init -> void
|
|||||||
DodoSSH.Contracts.VaultGrantSummary.State.get -> DodoSSH.Contracts.VaultGrantState
|
DodoSSH.Contracts.VaultGrantSummary.State.get -> DodoSSH.Contracts.VaultGrantState
|
||||||
DodoSSH.Contracts.VaultGrantSummary.State.init -> void
|
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.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
|
||||||
DodoSSH.Contracts.VaultSummary.<Clone>$() -> 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.Equals(DodoSSH.Contracts.VaultSummary? other) -> bool
|
||||||
DodoSSH.Contracts.VaultSummary.IsPersonal.get -> bool
|
DodoSSH.Contracts.VaultSummary.IsPersonal.get -> bool
|
||||||
DodoSSH.Contracts.VaultSummary.IsPersonal.init -> void
|
DodoSSH.Contracts.VaultSummary.IsPersonal.init -> void
|
||||||
@@ -724,13 +746,15 @@ DodoSSH.Contracts.VaultSummary.Name.get -> string!
|
|||||||
DodoSSH.Contracts.VaultSummary.Name.init -> void
|
DodoSSH.Contracts.VaultSummary.Name.init -> void
|
||||||
DodoSSH.Contracts.VaultSummary.Permissions.get -> int
|
DodoSSH.Contracts.VaultSummary.Permissions.get -> int
|
||||||
DodoSSH.Contracts.VaultSummary.Permissions.init -> void
|
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.get -> bool
|
||||||
DodoSSH.Contracts.VaultSummary.RekeyRequired.init -> void
|
DodoSSH.Contracts.VaultSummary.RekeyRequired.init -> void
|
||||||
DodoSSH.Contracts.VaultSummary.TeamId.get -> System.Guid?
|
DodoSSH.Contracts.VaultSummary.TeamId.get -> System.Guid?
|
||||||
DodoSSH.Contracts.VaultSummary.TeamId.init -> void
|
DodoSSH.Contracts.VaultSummary.TeamId.init -> void
|
||||||
DodoSSH.Contracts.VaultSummary.VaultId.get -> System.Guid
|
DodoSSH.Contracts.VaultSummary.VaultId.get -> System.Guid
|
||||||
DodoSSH.Contracts.VaultSummary.VaultId.init -> void
|
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.get -> byte[]?
|
||||||
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void
|
DodoSSH.Contracts.VaultSummary.WrappedVaultKey.init -> void
|
||||||
override DodoSSH.Contracts.AddTeamMemberRequest.Equals(object? obj) -> bool
|
override DodoSSH.Contracts.AddTeamMemberRequest.Equals(object? obj) -> bool
|
||||||
@@ -796,6 +820,9 @@ override DodoSSH.Contracts.RegisterDeviceRequest.ToString() -> string!
|
|||||||
override DodoSSH.Contracts.RegisterDeviceResponse.Equals(object? obj) -> bool
|
override DodoSSH.Contracts.RegisterDeviceResponse.Equals(object? obj) -> bool
|
||||||
override DodoSSH.Contracts.RegisterDeviceResponse.GetHashCode() -> int
|
override DodoSSH.Contracts.RegisterDeviceResponse.GetHashCode() -> int
|
||||||
override DodoSSH.Contracts.RegisterDeviceResponse.ToString() -> string!
|
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.Equals(object? obj) -> bool
|
||||||
override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int
|
override DodoSSH.Contracts.RelayConfiguration.GetHashCode() -> int
|
||||||
override DodoSSH.Contracts.RelayConfiguration.ToString() -> string!
|
override DodoSSH.Contracts.RelayConfiguration.ToString() -> string!
|
||||||
@@ -856,6 +883,9 @@ override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
|
|||||||
override DodoSSH.Contracts.VaultGrantSummary.Equals(object? obj) -> bool
|
override DodoSSH.Contracts.VaultGrantSummary.Equals(object? obj) -> bool
|
||||||
override DodoSSH.Contracts.VaultGrantSummary.GetHashCode() -> int
|
override DodoSSH.Contracts.VaultGrantSummary.GetHashCode() -> int
|
||||||
override DodoSSH.Contracts.VaultGrantSummary.ToString() -> string!
|
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.Equals(object? obj) -> bool
|
||||||
override DodoSSH.Contracts.VaultSummary.GetHashCode() -> int
|
override DodoSSH.Contracts.VaultSummary.GetHashCode() -> int
|
||||||
override DodoSSH.Contracts.VaultSummary.ToString() -> string!
|
override DodoSSH.Contracts.VaultSummary.ToString() -> string!
|
||||||
@@ -904,6 +934,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.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.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.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
|
static DodoSSH.Contracts.RelaySessionSummary.operator !=(DodoSSH.Contracts.RelaySessionSummary? left, DodoSSH.Contracts.RelaySessionSummary? right) -> bool
|
||||||
@@ -944,5 +976,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.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.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
|
||||||
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
|
||||||
|
|||||||
@@ -425,6 +425,37 @@ public sealed record IssueVaultGrantRequest(
|
|||||||
byte[] GrantSignature,
|
byte[] GrantSignature,
|
||||||
DateTimeOffset GrantedAt);
|
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>
|
/// <summary>One vault key grant, as the sharing interface sees it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The wrapped key itself is deliberately not here. A member reads their own through
|
/// The wrapped key itself is deliberately not here. A member reads their own through
|
||||||
@@ -457,7 +488,12 @@ public sealed record VaultGrantSummary(
|
|||||||
/// compares against rather than inferring from <see cref="VaultGrantSummary.State"/> alone.
|
/// compares against rather than inferring from <see cref="VaultGrantSummary.State"/> alone.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</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(
|
public sealed record VaultGrantsResponse(
|
||||||
Guid VaultId,
|
Guid VaultId,
|
||||||
uint KeyGeneration,
|
uint KeyGeneration,
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ public sealed class EndpointInventoryTests(ApiFixture fixture)
|
|||||||
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
|
"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",
|
"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
|
// 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.
|
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
|
||||||
// MapHealthChecks constrains no verb, hence ANY.
|
// MapHealthChecks constrains no verb, hence ANY.
|
||||||
|
|||||||
@@ -1127,6 +1127,198 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|||||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
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 ----
|
// ---- The directory and the key log ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1301,6 +1493,40 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
|||||||
return vault.VaultId;
|
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)
|
private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
|
||||||
{
|
{
|
||||||
var address = addresses.GetValueOrDefault(email, email);
|
var address = addresses.GetValueOrDefault(email, email);
|
||||||
|
|||||||
@@ -261,6 +261,12 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
|
|||||||
Guid userId,
|
Guid userId,
|
||||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<VaultSummary> RekeyVaultAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
RekeyVaultRequest request,
|
||||||
|
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,14 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
private readonly List<TeamSummary> teams = [];
|
private readonly List<TeamSummary> teams = [];
|
||||||
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
|
private readonly Dictionary<Guid, List<TeamMemberSummary>> members = [];
|
||||||
private readonly Dictionary<Guid, VaultSummary> teamVaults = [];
|
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<KeyLogRecord> keyLog = [];
|
||||||
private readonly List<DirectoryEntry> directory = [];
|
private readonly List<DirectoryEntry> directory = [];
|
||||||
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
|
private readonly Dictionary<Guid, List<TeamInvitationSummary>> invitations = [];
|
||||||
@@ -51,8 +58,29 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IVaultGrantApi Grants => this;
|
public IVaultGrantApi Grants => this;
|
||||||
|
|
||||||
/// <summary>Grants this fake has been asked to record, for a test to assert on.</summary>
|
/// <summary>
|
||||||
internal IReadOnlyDictionary<(Guid VaultId, Guid UserId), IssueVaultGrantRequest> IssuedGrants => grants;
|
/// 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>
|
/// <summary>
|
||||||
/// When true, the log served omits its last entry's link, so its chain no longer verifies.
|
/// 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
|
// 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
|
// same transaction. A fake that removed the membership and left the grants would let a test
|
||||||
// "prove" a revocation that had not happened.
|
// "prove" a revocation that had not happened.
|
||||||
foreach (var vaultId in teamVaults.Values
|
var theirs = grants.Keys
|
||||||
.Where(vault => vault.TeamId == teamId)
|
.Where(key => key.UserId == userId
|
||||||
.Select(vault => vault.VaultId))
|
&& 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);
|
Recount(teamId);
|
||||||
@@ -491,6 +525,17 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
|
|
||||||
teamVaults[vault.VaultId] = vault;
|
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);
|
Recount(teamId);
|
||||||
|
|
||||||
return Task.FromResult(vault);
|
return Task.FromResult(vault);
|
||||||
@@ -581,16 +626,20 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
Task.FromResult(new VaultGrantsResponse(
|
Task.FromResult(new VaultGrantsResponse(
|
||||||
vaultId,
|
vaultId,
|
||||||
KeyGeneration: 1,
|
KeyGeneration: Generation(vaultId),
|
||||||
RekeyRequired: false,
|
RekeyRequired: false,
|
||||||
Grants:
|
Grants:
|
||||||
[
|
[
|
||||||
.. grants.Where(entry => entry.Key.VaultId == vaultId).Select(entry =>
|
// One row per holder rather than per grant, as the real listing shows a member once
|
||||||
new VaultGrantSummary(
|
// and lets the generation say whether their key is current.
|
||||||
entry.Key.UserId,
|
.. grants
|
||||||
directory.Find(candidate => candidate.UserId == entry.Key.UserId)?.Email,
|
.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,
|
null,
|
||||||
KeyGeneration: 1,
|
KeyGeneration: group.Max(entry => entry.Key.KeyGeneration),
|
||||||
VaultGrantState.Active,
|
VaultGrantState.Active,
|
||||||
UserId,
|
UserId,
|
||||||
DateTimeOffset.UnixEpoch,
|
DateTimeOffset.UnixEpoch,
|
||||||
@@ -603,17 +652,88 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
IssueVaultGrantRequest request,
|
IssueVaultGrantRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
grants[(vaultId, request.RecipientUserId)] = request;
|
grants[(vaultId, request.RecipientUserId, request.KeyGeneration)] = request;
|
||||||
|
|
||||||
return Task.CompletedTask;
|
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 />
|
/// <inheritdoc />
|
||||||
public Task<bool> RevokeVaultGrantAsync(
|
public Task<bool> RevokeVaultGrantAsync(
|
||||||
Guid vaultId,
|
Guid vaultId,
|
||||||
Guid userId,
|
Guid userId,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken)
|
||||||
Task.FromResult(grants.Remove((vaultId, userId)));
|
{
|
||||||
|
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>
|
/// <summary>Publishes the enrolling account's own key, in the directory and the key log.</summary>
|
||||||
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
|
private void RegisterSelf(KeyStatement statement, byte[] statementSignature)
|
||||||
|
|||||||
@@ -217,6 +217,70 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
Queued().ShouldHaveSingleItem();
|
Queued().ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The phone's way in, and it has to obey the same rules as every other: a document chosen in the system
|
||||||
|
/// picker is copied into the cache and the copy is queued, which is an upload with one extra property —
|
||||||
|
/// that this application made the file and will delete it again. Everything about *what may be queued*
|
||||||
|
/// is the same, and this says so rather than leaving a second path free to drift.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void StagedUploads_QueueUnderTheSameRulesAsAnyOther()
|
||||||
|
{
|
||||||
|
Connected();
|
||||||
|
|
||||||
|
var folder = Path.Combine(directory, "a-folder");
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
|
transfers.QueueStagedUploads([File("picked.txt"), folder]);
|
||||||
|
|
||||||
|
Queued().ShouldHaveSingleItem();
|
||||||
|
transfers.Status.ShouldContain("1 file");
|
||||||
|
transfers.Status.ShouldContain("1 folder was skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The phone's way out. The delivery itself — copying the finished file into the document the save
|
||||||
|
/// picker made — needs a transfer that actually runs and a picker to have made something, so it is
|
||||||
|
/// checked by hand in <c>docs/manual-checks.md</c> phase 14. What is worth pinning here is the pair of
|
||||||
|
/// refusals in front of it, because both would otherwise be discovered as an empty file sitting in
|
||||||
|
/// somebody's Downloads: the picker creates the destination the moment it is dismissed, so anything
|
||||||
|
/// this method turns away after that point has already cost a visible artefact.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ADeliveredDownload_QueuesTheFileAndRefusesADirectory()
|
||||||
|
{
|
||||||
|
var delivered = 0;
|
||||||
|
|
||||||
|
Connected();
|
||||||
|
|
||||||
|
transfers.QueueDeliveredDownload(
|
||||||
|
RemoteFile("one.log"),
|
||||||
|
Path.Combine(directory, "staged", "one.log"),
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
delivered++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
Queued().ShouldHaveSingleItem();
|
||||||
|
transfers.Status.ShouldContain("one.log");
|
||||||
|
|
||||||
|
// A directory has nothing to fetch, and the message is the same one every other path on this screen
|
||||||
|
// gives for the same mistake.
|
||||||
|
transfers.QueueDeliveredDownload(
|
||||||
|
RemoteDirectory("logs"),
|
||||||
|
Path.Combine(directory, "staged", "logs"),
|
||||||
|
_ => Task.CompletedTask);
|
||||||
|
|
||||||
|
Queued().Count.ShouldBe(1);
|
||||||
|
transfers.Status.ShouldContain("Only files");
|
||||||
|
|
||||||
|
// Nothing is delivered by queueing. The callback runs when the bytes are there and not before.
|
||||||
|
delivered.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
||||||
|
|||||||
@@ -104,12 +104,42 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The whole point of a shared vault, in one test. Note what the status line says after the add and
|
/// The whole point of a shared vault, in one test. Adding somebody wraps the vault to them, so the
|
||||||
/// before the share: adding somebody grants them nothing readable, and the interface has to say so
|
/// status line names what they were given rather than what is still owed — and the grant is on the
|
||||||
/// rather than let a user believe the credential is already with their colleague.
|
/// server before the add has finished reporting.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreatingAVaultAndSharingIt_WrapsTheKeyToTheOtherMember()
|
public async Task AddingSomebody_WrapsTheVaultToThemStraightAway()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
var colleague = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
vaults.InviteEmail = "bob@example.com";
|
||||||
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||||
|
|
||||||
|
server.IssuedGrants.ShouldContainKey(
|
||||||
|
(vaultId, colleague),
|
||||||
|
"adding somebody to a vault is what shares it with them");
|
||||||
|
|
||||||
|
vaults.Status.ShouldContain("Platform secrets");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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();
|
await UnlockedAsync();
|
||||||
|
|
||||||
@@ -121,9 +151,6 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vaults.InviteEmail = "bob@example.com";
|
vaults.InviteEmail = "bob@example.com";
|
||||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
|
||||||
vaults.Status.ShouldContain("cannot read anything yet");
|
|
||||||
|
|
||||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||||
|
|
||||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||||
@@ -137,6 +164,91 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
|
vaults.Status.ShouldContain("fingerprint", Case.Insensitive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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 RemovingSomebody_RotatesTheVaultAndHandsTheNewKeyToWhoIsLeft()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
var leaving = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
var staying = server.AddAccount("carol@example.com", "Carol Example");
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
foreach (var address in (string[])["bob@example.com", "carol@example.com"])
|
||||||
|
{
|
||||||
|
vaults.InviteEmail = address;
|
||||||
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
vaults.Members.Count.ShouldBe(3, vaults.Status);
|
||||||
|
|
||||||
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == leaving);
|
||||||
|
|
||||||
|
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vaults.Status.ShouldContain("Rotated", customMessage: vaults.Status);
|
||||||
|
vaults.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.
|
||||||
|
vaults.Status.ShouldContain("re-sealed under the new key", customMessage: vaults.Status);
|
||||||
|
|
||||||
|
// 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 AddingSomebodyToARotatedVault_HandsThemItsHistoryAsWell()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
var first = server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
var second = server.AddAccount("carol@example.com", "Carol Example");
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Platform secrets");
|
||||||
|
|
||||||
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
vaults.InviteEmail = "bob@example.com";
|
||||||
|
await vaults.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.
|
||||||
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == first);
|
||||||
|
await vaults.RemoveMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vaults.InviteEmail = "carol@example.com";
|
||||||
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
server.GenerationsGranted(vaultId, second).ShouldBe([1u, 2u], vaults.Status);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The test this whole design exists for. A server that wants to read a shared vault only has to
|
/// The test this whole design exists for. A server that wants to read a shared vault only has to
|
||||||
@@ -159,16 +271,24 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
|
|
||||||
await CreateVaultAsync(vaults, "Platform secrets");
|
await CreateVaultAsync(vaults, "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;
|
||||||
|
|
||||||
vaults.InviteEmail = "mallory@example.com";
|
vaults.InviteEmail = "mallory@example.com";
|
||||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
var vaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
server.CorruptKeyLog = true;
|
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
||||||
|
vaults.Status.ShouldContain("Could not share");
|
||||||
|
vaults.Status.ShouldContain("key log");
|
||||||
|
|
||||||
|
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
||||||
|
|
||||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
server.IssuedGrants.ShouldBeEmpty();
|
server.IssuedGrants.ShouldNotContainKey((vaultId, colleague));
|
||||||
vaults.Status.ShouldContain("Did not share");
|
vaults.Status.ShouldContain("Did not share");
|
||||||
vaults.Status.ShouldContain("key log");
|
vaults.Status.ShouldContain("key log");
|
||||||
}
|
}
|
||||||
@@ -409,8 +529,8 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The screen's answer to "who can actually open this". Asserted after a share rather than before,
|
/// The screen's answer to "who can actually open this". Asserted after somebody has been added
|
||||||
/// because an empty list proves nothing about whether the call was made.
|
/// rather than before, because an empty list proves nothing about whether the call was made.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
|
public async Task SelectingAVault_ListsWhoHoldsAKeyToIt()
|
||||||
@@ -425,13 +545,13 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vaults.InviteEmail = "bob@example.com";
|
vaults.InviteEmail = "bob@example.com";
|
||||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vaults.SelectedMember = vaults.Members.Single(member => member.UserId == colleague);
|
// Two, and the creator is the other: their own self-grant is what makes a vault they just made
|
||||||
|
// readable at all, so a list that left it out would show the one person who can certainly open
|
||||||
|
// this vault as somebody who cannot.
|
||||||
|
vaults.Grants.Count.ShouldBe(2, vaults.Status);
|
||||||
|
|
||||||
await vaults.ShareVaultCommand.ExecuteAsync(null);
|
var holder = vaults.Grants.Single(row => row.UserId == colleague);
|
||||||
|
|
||||||
var holder = vaults.Grants.ShouldHaveSingleItem();
|
|
||||||
|
|
||||||
holder.UserId.ShouldBe(colleague);
|
|
||||||
holder.IsLive.ShouldBeTrue(vaults.Status);
|
holder.IsLive.ShouldBeTrue(vaults.Status);
|
||||||
holder.State.ShouldBe("holds a key");
|
holder.State.ShouldBe("holds a key");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user