diff --git a/DodoSSH.slnx b/DodoSSH.slnx index 60c1c10..dd5dde5 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -20,6 +20,7 @@ + @@ -29,8 +30,10 @@ + + diff --git a/README.md b/README.md index 02023fa..ff3d022 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ src/ DodoSSH.Client.Domain the decrypted item model and the three-way merge — no I/O at all DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy + DodoSSH.Client.Session where a profile lives, unlocking it, and getting one in the first place DodoSSH.Client.Ssh connections, PTY shells, host key trust DodoSSH.Client.Terminal the loopback data plane and credit-based flow control DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit @@ -104,11 +105,18 @@ off-Windows. *Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and enrollment with the identity-provider key binding. *Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key - trust, the terminal data plane, an Avalonia shell whose terminal works end to end against a real - `sshd`, and the encrypted local cache with the sync client — hosts, offline unlock, an outbox and a - field-level three-way merge, with the conflict matrix green. - *Remaining:* wiring the shell to the vault, so the host list comes from `HostRepository` rather than - from the form the window still shows. + trust, the terminal data plane, the encrypted local cache with the sync client — offline unlock, an + outbox and a field-level three-way merge, conflict matrix green — and an Avalonia shell that is + vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's whole + path is covered by tests against an in-memory server, so the states that matter most (the recovery code + that cannot be skipped, the unlock that needs no network) are checked rather than remembered. + *Remaining:* the manual end-to-end run against the real API and a real Keycloak from + `deploy/docker-compose.dev.yml`, which is what M1's definition of done actually asks for. + + Known gaps in the client, stated rather than implied by the interface: credentials are not a synced + entity type yet, so a connection still asks for a password; known host keys live in memory for one + session instead of in the vault; and no device key is registered, so the passphrase is needed on every + launch until the OS keystore is wired. - **M2 — full personal vault**, robust sync, relay. - **M3 — teams**, sharing, ACLs. - **M4 — hardening and ops**, packaging, self-hosting guide. diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 9f93802..1565ef9 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -105,14 +105,18 @@ so a platform-specific opener can be substituted without touching the flow. ## Local cache -**The cache file has no location yet.** `ClientCacheFactory.ForFile` takes a full path and the -application does not yet choose one, because nothing wires the cache into the shell so far. When it -does, the path must be per-OS — `%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS, -`$XDG_DATA_HOME` or `~/.local/share` on Linux — and it must **not** land in a directory that syncs to -a cloud drive. Two machines writing one SQLite file through a file-sync client corrupts it, and the -whole point of the outbox is that each machine has its own. `Environment.SpecialFolder.LocalApplicationData` -maps correctly on all three, but on Linux it ignores `XDG_DATA_HOME` and returns `~/.local/share` -unconditionally. *Unverified off Windows.* +**The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it: +`%LOCALAPPDATA%\DodoSSH` on Windows, `~/Library/Application Support/DodoSSH` on macOS, +`$XDG_DATA_HOME/dodossh` or `~/.local/share/dodossh` on Linux. It must **not** land anywhere that syncs +to a cloud drive or roams: two machines writing one SQLite file through a file-sync client corrupts it, +and the whole point of the outbox is that each machine has its own. That is also why Windows uses +`%LOCALAPPDATA%` and not `%APPDATA%`, which roams in a domain environment. + +The platform branches are explicit rather than delegating to +`Environment.SpecialFolder.LocalApplicationData` everywhere, because on macOS the runtime maps that to +`~/.local/share` rather than to `~/Library/Application Support`. *Verified on Windows only* — the client +created `%LOCALAPPDATA%\DodoSSH\cache.db` and migrated it on first launch. The macOS and Linux branches +are reasoned, not run. **SQLite timestamps are stored as integers, deliberately.** EF's default `DateTimeOffset` mapping for SQLite is a text form it then refuses to order or compare, so any query that sorts or filters by time @@ -120,12 +124,30 @@ throws at execution rather than at model build. `UnixMillisecondsConverter` is a so a timestamp added later cannot be the one left unconverted. This is provider behaviour, not platform behaviour, but it cost a debugging session and will again if the converter is removed. +**The cache is three files, not one.** EF Core's SQLite provider puts the database in WAL mode, which is +the right mode here — a background sync pass writes while the interface reads, and under the default +rollback journal those reads would fail busy — but it means `cache.db` is accompanied by `cache.db-wal` +and `cache.db-shm`. Any backup, export or uninstall routine that touches only `cache.db` is wrong. +Verified by launching the client and reading `PRAGMA journal_mode`, after a comment in the code claimed +the opposite. + +**Pooled SQLite connections keep the file open after the last context is disposed.** On Windows that +means locked, so the application cannot delete or replace its own cache and a test cannot clean up after +itself. `ClientCacheFactory.Dispose` clears the pool for exactly this reason; removing that line makes +the failure appear only on Windows. + **No SQLCipher, on any platform.** The rows are already ciphertext from the server, so an encrypted database file would protect bytes that are protected already at the cost of a native dependency and a licence obligation — and `bundle_e_sqlcipher` was deprecated in SQLitePCLRaw 3.0. The consequence to be honest about: the cache offers no protection against another process running as the same user. See `LocalCacheProtector` for what it does and does not defend against. +**A `NativeWebView` that is never laid out is never realised.** The shell covers the terminal with its +setup and unlock screens rather than collapsing it with `IsVisible`, because the control hosts a real +child window and hiding it would leave the terminal blank on the first connection after unlocking. +Verified on Windows: with the unlock overlay showing, `msedgewebview2` still had an established +connection to the data plane port, so the page had loaded and completed its WebSocket handshake. + ## Build and CI **Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI. diff --git a/src/DodoSSH.Client.Api/ClientEnrollment.cs b/src/DodoSSH.Client.Api/ClientEnrollment.cs index f2774c8..904c182 100644 --- a/src/DodoSSH.Client.Api/ClientEnrollment.cs +++ b/src/DodoSSH.Client.Api/ClientEnrollment.cs @@ -15,8 +15,9 @@ namespace DodoSSH.Client.Api; /// The identity key pair, unlocked for this session. /// The personal vault's key, in plaintext for this session. /// -/// The enrolled device's X25519 private key. Belongs in the OS keystore — it is what lets a later -/// launch unlock without the passphrase. +/// The enrolled device's X25519 private key, or when no device was bound. +/// Belongs in the OS keystore — it is what lets a later launch unlock without the passphrase, and it +/// is the only thing that can open the device wrap held server-side. /// /// /// The generated recovery code, which must be shown to the user once and never stored. Losing this @@ -27,7 +28,7 @@ public sealed record EnrollmentOutcome( EnrollmentResponse Response, UserSecretBundle Bundle, byte[] PersonalVaultKey, - byte[] DevicePrivateKey, + byte[]? DevicePrivateKey, string RecoveryCode); /// @@ -46,10 +47,19 @@ public sealed record EnrollmentOutcome( /// /// public sealed class ClientEnrollment( - DodoSshApiClient api, + IAccountApi api, IKeyBindingAuthorizer keyBinding, - TimeProvider clock) + TimeProvider clock, + Argon2Profile? passphraseProfile = null) { + /// + /// Configurable because the cost is a product decision, not a constant: the plan exposes 128, 256 and + /// 512 MiB security levels, and the parameters travel with the wrap so a user's choice is theirs + /// alone. It also lets a test pay milliseconds instead of a third of a second to prove something that + /// has nothing to do with how hard the passphrase is to attack. + /// + private readonly Argon2Profile passphraseProfile = passphraseProfile ?? Argon2Profile.PassphraseDefault; + /// Bytes of entropy behind a recovery code. private const int RecoveryEntropyBytes = 20; @@ -63,12 +73,22 @@ public sealed class ClientEnrollment( /// The vault passphrase. Never transmitted or stored. /// Human-readable name for this machine. /// Display name for the personal vault. Plaintext, as vault names are. + /// + /// Whether to register a device key so a later launch can unlock without the passphrase. + /// + /// Pass when the caller has nowhere durable to keep the private half. A + /// device wrap whose private key does not survive the process is a row on the server that nobody can + /// ever open, and it makes the account's device list claim a capability this machine does not have — + /// which is worse than not offering it. + /// + /// /// Cancellation token. public async Task EnrollAsync( MeResponse me, string passphrase, string deviceName, string vaultName, + bool bindThisDevice, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(me); @@ -91,7 +111,8 @@ public sealed class ClientEnrollment( .ConfigureAwait(false); var request = BuildRequest( - me, bundle, statement, idToken, passphrase, vaultName, now, out var material); + me, bundle, statement, idToken, passphrase, passphraseProfile, vaultName, + bindThisDevice, now, out var material); var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false); @@ -130,7 +151,7 @@ public sealed class ClientEnrollment( /// Secrets the caller keeps after a successful enrollment. private readonly record struct SessionMaterial( byte[] VaultKey, - byte[] DevicePrivateKey, + byte[]? DevicePrivateKey, string RecoveryCode); /// @@ -143,7 +164,9 @@ public sealed class ClientEnrollment( KeyStatement statement, string idToken, string passphrase, + Argon2Profile passphraseProfile, string vaultName, + bool bindThisDevice, DateTimeOffset now, out SessionMaterial material) { @@ -158,8 +181,7 @@ public sealed class ClientEnrollment( byte[] passphraseWrap; byte[] recoveryWrap; - using (var master = MasterKey.Derive( - passphrase, passphraseSalt, Argon2Profile.PassphraseDefault)) + using (var master = MasterKey.Derive(passphrase, passphraseSalt, passphraseProfile)) { passphraseWrap = master.WrapBundle(bundle, descriptor); } @@ -171,19 +193,24 @@ public sealed class ClientEnrollment( recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor); } - using var deviceKey = Key.Create( - KeyAgreementAlgorithm.X25519, - new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + byte[]? devicePublicKey = null; + byte[]? deviceWrap = null; + byte[]? devicePrivateKey = null; - var devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); - var deviceWrap = bundle.SealTo(devicePublicKey, descriptor); + if (bindThisDevice) + { + using var deviceKey = Key.Create( + KeyAgreementAlgorithm.X25519, + new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + + devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); + deviceWrap = bundle.SealTo(devicePublicKey, descriptor); + devicePrivateKey = deviceKey.Export(KeyBlobFormat.RawPrivateKey); + } var vault = BuildPersonalVault(me, bundle, vaultName, now, out var vaultKey); - material = new SessionMaterial( - vaultKey, - deviceKey.Export(KeyBlobFormat.RawPrivateKey), - recoveryCode); + material = new SessionMaterial(vaultKey, devicePrivateKey, recoveryCode); return new EnrollmentRequest( Statement: statement, @@ -192,7 +219,7 @@ public sealed class ClientEnrollment( KeyStatementCodec.Encode(ToFields(statement))), IdentityProviderToken: idToken, WrappedPrivateKey: passphraseWrap, - KdfParameters: ToContract(passphraseSalt, Argon2Profile.PassphraseDefault), + KdfParameters: ToContract(passphraseSalt, passphraseProfile), DevicePublicKey: devicePublicKey, DeviceWrappedPrivateKey: deviceWrap, RecoveryWrappedPrivateKey: recoveryWrap, diff --git a/src/DodoSSH.Client.Api/DodoSshApiClient.cs b/src/DodoSSH.Client.Api/DodoSshApiClient.cs index 54fdb9c..ca7f5aa 100644 --- a/src/DodoSSH.Client.Api/DodoSshApiClient.cs +++ b/src/DodoSSH.Client.Api/DodoSshApiClient.cs @@ -18,6 +18,24 @@ public interface IAccessTokenProvider ValueTask GetAccessTokenAsync(CancellationToken cancellationToken); } +/// +/// The account calls: who am I, and publish my first key. +/// +/// +/// Separated for the same reason as . What the session layer does with these is +/// decide between enrolling and unlocking, and persist the result so the next launch needs no network; +/// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing +/// about the decision. +/// +public interface IAccountApi +{ + /// Reads the caller's profile, unlock material and reachable vaults. + Task GetMeAsync(CancellationToken cancellationToken); + + /// Publishes the caller's first identity key and creates their personal vault. + Task EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken); +} + /// /// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP. /// @@ -58,7 +76,8 @@ public interface ISyncApi /// Everything else carries a bearer token. /// /// -public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) : ISyncApi +public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) + : IAccountApi, ISyncApi { private const string MetaPath = "/api/v1/meta"; private const string ConfigurationPath = "/.well-known/dodossh-configuration"; diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs index 059079d..4edf364 100644 --- a/src/DodoSSH.Client.App/App.axaml.cs +++ b/src/DodoSSH.Client.App/App.axaml.cs @@ -4,7 +4,10 @@ using Avalonia.Markup.Xaml; using DodoSSH.Client.App.Terminal; using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.App.Views; +using DodoSSH.Client.Auth; +using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; +using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; namespace DodoSSH.Client.App; @@ -34,16 +37,24 @@ internal sealed partial class DodoSshApp : Application } /// - /// Composed by hand rather than through a container. The graph is four objects deep, and an - /// indirection to read through would buy nothing at this size. /// - /// The workspace is a local captured by the closures below rather than a field, so this type does - /// not own a disposable it has no good place to dispose — an Avalonia Application has no - /// disposal hook of its own. + /// Composed by hand rather than through a container. The graph is a handful of objects deep and an + /// indirection to read through would buy nothing at this size. + /// + /// + /// Everything disposable is a local captured by the closures below rather than a field, because an + /// Avalonia Application has no disposal hook of its own and a type that owned them would have + /// nowhere honest to release them. /// /// private static void Compose(IClassicDesktopStyleApplicationLifetime desktop) { + var paths = ClientPaths.Default; + var caches = ClientCacheFactory.ForFile(paths.CacheFile); + + // Known hosts are still in memory. The plan puts them in the vault as a synced entity so trust + // follows the user to every device, and SyncEntityType.KnownHostKey is reserved for it — but that + // entity type is not synced yet, so trust currently lasts one session. var knownHosts = new InMemoryKnownHostStore(); var workspace = new TerminalWorkspace( @@ -53,16 +64,30 @@ internal sealed partial class DodoSshApp : Application workspace.Start(); - desktop.MainWindow = new MainWindow - { - DataContext = new MainWindowViewModel(workspace, knownHosts), - }; + var browser = new SystemBrowserLauncher(); + + var viewModel = new MainWindowViewModel( + paths, + caches, + workspace, + knownHosts, + async (url, cancellationToken) => await ServerConnection + .SignInAsync(url, browser, TimeProvider.System, cancellationToken) + .ConfigureAwait(false), + TimeProvider.System); + + desktop.MainWindow = new MainWindow { DataContext = viewModel }; + + // Started rather than awaited: the framework's initialisation must not block on a schema + // migration. The view model shows its own progress and handles its own failures, which is why + // discarding the task here is safe rather than merely convenient. + _ = viewModel.StartAsync(CancellationToken.None); var shuttingDown = false; // Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening - // socket, and blocking the UI thread on their disposal is how an application comes to take - // several seconds to close — or deadlocks, if any of that disposal needs the UI thread. + // socket, and blocking the UI thread on their disposal is how an application comes to take several + // seconds to close — or deadlocks, if any of that disposal needs the UI thread. desktop.ShutdownRequested += async (_, e) => { if (shuttingDown) @@ -73,8 +98,13 @@ internal sealed partial class DodoSshApp : Application shuttingDown = true; e.Cancel = true; + // The view model first: it holds the vault session, and disposing that is what zeroes the + // identity keys, the vault keys and the cache key. + await viewModel.DisposeAsync().ConfigureAwait(true); await workspace.DisposeAsync().ConfigureAwait(true); + caches.Dispose(); + desktop.Shutdown(); }; } diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj index 269d39c..1e0272f 100644 --- a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj +++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj @@ -25,10 +25,19 @@ + + + + + + + + + + + + + + + + + + + + +