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); } }