Public Access
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.
The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.
The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.
What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.
Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
608 lines
25 KiB
C#
608 lines
25 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>
|
|
public sealed record VaultRekeyReport(
|
|
Guid VaultId,
|
|
string Name,
|
|
uint KeyGeneration,
|
|
IReadOnlyList<Guid> Shared,
|
|
IReadOnlyList<(Guid UserId, string Reason)> NotShared,
|
|
Exception? Failure)
|
|
{
|
|
/// <summary>Whether the vault moved to a new key.</summary>
|
|
public bool Rotated => Failure is null && KeyGeneration > 0;
|
|
}
|
|
|
|
/// <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>
|
|
/// 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="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>Two 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>What a rotation is worth, stated honestly.</b> Nothing already stored is re-encrypted — only a
|
|
/// client holding both keys could, and that is deferred work. So this does not take back what the
|
|
/// departed member already has, and it does not re-seal the vault's history against the key they may
|
|
/// have kept. What it does is make everything written from now on unreadable to them. Retroactive
|
|
/// revocation is not achievable; rotate the credentials themselves. See ADR 0001.
|
|
/// </para>
|
|
/// </remarks>
|
|
public async Task<VaultRekeyReport> RekeyVaultAsync(
|
|
IVaultGrantApi grants,
|
|
IDirectoryApi directory,
|
|
Guid vaultId,
|
|
IReadOnlyList<Guid> recipients,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
ArgumentNullException.ThrowIfNull(grants);
|
|
ArgumentNullException.ThrowIfNull(directory);
|
|
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));
|
|
}
|
|
}
|
|
|
|
return new VaultRekeyReport(
|
|
vaultId, name, summary.KeyGeneration, shared, missed, Failure: null);
|
|
}
|
|
|
|
/// <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,
|
|
Guid teamId,
|
|
IReadOnlyList<Guid> recipients,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
ArgumentNullException.ThrowIfNull(grants);
|
|
ArgumentNullException.ThrowIfNull(directory);
|
|
ArgumentNullException.ThrowIfNull(recipients);
|
|
|
|
var reports = new List<VaultRekeyReport>();
|
|
|
|
foreach (var vault in TeamVaults(teamId))
|
|
{
|
|
try
|
|
{
|
|
reports.Add(
|
|
await RekeyVaultAsync(
|
|
grants, directory, 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);
|
|
}
|