Public Access
ForgetDeviceAsync stopped this machine unlocking without a passphrase and left
the server's row exactly where it was, so the account went on listing a device
nobody could account for. ADR 0007 recorded that as a deliberate gap needing an
endpoint. This is the endpoint, and the two things that turned up behind it.
DELETE /api/v1/me/devices/{id}. The device row is not the dangerous half: a
kind=device wrap is the user's identity bundle sealed to a key somebody may be
holding, and that is what has to go. It goes on the foreign key's cascade rather
than a second statement, and RevokeDevice_TakesItsWrapWithIt asserts the cascade
rather than trusting the configuration to keep saying so.
Scoped to the caller's own account, which is the only authorisation check there
is. The id is an unguessable v7 GUID, but unguessable is not a permission —
without the scope one user could withdraw another's device key by pasting an id
they saw once, and the victim's next launch would ask for a passphrase with no
explanation. 404 rather than 403 for somebody else's device, so a stranger does
not learn the id exists.
Never refused for being the last device. ADR 0001 makes an enrolled device a
recovery path, so removing the last one does cost the user something — but the
machine being revoked is most likely the one they have just lost, and a server
that argued about it would be refusing the one request that has to work
immediately. The passphrase wrap is untouched either way, which
RevokeDevice_LeavesThePassphraseWrapAlone pins.
--- Two things found on the way ---
Registering twice from one machine left two devices on the account. The server
is idempotent on the public key, but the client generates a fresh key pair every
call and the keystore holds one — so the second registration orphaned a wrap
whose private half had just been overwritten, which is precisely the leftover
this change exists to remove. Registering now withdraws the previous device.
Found by a test that asserted the property and failed.
And the fakes were lying about it. FakeAccountServer's comment claimed the real
service's idempotence while handing back a fresh Guid on every call, which is
invisible until something revokes by id — at which point a test would be
revoking an id the server never issued, and passing. Both fakes now issue one id
per public key and drop the wrap with the device, as the cascade does.
--- Reachable at all ---
ForgetDeviceAsync had exactly one caller and it was a test, so "Stop unlocking
here" now sits in the account bar where "Use Windows Hello here" was. Its own
flag rather than the negation of that one: a machine with no TPM and a machine
that is already registered are both "cannot register", and only the second has
anything to take back.
No confirmation prompt, deliberately. The cost of pressing it by accident is one
passphrase and one re-registration; the cost of a dialog is a moment's
hesitation at the point somebody has realised a machine is in the wrong hands.
Offline it does the local half and says so rather than refusing. Whether this
machine may unlock itself is decided entirely by the local cache and the local
keystore — the unlock path never asks the server — so forgetting here is what
actually revokes, and "you are offline, so this machine will go on unlocking
itself" would be the worst available answer. DeviceRevocation.LocalOnly is what
the interface reports and the status line explains what is left to do.
The local half runs first for the same reason, and the keystore call is the
first thing in the method that can yield: on Windows it raises a consent dialog,
and a dialog wants the thread it was called from. That ordering is currently
load-bearing and shakier than it looks — see the open device-unlock hang.
Four mutations, all caught: dropping the user scope from the server query
(1 test), skipping the stale-device revoke on re-registration (2), skipping the
server call in ForgetDeviceAsync (2), and the earlier version of the client that
never called it at all.
930 tests green across 16 projects, 13 of them new. Zero warnings, format clean.
368 lines
16 KiB
C#
368 lines
16 KiB
C#
using System.Security.Cryptography;
|
|
using DodoSSH.Client.Api;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Sync;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Crypto;
|
|
using NSec.Cryptography;
|
|
|
|
namespace DodoSSH.Client.Session;
|
|
|
|
/// <summary>A conflict, decoded and ready to show.</summary>
|
|
/// <param name="Id">The conflict record, so it can be acknowledged.</param>
|
|
/// <param name="EntityId">The item it happened to.</param>
|
|
/// <param name="Kind">What happened.</param>
|
|
/// <param name="Summary">One line for a person.</param>
|
|
/// <param name="Fields">
|
|
/// Everything the merge overrode, with the discarded values. Empty for the kinds that have no field
|
|
/// detail — a rejected push, or an item that would not decrypt.
|
|
/// </param>
|
|
/// <param name="DetectedAt">When it was noticed.</param>
|
|
public sealed record ConflictNotice(
|
|
Guid Id,
|
|
Guid EntityId,
|
|
ConflictKind Kind,
|
|
string Summary,
|
|
IReadOnlyList<ConflictDetailEntry> Fields,
|
|
DateTimeOffset DetectedAt);
|
|
|
|
/// <summary>
|
|
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Everything a session owns dies with it — the identity bundle, the vault keys and the cache key. That
|
|
/// is the whole reason this is a disposable object rather than a set of long-lived services: locking is
|
|
/// disposing, and there is exactly one place that has to be right.
|
|
/// </para>
|
|
/// <para>
|
|
/// The sync engine is <em>not</em> held here. It carries no state, so it is constructed per pass around
|
|
/// whichever transport the caller currently has — which models the actual situation, where a session is
|
|
/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class VaultSession : IAsyncDisposable
|
|
{
|
|
private readonly UserSecretBundle bundle;
|
|
private readonly LocalCacheProtector protector;
|
|
private readonly VaultKeyring keyring;
|
|
private readonly TimeProvider clock;
|
|
private readonly SyncOptions options;
|
|
private bool disposed;
|
|
|
|
internal VaultSession(
|
|
StoredUnlockMaterial profile,
|
|
IReadOnlyList<StoredVault> vaults,
|
|
Guid activeVaultId,
|
|
UserSecretBundle bundle,
|
|
LocalCacheProtector protector,
|
|
VaultKeyring keyring,
|
|
ClientCacheFactory caches,
|
|
TimeProvider clock,
|
|
SyncOptions options)
|
|
{
|
|
Profile = profile;
|
|
Vaults = vaults;
|
|
ActiveVaultId = activeVaultId;
|
|
|
|
this.bundle = bundle;
|
|
this.protector = protector;
|
|
this.keyring = keyring;
|
|
this.clock = clock;
|
|
this.options = options;
|
|
|
|
Items = new ItemStore(caches, protector);
|
|
Outbox = new OutboxStore(caches, protector, clock);
|
|
SyncState = new SyncStateStore(caches);
|
|
Conflicts = new ConflictStore(caches, protector, clock);
|
|
Vault = new VaultStore(caches, clock);
|
|
Unlock = new UnlockStore(caches, clock);
|
|
Hosts = new HostRepository(Items, Outbox, keyring);
|
|
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
|
|
Credentials = new CredentialRepository(Items, Outbox, keyring);
|
|
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
|
|
}
|
|
|
|
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
|
public StoredUnlockMaterial Profile { get; }
|
|
|
|
/// <summary>Every vault this user can reach, readable or not.</summary>
|
|
public IReadOnlyList<StoredVault> Vaults { get; }
|
|
|
|
/// <summary>The vault the interface is showing. The personal one, for now.</summary>
|
|
public Guid ActiveVaultId { get; }
|
|
|
|
/// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
|
|
public HostRepository Hosts { get; }
|
|
|
|
/// <summary>SSH keys, decrypted, with unpushed local changes laid over them.</summary>
|
|
/// <remarks>
|
|
/// Shares the item store and outbox with <see cref="Hosts"/>, so one synchronisation pass carries
|
|
/// both and a key edit made offline queues behind a host edit in the order the user made them.
|
|
/// </remarks>
|
|
public SshKeyRepository SshKeys { get; }
|
|
|
|
/// <summary>Usernames and passwords, decrypted, with unpushed local changes laid over them.</summary>
|
|
public CredentialRepository Credentials { get; }
|
|
|
|
/// <summary>The host keys this vault trusts, decrypted, with unpushed local changes laid over them.</summary>
|
|
/// <remarks>
|
|
/// Read through <see cref="VaultKnownHostStore"/> rather than directly by anything that connects. The
|
|
/// handshake asks about host key trust from inside a synchronous SSH.NET event, and listing decrypts every
|
|
/// pin in the vault — see that type for why the two must not meet.
|
|
/// </remarks>
|
|
public KnownHostRepository KnownHosts { get; }
|
|
|
|
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
|
|
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
|
|
|
|
internal ItemStore Items { get; }
|
|
|
|
internal OutboxStore Outbox { get; }
|
|
|
|
internal SyncStateStore SyncState { get; }
|
|
|
|
internal ConflictStore Conflicts { get; }
|
|
|
|
internal VaultStore Vault { get; }
|
|
|
|
/// <remarks>
|
|
/// Held so registering or forgetting a device can record it against the profile. Built here with the
|
|
/// other stores rather than on demand, so the cache factory does not have to be kept as a field for
|
|
/// one method's sake.
|
|
/// </remarks>
|
|
internal UnlockStore Unlock { get; }
|
|
|
|
/// <summary>Runs one synchronisation pass over the active vault.</summary>
|
|
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public Task<SyncReport> SyncAsync(ISyncApi api, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
ArgumentNullException.ThrowIfNull(api);
|
|
|
|
var engine = new SyncEngine(
|
|
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
|
|
|
|
return engine.SyncAsync(ActiveVaultId, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers this machine's device key, so a later launch can unlock without the passphrase.
|
|
/// </summary>
|
|
/// <param name="api">The transport, supplied per call as <see cref="SyncAsync"/> takes its own.</param>
|
|
/// <param name="deviceKeys">Where the private half will live. See ADR 0007.</param>
|
|
/// <param name="deviceName">What to call this machine in the account's device list.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>
|
|
/// <see langword="true"/> when a device was registered; <see langword="false"/> when this machine has
|
|
/// nowhere to keep the key, which is not a failure — it is the answer for a platform with no keystore.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Here rather than in a service above, because sealing the bundle is the one step only an open session
|
|
/// can do and this type is the bundle's custodian. Everything else — the call, the keystore — arrives as
|
|
/// a parameter, so the session still knows nothing about how either is implemented.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Ordered so a failure cannot leave a lie behind.</b> The key is generated, stored locally, and only
|
|
/// then registered with the server; the local wrap is cached last, once the server has accepted it. A
|
|
/// server row whose private half was never saved is a device that can never unlock and that the account
|
|
/// claims can, which is worse than not offering the feature — so the write that could produce it happens
|
|
/// after the one that prevents it.
|
|
/// </para>
|
|
/// </remarks>
|
|
public async Task<bool> RegisterDeviceAsync(
|
|
IAccountApi api,
|
|
IDeviceKeyStore deviceKeys,
|
|
string deviceName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
ArgumentNullException.ThrowIfNull(api);
|
|
ArgumentNullException.ThrowIfNull(deviceKeys);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
|
|
|
if (!await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(false))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
using var deviceKey = Key.Create(
|
|
KeyAgreementAlgorithm.X25519,
|
|
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
|
|
|
var publicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
|
var wrap = bundle.SealTo(publicKey, DshAad.UserSecretBundle(Profile.UserId, Profile.KeyGeneration));
|
|
|
|
var privateKey = deviceKey.Export(KeyBlobFormat.RawPrivateKey);
|
|
|
|
try
|
|
{
|
|
await deviceKeys.SaveAsync(privateKey, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(privateKey);
|
|
}
|
|
|
|
// Read after the keystore write, not before, so that write stays the first thing in this method that
|
|
// can yield. On Windows it raises a consent dialog, and a dialog wants the thread it was called from.
|
|
var previousDeviceId = (await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false))?.DeviceId;
|
|
|
|
var registered = await api
|
|
.RegisterDeviceAsync(new RegisterDeviceRequest(deviceName, publicKey, wrap), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
// A machine is a device, so registering again replaces rather than adds. The server is idempotent on
|
|
// the public key, but this generates a fresh key pair every time and the keystore holds one — so a
|
|
// second registration would leave the account listing a device whose private half has just been
|
|
// overwritten. That row is not merely untidy: its kind=device wrap is the identity bundle sealed to a
|
|
// key that no longer exists anywhere, which is precisely the leftover revocation exists to remove.
|
|
if (previousDeviceId is { } stale && stale != registered.DeviceId)
|
|
{
|
|
await api.RevokeDeviceAsync(stale, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
await Unlock.AttachDeviceAsync(registered.DeviceId, wrap, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws this machine's device key, here and on the account.
|
|
/// </summary>
|
|
/// <param name="api">The server, or null when there is none to reach.</param>
|
|
/// <param name="deviceKeys">This machine's keystore.</param>
|
|
/// <param name="cancellationToken">Cancellation.</param>
|
|
/// <returns>How far the withdrawal got.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>The local half first, and it is the half that matters.</b> Whether this machine may unlock without
|
|
/// a passphrase is decided entirely by what is in the local cache and the local keystore — the unlock
|
|
/// path never asks the server — so forgetting here is what actually revokes. Doing it first also means a
|
|
/// server call that fails cannot leave the machine still able to let itself in.
|
|
/// </para>
|
|
/// <para>
|
|
/// The server row is not bookkeeping, though, which is why this no longer stops at the local half. A
|
|
/// <c>kind=device</c> wrap is the user's identity bundle sealed to a key that may be in somebody else's
|
|
/// laptop; leaving it there means a machine that is wiped and reinstalled can pull the wrap down again,
|
|
/// and it means the account goes on listing a device nobody can account for.
|
|
/// </para>
|
|
/// <para>
|
|
/// Offline still does the local half and says so, rather than refusing. Somebody revoking a device
|
|
/// usually has a reason to want it gone <em>now</em>, and "you are offline, so this machine will go on
|
|
/// unlocking itself" is the worst of the available answers. <see cref="DeviceRevocation.LocalOnly"/> is
|
|
/// what the interface reports, and it is a state the user can act on by trying again online.
|
|
/// </para>
|
|
/// <para>
|
|
/// The device id is read from the store rather than from <see cref="Profile"/>, which is a snapshot taken
|
|
/// when the session opened and does not know about a device registered since.
|
|
/// </para>
|
|
/// </remarks>
|
|
public async Task<DeviceRevocation> ForgetDeviceAsync(
|
|
IAccountApi? api,
|
|
IDeviceKeyStore deviceKeys,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
ArgumentNullException.ThrowIfNull(deviceKeys);
|
|
|
|
// Before any await that could yield, because on Windows this reaches a consent dialog and a dialog
|
|
// needs the thread it was called from to be one that pumps messages. See WindowsDeviceKeyStore.
|
|
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var stored = await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
await Unlock.DetachDeviceAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
if (stored?.DeviceId is not { } deviceId)
|
|
{
|
|
return DeviceRevocation.NothingRegistered;
|
|
}
|
|
|
|
if (api is null)
|
|
{
|
|
return DeviceRevocation.LocalOnly;
|
|
}
|
|
|
|
// A device the account does not have is the state this was aiming at, so a false answer is an
|
|
// arrival rather than a failure — another machine may have revoked it first.
|
|
await api.RevokeDeviceAsync(deviceId, cancellationToken).ConfigureAwait(false);
|
|
|
|
return DeviceRevocation.Complete;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the conflicts a person still needs to see.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A conflict whose detail will not decode is still reported, with the reason in place of the
|
|
/// summary. The record itself — which item, when, what kind — remains useful even when the
|
|
/// discarded value has become unreadable, and dropping the row would be the one outcome the whole
|
|
/// conflict log exists to avoid.
|
|
/// </remarks>
|
|
public async Task<IReadOnlyList<ConflictNotice>> ReadConflictsAsync(
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
|
|
var stored = await Conflicts
|
|
.ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
return [.. stored.Select(Describe)];
|
|
}
|
|
|
|
/// <summary>Marks a conflict as seen, keeping the discarded value retrievable.</summary>
|
|
public Task<bool> AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
|
|
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
|
|
}
|
|
|
|
/// <summary>How many local changes are waiting to be pushed.</summary>
|
|
public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
|
|
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
|
|
return pending.Count;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
if (disposed)
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
disposed = true;
|
|
|
|
// Order is not important — none of these depend on another — but completeness is. Missing one
|
|
// leaves key material in memory for the life of the process, which is the opposite of what
|
|
// locking is supposed to mean.
|
|
keyring.Dispose();
|
|
protector.Dispose();
|
|
bundle.Dispose();
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private static ConflictNotice Describe(StoredConflict conflict)
|
|
{
|
|
var detail = ConflictDetails.TryRead(conflict.Detail);
|
|
|
|
return new ConflictNotice(
|
|
conflict.Id,
|
|
conflict.EntityId,
|
|
conflict.Kind,
|
|
detail?.Summary ?? "The details of this conflict could not be read.",
|
|
detail?.Fields ?? [],
|
|
conflict.DetectedAt);
|
|
}
|
|
}
|