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); /// /// 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 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 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); } /// Who this session belongs to, and the material that unlocked it. public StoredUnlockMaterial Profile { get; } /// Every vault this user can reach, readable or not. public IReadOnlyList Vaults { get; } /// The vault the interface is showing. The personal one, for now. public Guid ActiveVaultId { get; } /// 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; } /// 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; } /// Runs one synchronisation pass over the active vault. /// The transport. Supplied per call because a session outlives any one connection. /// Cancellation token. public Task 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); } /// /// 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 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; } /// /// 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 are waiting to be pushed. public async Task PendingChangeCountAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(disposed, this); var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false); return pending.Count; } /// 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); } }