Public Access
Take a rotated vault's contents onto the new key as well
Rotating a vault re-keyed the vault and not its contents, which was the deal struck last time: everything already stored stayed sealed under the generation it was written with, every remaining member kept the older keys, and the guarantee was narrowed to "nothing written from now on". That left one gap worth closing — somebody who walked off with the old key could still open old ciphertext they later got hold of — and the reason it was safe to defer is the reason it was cheap to add. A vault at mixed generations reads perfectly well, so the pass that moves items across can stop half way and be run again. VaultResealer walks the vault and rewrites each item as an ordinary upsert against the version the server holds. It never decodes the plaintext: an item is opened and the same bytes are sealed again under a fresh data key, so an item written by a newer client crosses a rotation untouched rather than being re-encoded through this build's codec and quietly losing the fields this build has no concept of. It also means nothing in the pass knows what an item is, which is why one loop covers every type including the ones added after it. A conflict is counted and skipped rather than merged — there is nothing to merge, since no content changes — and the next pass picks the item up at the version the other client left. The half that a pass over stored items cannot see is a change queued before the rotation and pushed after it, which would put a brand-new item into the vault under the key the person who just left still holds. So the push path re-seals a stale payload as it dispatches it, writing the revision back to the outbox first so that a retry sends the same bytes rather than a fresh envelope. Between the two, nothing reaches the server under a superseded generation at all. Queued items are therefore deliberately left alone by the pass: rewriting one there would overwrite the user's unpushed work with the version the server holds, which is the one thing a re-keying pass must never do. Removal runs it last, after a sync — a mirror that is behind produces a batch of conflicts instead of a re-sealed vault — and the status line distinguishes the two guarantees, because they are not the same: a vault fully re-sealed is closed to the person who left, and one with items outstanding is closed only to what happens next. Six tests, and three mutations run against them: making the re-seal return the payload unchanged fails five of the six, making the push path skip re-sealing fails the queued-edit test and only that one, and counting conflicts as applied fails the write-elsewhere test. One of the six was wrong before it was right — it modelled a third-party write by re-pushing an existing payload at a bumped version, which no real client would do, and it took reading the AAD to see that the test was lying rather than the code.
This commit is contained in:
@@ -1086,7 +1086,7 @@ internal sealed partial class TeamsViewModel(
|
||||
|
||||
var reports = await open
|
||||
.RekeyTeamVaultsAsync(
|
||||
server.Grants, server.Directory, team.TeamId, remaining, cancellationToken)
|
||||
server.Grants, server.Directory, server.Sync, team.TeamId, remaining, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (reports.Count == 0)
|
||||
@@ -1102,29 +1102,7 @@ internal sealed partial class TeamsViewModel(
|
||||
|
||||
if (rotated.Count > 0)
|
||||
{
|
||||
// Says what a rotation is and is not worth, because the word promises more than it can
|
||||
// deliver: from here on they cannot read this vault, and what is already in it was sealed
|
||||
// under the key they used to hold.
|
||||
sentences.Add(
|
||||
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
|
||||
+ "written from now on is readable to them.");
|
||||
|
||||
// The members who did not get the new key. They are still in the team and can still write,
|
||||
// but until somebody wraps it to them they will find the vault stops updating.
|
||||
// Distinct by id rather than by name, because two accounts can share a display name and
|
||||
// collapsing them would tell somebody one person is owed a key when two are.
|
||||
var missed = rotated
|
||||
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
|
||||
.Distinct()
|
||||
.Select(Name)
|
||||
.ToList();
|
||||
|
||||
if (missed.Count > 0)
|
||||
{
|
||||
sentences.Add(
|
||||
$"The new key did not reach {Join(missed)} — press SHARE KEY for them, or they "
|
||||
+ "will stop seeing changes.");
|
||||
}
|
||||
sentences.AddRange(Describe(rotated));
|
||||
}
|
||||
|
||||
if (failed.Count > 0)
|
||||
@@ -1136,6 +1114,42 @@ internal sealed partial class TeamsViewModel(
|
||||
return string.Join(" ", sentences);
|
||||
}
|
||||
|
||||
/// <summary>What the vaults that did rotate are now worth, in the order somebody needs it.</summary>
|
||||
private IEnumerable<string> Describe(List<VaultRekeyReport> rotated)
|
||||
{
|
||||
yield return
|
||||
$"Rotated {VaultCount(rotated.Count)} — {Join(rotated.Select(r => r.Name))} — so nothing "
|
||||
+ "written from now on is readable to them.";
|
||||
|
||||
// Two different promises, so two different sentences. A vault whose items were all moved onto
|
||||
// the new key is closed to them completely; one where some were left is closed to what happens
|
||||
// next, and the difference is not the interface's to blur.
|
||||
var sealedUp = rotated.Count(report => report.Sealed);
|
||||
|
||||
yield return sealedUp == rotated.Count
|
||||
? "Everything already stored was re-sealed under the new key too, so their old key opens "
|
||||
+ "nothing."
|
||||
: $"{sealedUp} of {rotated.Count} had everything already stored re-sealed under the new "
|
||||
+ "key; the rest still hold items under the old one and will be picked up next time. "
|
||||
+ "Rotate the credentials that mattered either way.";
|
||||
|
||||
// The members who did not get the new key. They are still in the team and can still write, but
|
||||
// until somebody wraps it to them they will find the vault stops updating. Distinct by id
|
||||
// rather than by name, because two accounts can share a display name and collapsing them would
|
||||
// tell somebody one person is owed a key when two are.
|
||||
var missed = rotated
|
||||
.SelectMany(report => report.NotShared.Select(entry => entry.UserId))
|
||||
.Distinct()
|
||||
.Select(Name)
|
||||
.ToList();
|
||||
|
||||
if (missed.Count > 0)
|
||||
{
|
||||
yield return $"The new key did not reach {Join(missed)} — press SHARE KEY for them, or "
|
||||
+ "they will stop seeing changes.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What to call a member in a sentence, from the list this screen already has.</summary>
|
||||
private string Name(Guid userId) =>
|
||||
Members.FirstOrDefault(row => row.UserId == userId)?.Name ?? userId.ToString();
|
||||
|
||||
Reference in New Issue
Block a user