Files
DodoSSH/src/DodoSSH.Client.Session/VaultSharing.cs
T
jaap-jan e9cea2ccbc Let a shared vault arrive, a bucket be found, and a vault be deleted
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.

**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.

So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.

The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.

**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.

The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.

The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.

**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.

The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.

Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.

The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.

Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.

Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
2026-08-04 15:34:40 +02:00

729 lines
31 KiB
C#

using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session;
/// <summary>What a share attempt did.</summary>
/// <param name="Shared">Whether a grant was recorded.</param>
/// <param name="Verification">
/// How the recipient's key was checked. Present whether or not the share went ahead, because a refusal
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
/// </param>
/// <param name="Message">One line for a person. Never contains key material.</param>
/// <param name="Generations">
/// How many generations of the vault key were wrapped. One for a vault that has never been rotated;
/// more for one that has, because its older items are still sealed under the keys they were written
/// with and a recipient given only the newest would find them unreadable.
/// </param>
public sealed record ShareOutcome(
bool Shared,
RecipientVerification Verification,
string Message,
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>
/// Sharing, from the side that holds the keys.
/// </summary>
/// <remarks>
/// <para>
/// These live on <see cref="VaultSession"/> rather than in a service above it for the reason
/// registering a device does: wrapping a vault key is the one step only an unlocked session can
/// perform, and this type is the keyring's custodian. Everything else — the calls, the directory —
/// arrives as a parameter, so the session still knows nothing about how either is implemented.
/// </para>
/// <para>
/// <b>Nothing here trusts the server's answer about somebody else's key.</b> Every share reads the
/// whole key log, verifies its hash chain, and refuses unless the directory's answer appears in it
/// unchanged. That check is the difference between end-to-end encryption and a server that can read
/// everything by handing out a key of its own; see <see cref="KeyLogAudit"/> and ADR 0001.
/// </para>
/// </remarks>
public sealed partial class VaultSession
{
/// <summary>
/// Creates a vault owned by a team, generating its key here.
/// </summary>
/// <param name="api">The team calls.</param>
/// <param name="teamId">The owning team.</param>
/// <param name="name">Display name. Plaintext, as all vault names are.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The new vault, already readable by this session.</returns>
/// <remarks>
/// The key never leaves this process in the clear: it is generated here, sealed to this user's own
/// encryption key, and the seal is what the server stores. The creator's grant carries no key log
/// head, exactly as a personal vault's does not — there is no third party whose key could have been
/// substituted when you wrap something to yourself.
/// </remarks>
public async Task<StoredVault> CreateTeamVaultAsync(
ITeamApi api,
Guid teamId,
string name,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var vaultId = Guid.CreateVersion7();
var vaultKey = VaultKeys.Create();
var now = clock.GetUtcNow();
try
{
var request = BuildCreateRequest(vaultId, vaultKey, name, now);
var summary = await api.CreateTeamVaultAsync(teamId, request, cancellationToken)
.ConfigureAwait(false);
var stored = ToStored(summary);
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
// Adopted rather than unwrapped from the response: this process generated the key, so
// unwrapping the server's copy of our own seal would be a round trip to learn something we
// already know. The keyring takes ownership from here.
keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return stored;
}
catch
{
// Never reached the keyring, so this is the only thing that can release it.
CryptographicOperations.ZeroMemory(vaultKey);
throw;
}
}
/// <summary>
/// Renames a vault, here and on the server.
/// </summary>
/// <param name="api">The vault calls.</param>
/// <param name="vaultId">The vault to rename.</param>
/// <param name="name">What to call it. Plaintext, as all vault names are.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The vault as this machine now holds it.</returns>
/// <remarks>
/// <para>
/// Nothing is re-encrypted. A vault's name is the one thing about it the server stores in the clear —
/// a person has to be able to choose a vault before anything is decrypted — so a rename is a plain
/// column write at both ends and touches no key.
/// </para>
/// <para>
/// <b>The cached row is edited rather than replaced with the response.</b> The server answers with a
/// summary written for a caller who is not this one: no wrapped key and no permissions, because it
/// has nothing to say about either that this session does not already hold. Replacing the cached row
/// with it would take this machine's own grant away and leave the vault unreadable until the next
/// refresh.
/// </para>
/// </remarks>
public async Task<StoredVault> RenameVaultAsync(
IVaultGrantApi api,
Guid vaultId,
string name,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var summary = await api
.RenameVaultAsync(vaultId, new UpdateVaultRequest(name), cancellationToken)
.ConfigureAwait(false);
var stored = Vaults.FirstOrDefault(vault => vault.VaultId == vaultId) is { } known
? known with { Name = summary.Name }
: ToStored(summary);
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return stored;
}
/// <summary>
/// Deletes a vault, here and on the server.
/// </summary>
/// <param name="api">The vault calls.</param>
/// <param name="vaultId">The vault to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Whether there was a vault to delete.</returns>
/// <remarks>
/// <para>
/// The server first, and this machine only if it agreed. The other order would take a vault off this
/// screen and leave it on everybody else's, which is the one outcome worse than the deletion failing:
/// the person who pressed it is then the only one who believes it is gone.
/// </para>
/// <para>
/// <b>The key is dropped from the keyring, and the items are not.</b> Dropping the key is what makes
/// this machine unable to read what is left, which is the honest end state — the ciphertext is still
/// in this cache, as it is in every other member's, and pretending otherwise by deleting the rows
/// would be claiming a reach this product does not have. See ADR 0001. The rows go with the next
/// wipe of the cache; nothing reads them meanwhile, because every list is built from the vaults the
/// keyring can open.
/// </para>
/// </remarks>
public async Task<bool> DeleteVaultAsync(
IVaultGrantApi api,
Guid vaultId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
if (!await api.DeleteVaultAsync(vaultId, cancellationToken).ConfigureAwait(false))
{
return false;
}
keyring.Forget(vaultId);
await Vault.RemoveAsync(vaultId, cancellationToken).ConfigureAwait(false);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
return true;
}
/// <summary>
/// Wraps a vault's key to another member, after verifying their published key.
/// </summary>
/// <param name="grants">The grant calls.</param>
/// <param name="directory">The directory and the key log that makes it checkable.</param>
/// <param name="vaultId">The vault to share.</param>
/// <param name="recipientUserId">Who to share it with.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// <para>
/// The verification is not optional and is not a parameter. A caller that could pass
/// <c>skipChecks: true</c> is a caller that will, on the day the log is briefly unreachable, and the
/// resulting grant is indistinguishable from a correct one afterwards.
/// </para>
/// <para>
/// What this still cannot promise is that the key belongs to the person you meant. Compare
/// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
/// that is the only step that closes the gap, and the outcome message says so.
/// </para>
/// <para>
/// <b>Every generation this session holds is wrapped, not only the newest.</b> A rotation does not
/// re-encrypt what is already stored, so a vault that has been rotated twice holds items under three
/// keys — and a recipient handed only the current one would open the vault to find most of it
/// unreadable. This is also the only party that can do it: the server holds ciphertext it cannot
/// read, and the recipient holds nothing yet.
/// </para>
/// </remarks>
public async Task<ShareOutcome> ShareVaultAsync(
IVaultGrantApi grants,
IDirectoryApi directory,
Guid vaultId,
Guid recipientUserId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(grants);
ArgumentNullException.ThrowIfNull(directory);
if (!keyring.TryGet(vaultId, out _, out _))
{
throw new VaultUnreadableException(vaultId);
}
var entry = await directory.LookupByIdAsync(recipientUserId, cancellationToken)
.ConfigureAwait(false);
var log = await KeyLogAudit.ReadAsync(directory, cancellationToken).ConfigureAwait(false);
var verification = KeyLogAudit.Verify(log, entry);
if (!verification.IsVerified)
{
return new ShareOutcome(false, verification, verification.Message);
}
var recipient = verification.Recipient!;
var generations = keyring.GenerationsHeld(vaultId);
// Oldest first, so an interruption leaves the recipient holding history without the present
// rather than the reverse. Both are incomplete; only one of them looks like a working vault
// that is quietly missing its recent items.
foreach (var generation in generations)
{
if (!keyring.TryGetAt(vaultId, generation, out var vaultKey))
{
continue;
}
await IssueAsync(grants, vaultId, vaultKey, generation, recipient, cancellationToken)
.ConfigureAwait(false);
}
return new ShareOutcome(
true,
verification,
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
+ "its own only proves this server has been consistent with itself.",
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>
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
/// </summary>
/// <returns>How many vaults this call made readable that were not before.</returns>
/// <remarks>
/// Called after a share and on a periodic pass. A vault somebody shared a minute ago arrives as a
/// new entry with a wrapped key attached; one whose grant was revoked arrives without one, and is
/// marked unreadable rather than quietly dropped so the interface can say what happened. Items
/// already pulled are deliberately left alone — see <see cref="VaultStore.ReplaceAllAsync"/>.
/// </remarks>
public async Task<int> RefreshVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
await Vault.ReplaceAllAsync([.. me.Vaults.Select(ToStored)], cancellationToken)
.ConfigureAwait(false);
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
var admitted = 0;
foreach (var vault in Vaults)
{
// Attempted even for a vault that already opens, because the answer can have grown: a
// rotated vault arrives with a new current generation, and a vault shared by somebody who
// holds more of its history arrives with wraps this session did not have. Admitting is
// idempotent, so the only thing an unconditional call costs is the unwrap it skips.
var readable = keyring.CanRead(vault.VaultId);
if (keyring.TryAdmit(bundle, vault))
{
if (!readable)
{
admitted++;
}
}
else
{
keyring.MarkUnreadable(vault.VaultId);
}
}
return admitted;
}
/// <summary>Signs and posts one grant.</summary>
private async Task IssueAsync(
IVaultGrantApi grants,
Guid vaultId,
ReadOnlyMemory<byte> vaultKey,
uint keyGeneration,
VerifiedRecipient recipient,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var entry = recipient.Entry;
var wrapped = VaultKeys.WrapTo(
vaultKey.Span, entry.EncryptionPublicKey, vaultId, keyGeneration);
var ownFingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
keyGeneration,
GrantPurpose.Member,
granteeUserId: entry.UserId,
granteeKeyFingerprint: recipient.Fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: ownFingerprint,
// Present, unlike a self-grant's. This is the third-party case the head exists for: it
// records which view of the key log this client held while wrapping, so a server showing
// two clients different logs has to keep both stories straight for ever after.
keyLogHead: recipient.KeyLogHead,
grantedAt: now);
await grants.IssueVaultGrantAsync(
vaultId,
new IssueVaultGrantRequest(
RecipientUserId: entry.UserId,
RecipientKeyFingerprint: recipient.Fingerprint,
KeyGeneration: keyGeneration,
WrappedVaultKey: wrapped,
KeyLogHead: recipient.KeyLogHead,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now),
cancellationToken)
.ConfigureAwait(false);
}
/// <remarks>
/// The signature covers the vault id, so the id has to be chosen before anything is wrapped — which
/// is also what makes a create whose response was lost safe to send again.
/// </remarks>
private CreateTeamVaultRequest BuildCreateRequest(
Guid vaultId,
byte[] vaultKey,
string name,
DateTimeOffset now)
{
var wrapped = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
var fingerprint = DshCrypto.ComputeFingerprint(
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
var canonical = GrantStatementCodec.Encode(
vaultId,
keyGeneration: 1,
GrantPurpose.Member,
granteeUserId: Profile.UserId,
granteeKeyFingerprint: fingerprint,
wrappedKey: wrapped,
granterUserId: Profile.UserId,
granterKeyFingerprint: fingerprint,
keyLogHead: default,
grantedAt: now);
return new CreateTeamVaultRequest(
VaultId: vaultId,
Name: name,
WrappedVaultKey: wrapped,
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
GrantedAt: now);
}
private static StoredVault ToStored(VaultSummary summary) =>
new(
summary.VaultId,
summary.Name,
summary.IsPersonal,
summary.TeamId,
summary.KeyGeneration,
summary.Permissions,
summary.WrappedVaultKey,
summary.RekeyRequired,
summary.PriorKeyWraps);
}