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