Files
DodoSSH/src/DodoSSH.Client.Session/AccountProvisioner.cs
T
jaap-jan d5b1a73182 Move the keys when a membership changes, not just the flag
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.
2026-08-03 23:05:40 +02:00

202 lines
7.9 KiB
C#

using System.Security.Cryptography;
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Session;
/// <summary>What the server said about this account.</summary>
public enum ProvisionStatus
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>
/// No identity key exists yet. The user must choose a passphrase and enroll before anything else
/// works.
/// </summary>
EnrollmentRequired = 1,
/// <summary>Enrolled, and everything an offline unlock needs is now cached.</summary>
Ready = 2,
}
/// <summary>The result of talking to the server about this account.</summary>
/// <param name="Status">What happened.</param>
/// <param name="Me">The profile the server reported.</param>
/// <param name="RecoveryCode">
/// Present only immediately after enrolling. <b>Must be shown once and never stored.</b> Losing this
/// along with the passphrase and every enrolled device means the vault is unrecoverable, and there is no
/// server-side reset by design — see docs/crypto.md §10.
/// </param>
/// <param name="Message">Something to show the user.</param>
public sealed record ProvisionOutcome(
ProvisionStatus Status,
MeResponse Me,
string? RecoveryCode,
string Message);
/// <summary>
/// Gets this machine from "signed in" to "has everything an offline unlock needs".
/// </summary>
/// <remarks>
/// <para>
/// The only part of the client that requires a network. Everything it does is in service of the part
/// that does not: it caches the KDF salt, the wrapped identity bundle and the vault grants, so every
/// later launch opens the vault with nothing but the passphrase.
/// </para>
/// <para>
/// After enrolling it re-reads <c>/me</c> rather than caching what it believes it sent. That is a
/// deliberate round trip: it proves the server stored what this client thinks it did, and the passphrase
/// the user just chose is then verified against the cached wrap on the very next unlock rather than on
/// some future launch when they have forgotten which one they typed.
/// </para>
/// </remarks>
public sealed class AccountProvisioner(
IAccountApi api,
IKeyBindingAuthorizer keyBinding,
ClientCacheFactory caches,
TimeProvider clock,
Argon2Profile? passphraseProfile = null)
{
/// <summary>Reads the account and caches whatever an offline unlock will need.</summary>
public async Task<ProvisionOutcome> RefreshAsync(
string serverUrl,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
if (me.EnrollmentRequired)
{
return new ProvisionOutcome(
ProvisionStatus.EnrollmentRequired,
me,
null,
"This account has no vault key yet. Choose a passphrase to create one.");
}
await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
return new ProvisionOutcome(
ProvisionStatus.Ready, me, null, "Signed in. Unlock with your vault passphrase.");
}
/// <summary>
/// Creates this account's identity key and personal vault.
/// </summary>
/// <param name="serverUrl">The server this profile belongs to.</param>
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
/// <param name="deviceName">Human-readable name for this machine, shown in the key statement.</param>
/// <param name="vaultName">Display name for the personal vault.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// <b>No device key is registered.</b> Its private half belongs in the OS keystore, and nothing wires
/// one up yet — so registering it would put a wrap on the server that no key can open and would make
/// the account's device list claim this machine can unlock without a passphrase. Until the keystore
/// is wired, the passphrase is required on every launch. That is a limitation, not a design choice.
/// </remarks>
public async Task<ProvisionOutcome> EnrollAsync(
string serverUrl,
string passphrase,
string deviceName,
string vaultName,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
ArgumentException.ThrowIfNullOrEmpty(passphrase);
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
if (!me.EnrollmentRequired)
{
// Someone else already enrolled this account — another machine, or a retry whose answer was
// lost. Caching what is there is the right move; re-enrolling would replace a key other
// people may already have wrapped vault keys to.
await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
return new ProvisionOutcome(
ProvisionStatus.Ready,
me,
null,
"This account was already enrolled. Unlock with your existing vault passphrase.");
}
var enrollment = new ClientEnrollment(api, keyBinding, clock, passphraseProfile);
var outcome = await enrollment
.EnrollAsync(me, passphrase, deviceName, vaultName, bindThisDevice: false, cancellationToken)
.ConfigureAwait(false);
try
{
var enrolled = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
await CacheAsync(serverUrl, enrolled, cancellationToken).ConfigureAwait(false);
return new ProvisionOutcome(
ProvisionStatus.Ready,
enrolled,
outcome.RecoveryCode,
"Your vault was created. Write the recovery code down before continuing.");
}
finally
{
// The session keys are re-derived from the cached wrap at unlock, so nothing here needs to
// survive this method — and a vault key left in a managed array is a vault key in a heap dump.
outcome.Bundle.Dispose();
CryptographicOperations.ZeroMemory(outcome.PersonalVaultKey);
}
}
/// <remarks>
/// Both halves matter. Without the unlock material there is no offline unlock; without the vault
/// grants an offline launch could open the identity bundle and still not decrypt a single item.
/// </remarks>
private async Task CacheAsync(
string serverUrl,
MeResponse me,
CancellationToken cancellationToken)
{
if (me.WrappedPrivateKey is null || me.KdfParameters is null || me.KeyGeneration is null)
{
throw new InvalidOperationException(
"The server reported an enrolled account without the material needed to unlock it. "
+ "Refusing to cache a profile that could never be opened.");
}
await new UnlockStore(caches, clock).SaveAsync(
new StoredUnlockMaterial(
serverUrl,
me.UserId,
me.Issuer,
me.Subject,
me.Email,
me.DisplayName,
(uint)me.KeyGeneration.Value,
me.WrappedPrivateKey,
me.KdfParameters,
clock.GetUtcNow()),
cancellationToken).ConfigureAwait(false);
await new VaultStore(caches, clock).ReplaceAllAsync(
[.. me.Vaults.Select(ToStored)],
cancellationToken).ConfigureAwait(false);
}
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);
}