From 49f617b450c669b29628fa2309b99fbc11a06828 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Wed, 29 Jul 2026 11:02:19 +0200 Subject: [PATCH] Wire the Avalonia shell to the vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host list now comes from the vault instead of from a form. A fresh machine takes a server URL, signs in through the browser, enrolls, and from then on opens with the passphrase alone. DodoSSH.Client.Session is the composition layer: where a profile lives, how it unlocks, and how a machine gets one. ClientPaths picks a non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%, because a SQLite cache that roams between two machines is a corrupt one, and each machine's outbox is its own. SessionOpener needs no transport at all and could not reach one if it wanted to; that is the offline unlock, asserted rather than asserted about. A wrong passphrase, a stale KDF and a grant revoked by a rekey are three different answers, because the remedies are three different things and telling someone to retype a passphrase that was never the problem is worse than saying nothing. The shell's states are the onboarding story. The recovery code gets its own state that cannot be clicked past: it exists for one moment, losing it with the passphrase loses the vault, and there is no server-side reset by design. It is dropped from memory on confirmation rather than merely hidden. Sign-in is a delegate over IVaultServer, so the whole state machine runs in a test against an in-memory server — no browser, no identity provider, no toolkit. The view models are plain observable objects, which is what makes that possible. What it does not cover is whether the XAML binds to the right names; that needs a rendered tree and Avalonia.Headless, and is its own piece of work. Three things found by doing it rather than by reading it: - Pooled SQLite connections keep the database file open after the last context is disposed. On Windows that means locked, so the application could never replace its own cache — and a test could not clean up after itself, which is how it surfaced. Dispose now clears the pool. - EF's SQLite provider puts the database in WAL mode, so the cache is three files. A comment in ClientCacheFactory claimed the opposite; reading PRAGMA journal_mode off a real launch settled it. WAL is the right mode here — a sync pass writes while the interface reads — so the comment was wrong on the merits as well as on the fact. - Enrolling a device key with nowhere to keep the private half would put a wrap on the server nobody can open and make the device list claim this machine can unlock without a passphrase. Device binding is now optional and the shell declines it until the OS keystore is wired. Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db and migrated it on first launch, and msedgewebview2 held an established connection to the data plane while the unlock overlay covered it — which is the point of covering the WebView rather than collapsing it, since a NativeWebView that is never laid out is never realised. 630 tests, up from 593. The recovery-code gate and the offline unlock were each verified by breaking them and watching the right test fail. Still to do for M1's actual definition of done: the manual run against the real API and a real Keycloak. Credentials are not a synced entity type yet, so a connection still asks for a password, and the interface says so rather than implying otherwise. --- DodoSSH.slnx | 3 + README.md | 18 +- docs/platform-flags.md | 38 +- src/DodoSSH.Client.Api/ClientEnrollment.cs | 65 +- src/DodoSSH.Client.Api/DodoSshApiClient.cs | 21 +- src/DodoSSH.Client.App/App.axaml.cs | 52 +- .../DodoSSH.Client.App.csproj | 9 + .../ViewModels/MainWindowViewModel.cs | 443 +++++++++-- .../ViewModels/VaultViewModel.cs | 550 ++++++++++++++ src/DodoSSH.Client.App/Views/MainWindow.axaml | 343 +++++++-- src/DodoSSH.Client.App/packages.lock.json | 255 ++++++- .../AccountProvisioner.cs | 200 +++++ src/DodoSSH.Client.Session/ClientPaths.cs | 74 ++ .../DodoSSH.Client.Session.csproj | 25 + .../ServerConnection.cs | 262 +++++++ src/DodoSSH.Client.Session/SessionOpener.cs | 243 ++++++ src/DodoSSH.Client.Session/VaultSession.cs | 189 +++++ src/DodoSSH.Client.Session/packages.lock.json | 267 +++++++ .../ClientCacheFactory.cs | 31 +- src/DodoSSH.Client.Sync/ItemReconciler.cs | 12 +- src/DodoSSH.Client.Sync/SyncEngine.cs | 2 +- src/DodoSSH.Client.Sync/SyncReport.cs | 14 +- .../ClientEnrollmentTests.cs | 12 +- .../DodoSSH.Client.App.Tests.csproj | 18 + .../FakeVaultServer.cs | 200 +++++ .../ShellFlowTests.cs | 440 +++++++++++ .../packages.lock.json | 710 ++++++++++++++++++ .../ClientPathsTests.cs | 81 ++ .../DodoSSH.Client.Session.Tests.csproj | 13 + .../FakeAccountServer.cs | 170 +++++ .../SessionLifecycleTests.cs | 311 ++++++++ .../packages.lock.json | 457 +++++++++++ .../FileBackedCacheTests.cs | 87 +++ 33 files changed, 5405 insertions(+), 210 deletions(-) create mode 100644 src/DodoSSH.Client.App/ViewModels/VaultViewModel.cs create mode 100644 src/DodoSSH.Client.Session/AccountProvisioner.cs create mode 100644 src/DodoSSH.Client.Session/ClientPaths.cs create mode 100644 src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj create mode 100644 src/DodoSSH.Client.Session/ServerConnection.cs create mode 100644 src/DodoSSH.Client.Session/SessionOpener.cs create mode 100644 src/DodoSSH.Client.Session/VaultSession.cs create mode 100644 src/DodoSSH.Client.Session/packages.lock.json create mode 100644 tests/DodoSSH.Client.App.Tests/DodoSSH.Client.App.Tests.csproj create mode 100644 tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs create mode 100644 tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs create mode 100644 tests/DodoSSH.Client.App.Tests/packages.lock.json create mode 100644 tests/DodoSSH.Client.Session.Tests/ClientPathsTests.cs create mode 100644 tests/DodoSSH.Client.Session.Tests/DodoSSH.Client.Session.Tests.csproj create mode 100644 tests/DodoSSH.Client.Session.Tests/FakeAccountServer.cs create mode 100644 tests/DodoSSH.Client.Session.Tests/SessionLifecycleTests.cs create mode 100644 tests/DodoSSH.Client.Session.Tests/packages.lock.json create mode 100644 tests/DodoSSH.Client.Storage.Tests/FileBackedCacheTests.cs 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 @@ + + + + + + + + + + + + + + + + + + + + +