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;
/// A conflict, decoded and ready to show.
/// The conflict record, so it can be acknowledged.
/// The item it happened to.
/// What happened.
/// One line for a person.
///
/// 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.
///
/// When it was noticed.
public sealed record ConflictNotice(
Guid Id,
Guid EntityId,
ConflictKind Kind,
string Summary,
IReadOnlyList Fields,
DateTimeOffset DetectedAt);
/// One vault's outcome from a pass over all of them.
/// The vault.
/// Its display name, so a message about it can name it.
/// What the pass did, when it completed.
///
/// Why it did not, when it failed. Carried rather than thrown so one unreachable team vault cannot
/// leave the others unsynced — and reported rather than swallowed, because a vault that silently
/// stopped syncing is the worst of the three outcomes.
///
public sealed record VaultSyncReport(
Guid VaultId,
string Name,
SyncReport? Report,
Exception? Failure)
{
/// Whether this vault synced.
public bool Succeeded => Report is not null;
}
///
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
///
///
///
/// 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.
///
///
/// The sync engine is not 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.
///
///
public sealed partial class VaultSession : IAsyncDisposable
{
private readonly UserSecretBundle bundle;
private readonly LocalCacheProtector protector;
private readonly VaultKeyring keyring;
private readonly TimeProvider clock;
private readonly SyncOptions options;
///
/// Records what is done to this vault's items, for as long as this session lasts.
///
///
/// Owned here rather than by the shell, unlike the connection recorder beside it. An edit is finished by
/// the time it is recorded, so nothing about it can outlive the session — where a shell genuinely can.
///
private readonly ActivityRecorder activity;
private bool disposed;
internal VaultSession(
StoredUnlockMaterial profile,
IReadOnlyList 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);
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
// The two log repositories first, and unaudited: the recorder writes through one of them, so a log
// that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
// actually stops it; building them first is what lets the recorder exist before the kinds that use
// it. See ActivityRecorder.
ConnectionLog = new ConnectionLogRepository(Items, Outbox, keyring);
ActivityLog = new ActivityLogRepository(Items, Outbox, keyring);
activity = new ActivityRecorder(
ActivityLog, activeVaultId, profile.UserId, Environment.MachineName, clock);
Hosts = new HostRepository(Items, Outbox, keyring, activity);
SshKeys = new SshKeyRepository(Items, Outbox, keyring, activity);
Credentials = new CredentialRepository(Items, Outbox, keyring, activity);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity);
HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity);
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
}
/// Who this session belongs to, and the material that unlocked it.
public StoredUnlockMaterial Profile { get; }
/// Every vault this user can reach, readable or not.
///
/// Re-read rather than fixed at unlock: a vault a teammate shares arrives mid-session, and one
/// whose grant is withdrawn stops being readable mid-session too.
/// is what moves it, and it is the only thing that does.
///
public IReadOnlyList Vaults { get; private set; }
///
/// The vault new items are created in.
///
///
/// One vault is the write target, not the read set — reading spans every vault the keyring opened.
/// It stays the first readable one, which is the personal vault whenever there is one, because an
/// application that silently filed a new host into a team's vault because that was the last thing
/// selected would be the wrong default in the one direction that is hard to undo.
///
public Guid ActiveVaultId { get; }
/// Every vault this session actually holds a key for.
public IEnumerable ReadableVaults =>
Vaults.Where(vault => keyring.CanRead(vault.VaultId));
/// Hosts, decrypted, with unpushed local changes laid over them.
public HostRepository Hosts { get; }
/// SSH keys, decrypted, with unpushed local changes laid over them.
///
/// Shares the item store and outbox with , so one synchronisation pass carries
/// both and a key edit made offline queues behind a host edit in the order the user made them.
///
public SshKeyRepository SshKeys { get; }
/// Usernames and passwords, decrypted, with unpushed local changes laid over them.
public CredentialRepository Credentials { get; }
/// The host keys this vault trusts, decrypted, with unpushed local changes laid over them.
///
/// Read through 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.
///
public KnownHostRepository KnownHosts { get; }
/// The groups hosts are filed under, decrypted, with unpushed local changes laid over them.
///
/// Membership is not in here. Each host carries its own GroupId, so a group is only ever a name —
/// which is what makes filing two hosts at once on two machines two independent writes rather than one
/// contested one.
///
public HostGroupRepository HostGroups { get; }
/// Saved commands, decrypted, with unpushed local changes laid over them.
public SnippetRepository Snippets { get; }
/// S3-compatible buckets and their credentials, decrypted.
///
/// Read when the file screen builds its picker, and the object-store client is constructed from the
/// result. Nothing here is on a transfer's data path.
///
public ObjectStoreRepository ObjectStores { get; }
/// The connections this vault has recorded, decrypted.
///
/// Written through rather than directly by anything that connects. An
/// entry is created once, on the teardown path of a session, and encrypting on that thread is how
/// closing the application comes to take four seconds — see that type for the queue that keeps the two
/// apart.
///
public ConnectionLogRepository ConnectionLog { get; }
/// The keychain changes this vault has recorded, decrypted.
public ActivityLogRepository ActivityLog { get; }
/// Vaults whose grant could not be opened, so their items cannot be read.
public IReadOnlyList UnreadableVaults => keyring.Unopened;
internal ItemStore Items { get; }
internal OutboxStore Outbox { get; }
internal SyncStateStore SyncState { get; }
internal ConflictStore Conflicts { get; }
internal VaultStore Vault { get; }
///
/// 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.
///
internal UnlockStore Unlock { get; }
///
/// Only reachable from an open session, which is the point rather than an accident of where it was
/// put: the token is sealed under this session's cache key, so a locked machine cannot read it and
/// therefore cannot reach the server at all. See RememberedSignInStore.
///
internal RememberedSignInStore SignIn { get; }
///
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
///
///
/// The refresh token the connection holds now. Providers rotate these, so a caller that
/// notices a change has to call this again — the value is not a constant for the life of a sign-in.
///
/// Cancellation token.
public Task RememberSignInAsync(string refreshToken, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return SignIn.SaveAsync(refreshToken, cancellationToken);
}
///
/// Reads the sign-in this machine may resume, or null when there is none to resume.
///
///
/// Null covers three situations that are one situation from the caller's side — nothing was ever
/// remembered, the record was written under a different identity, or its tag no longer verifies — and
/// the answer to all three is the same: sign in through the browser.
///
public Task ReadRememberedSignInAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return SignIn.ReadAsync(cancellationToken);
}
/// Forgets the remembered sign-in.
public Task ForgetSignInAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return SignIn.ForgetAsync(cancellationToken);
}
/// Runs one synchronisation pass over one vault.
/// The transport. Supplied per call because a session outlives any one connection.
/// The vault to sync.
/// Cancellation token.
public Task SyncAsync(
ISyncApi api,
Guid vaultId,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var engine = new SyncEngine(
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
return engine.SyncAsync(vaultId, cancellationToken);
}
///
/// Runs one synchronisation pass over every vault this session can read.
///
/// One report per vault, in the order they were synced.
///
///
/// Sequential rather than concurrent. Each vault has its own cursor and its own outbox, so nothing
/// forces the order — but a client that opened one connection per vault would multiply its request
/// rate by the number of teams somebody is in, against a server the same person is also using
/// interactively. Vaults are few and passes are cheap.
///
///
/// A vault that throws does not stop the rest. One team's vault being unreachable — a revoked grant
/// noticed mid-pass, a server-side fault — is not a reason to leave the personal vault unsynced,
/// and the failure is reported per vault rather than as one exception naming none of them.
///
///
public async Task> SyncAllAsync(
ISyncApi api,
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
var reports = new List();
foreach (var vault in ReadableVaults.ToList())
{
try
{
var report = await SyncAsync(api, vault.VaultId, cancellationToken)
.ConfigureAwait(false);
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, report, null));
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, null, exception));
}
}
return reports;
}
///
/// Registers this machine's device key, so a later launch can unlock without the passphrase.
///
/// The transport, supplied per call as takes its own.
/// Where the private half will live. See ADR 0007.
/// What to call this machine in the account's device list.
/// Cancellation token.
///
/// when a device was registered; when this machine has
/// nowhere to keep the key, which is not a failure — it is the answer for a platform with no keystore.
///
///
///
/// 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.
///
///
/// Ordered so a failure cannot leave a lie behind. 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.
///
///
public async Task 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;
}
///
/// Withdraws this machine's device key, here and on the account.
///
/// The server, or null when there is none to reach.
/// This machine's keystore.
/// Cancellation.
/// How far the withdrawal got.
///
///
/// The local half first, and it is the half that matters. 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.
///
///
/// The server row is not bookkeeping, though, which is why this no longer stops at the local half. A
/// kind=device 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.
///
///
/// Offline still does the local half and says so, rather than refusing. Somebody revoking a device
/// usually has a reason to want it gone now, and "you are offline, so this machine will go on
/// unlocking itself" is the worst of the available answers. is
/// what the interface reports, and it is a state the user can act on by trying again online.
///
///
/// The device id is read from the store rather than from , which is a snapshot taken
/// when the session opened and does not know about a device registered since.
///
///
public async Task 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 the desktop head's
// WindowsDeviceKeyStore — this layer only knows it is handed an IDeviceKeyStore.
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;
}
///
/// Reads the conflicts a person still needs to see.
///
///
/// 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.
///
public async Task> ReadConflictsAsync(
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
var stored = await Conflicts
.ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken)
.ConfigureAwait(false);
return [.. stored.Select(Describe)];
}
/// Marks a conflict as seen, keeping the discarded value retrievable.
public Task AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
}
///
/// How many local changes the user has made that are waiting to be pushed.
///
///
///
/// Log entries are excluded, and the exclusion is the honest reading rather than a convenience.
/// This number is shown in the titlebar and it answers one question: how much of my work is not yet
/// safe anywhere else. A connection that was recorded is not somebody's work — nobody typed it, nobody
/// would re-enter it if this machine were lost, and an entry queued a moment after a save would leave
/// the titlebar claiming an unsynced change immediately after reporting a successful sync.
///
///
/// The entries are still pushed, on the next pass like anything else. What they are kept out of is a
/// count that means something narrower than "rows in the outbox".
///
///
public async Task PendingChangeCountAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
return pending.Count(operation => operation.EntityType is not (
SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry));
}
///
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
// Before the keys go, and it waits — briefly. Anything queued has to be encrypted under a vault key
// that is about to be zeroed, so a fire-and-forget here would silently lose the last few entries of
// every session. The wait is bounded inside the recorder; locking never stalls on it.
await activity.DisposeAsync().ConfigureAwait(false);
// 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();
}
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);
}
}