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:
@@ -196,5 +196,6 @@ public sealed class AccountProvisioner(
|
||||
summary.KeyGeneration,
|
||||
summary.Permissions,
|
||||
summary.WrappedVaultKey,
|
||||
summary.RekeyRequired);
|
||||
summary.RekeyRequired,
|
||||
summary.PriorKeyWraps);
|
||||
}
|
||||
|
||||
@@ -295,6 +295,31 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
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>
|
||||
/// Runs one synchronisation pass over every vault this session can read.
|
||||
/// </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.
|
||||
/// </param>
|
||||
/// <param name="Message">One line for a person. Never contains key material.</param>
|
||||
/// <param name="Generations">
|
||||
/// How many generations of the vault key were wrapped. One for a vault that has never been rotated;
|
||||
/// more for one that has, because its older items are still sealed under the keys they were written
|
||||
/// with and a recipient given only the newest would find them unreadable.
|
||||
/// </param>
|
||||
public sealed record ShareOutcome(
|
||||
bool Shared,
|
||||
RecipientVerification Verification,
|
||||
string Message);
|
||||
string Message,
|
||||
int Generations = 0);
|
||||
|
||||
/// <summary>What sharing or rotating one vault did, named so a message can say which vault.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name.</param>
|
||||
/// <param name="Outcome">What happened, when the attempt was made.</param>
|
||||
/// <param name="Failure">
|
||||
/// Why it was not, when it failed. Carried rather than thrown for the reason a per-vault sync report
|
||||
/// carries its own: one unreachable vault must not stop the others, and a vault that silently did not
|
||||
/// get the key is the outcome this whole design exists to make visible.
|
||||
/// </param>
|
||||
public sealed record VaultShareReport(
|
||||
Guid VaultId,
|
||||
string Name,
|
||||
ShareOutcome? Outcome,
|
||||
Exception? Failure)
|
||||
{
|
||||
/// <summary>Whether a grant was recorded for this vault.</summary>
|
||||
public bool Succeeded => Outcome is { Shared: true };
|
||||
}
|
||||
|
||||
/// <summary>What rotating one vault did.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name.</param>
|
||||
/// <param name="KeyGeneration">The generation it now holds, or zero if it was not rotated.</param>
|
||||
/// <param name="Shared">The members the new key was wrapped to.</param>
|
||||
/// <param name="NotShared">
|
||||
/// The members it was not, with the reason. A rotation that re-wrapped to nobody has locked the
|
||||
/// remaining members out of everything written from now on, which they must be told rather than left
|
||||
/// to discover.
|
||||
/// </param>
|
||||
/// <param name="Failure">Why the rotation itself did not happen, when it did not.</param>
|
||||
/// <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.
|
||||
@@ -160,6 +217,13 @@ public sealed partial class VaultSession
|
||||
/// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
|
||||
/// that is the only step that closes the gap, and the outcome message says so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every generation this session holds is wrapped, not only the newest.</b> A rotation does not
|
||||
/// re-encrypt what is already stored, so a vault that has been rotated twice holds items under three
|
||||
/// keys — and a recipient handed only the current one would open the vault to find most of it
|
||||
/// unreadable. This is also the only party that can do it: the server holds ciphertext it cannot
|
||||
/// read, and the recipient holds nothing yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<ShareOutcome> ShareVaultAsync(
|
||||
IVaultGrantApi grants,
|
||||
@@ -172,7 +236,7 @@ public sealed partial class VaultSession
|
||||
ArgumentNullException.ThrowIfNull(grants);
|
||||
ArgumentNullException.ThrowIfNull(directory);
|
||||
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var keyGeneration))
|
||||
if (!keyring.TryGet(vaultId, out _, out _))
|
||||
{
|
||||
throw new VaultUnreadableException(vaultId);
|
||||
}
|
||||
@@ -189,17 +253,292 @@ public sealed partial class VaultSession
|
||||
}
|
||||
|
||||
var recipient = verification.Recipient!;
|
||||
var generations = keyring.GenerationsHeld(vaultId);
|
||||
|
||||
await IssueAsync(grants, vaultId, vaultKey, keyGeneration, recipient, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
// Oldest first, so an interruption leaves the recipient holding history without the present
|
||||
// rather than the reverse. Both are incomplete; only one of them looks like a working vault
|
||||
// that is quietly missing its recent items.
|
||||
foreach (var generation in generations)
|
||||
{
|
||||
if (!keyring.TryGetAt(vaultId, generation, out var vaultKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await IssueAsync(grants, vaultId, vaultKey, generation, recipient, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new ShareOutcome(
|
||||
true,
|
||||
verification,
|
||||
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
|
||||
+ "its own only proves this server has been consistent with itself.");
|
||||
+ "its own only proves this server has been consistent with itself.",
|
||||
generations.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a vault to a fresh key and hands it to the members who are left.
|
||||
/// </summary>
|
||||
/// <param name="grants">The grant calls.</param>
|
||||
/// <param name="directory">The directory and the key log that makes it checkable.</param>
|
||||
/// <param name="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>
|
||||
@@ -226,14 +565,18 @@ public sealed partial class VaultSession
|
||||
|
||||
foreach (var vault in Vaults)
|
||||
{
|
||||
if (keyring.CanRead(vault.VaultId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Attempted even for a vault that already opens, because the answer can have grown: a
|
||||
// rotated vault arrives with a new current generation, and a vault shared by somebody who
|
||||
// holds more of its history arrives with wraps this session did not have. Admitting is
|
||||
// idempotent, so the only thing an unconditional call costs is the unwrap it skips.
|
||||
var readable = keyring.CanRead(vault.VaultId);
|
||||
|
||||
if (keyring.TryAdmit(bundle, vault))
|
||||
{
|
||||
admitted++;
|
||||
if (!readable)
|
||||
{
|
||||
admitted++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -336,5 +679,6 @@ public sealed partial class VaultSession
|
||||
summary.KeyGeneration,
|
||||
summary.Permissions,
|
||||
summary.WrappedVaultKey,
|
||||
summary.RekeyRequired);
|
||||
summary.RekeyRequired,
|
||||
summary.PriorKeyWraps);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user