diff --git a/Directory.Packages.props b/Directory.Packages.props
index 386b744..440de5f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -100,6 +100,22 @@
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
-->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -51,3 +53,4 @@
+
diff --git a/src/DodoSSH.Client.Session/WindowsDeviceKeyStore.cs b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
similarity index 91%
rename from src/DodoSSH.Client.Session/WindowsDeviceKeyStore.cs
rename to src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
index a396fcd..2feb91f 100644
--- a/src/DodoSSH.Client.Session/WindowsDeviceKeyStore.cs
+++ b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
@@ -1,17 +1,28 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
+using DodoSSH.Client.Session;
-namespace DodoSSH.Client.Session;
+namespace DodoSSH.Client.App.Platform;
///
-/// Picks the device key store this machine can actually offer.
+/// Picks the device key store this desktop machine can actually offer.
///
///
+///
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
/// is not Windows, gets and therefore keeps asking for the
/// passphrase — which is the honest answer rather than a degraded one.
+///
+///
+/// "Desktop", because the choice belongs to a head rather than to the session layer. This file used
+/// to live in DodoSSH.Client.Session , which was the one thing keeping that project from being
+/// portable: everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the
+/// vault code meant a second head could not reference it without dragging Windows along. The seam that
+/// makes the move free is , which was already there — the session takes a
+/// store and has never known which one. See docs/android-port.md .
+///
///
-public static class DeviceKeyStores
+public static class DesktopDeviceKeyStores
{
/// The best store this machine supports.
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
diff --git a/src/DodoSSH.Client.App/Views/HostSidebar.axaml b/src/DodoSSH.Client.App/Views/HostSidebar.axaml
index 0fe36fe..04eeb2d 100644
--- a/src/DodoSSH.Client.App/Views/HostSidebar.axaml
+++ b/src/DodoSSH.Client.App/Views/HostSidebar.axaml
@@ -36,8 +36,10 @@
@@ -65,10 +67,37 @@
-->
-
-
+ ItemsSource="{Binding SidebarRows}"
+ SelectedItem="{Binding SelectedSidebarRow}">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -102,11 +131,20 @@
-->
+
+
-
+
+
@@ -148,6 +186,20 @@
+
+
+
+
+
+
+
+
+ IsVisible="{Binding IsConfirmingHostDeletion}">
diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml b/src/DodoSSH.Client.App/Views/HostsScreen.axaml
new file mode 100644
index 0000000..35a42db
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
new file mode 100644
index 0000000..dde4be9
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
@@ -0,0 +1,27 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// The hosts screen: the host list, and an overview of the one that is selected.
+///
+///
+/// Its data context is the shell rather than the vault, unlike and
+/// . The sidebar is handed the vault from inside the markup; everything else here
+/// reaches it through Vault.* . That split is not tidiness — this element's visibility is the shell's
+/// business and the sidebar's bindings are the vault's, and an element carrying both resolves the first
+/// against the second, where it does not exist.
+///
+internal sealed partial class HostsScreen : UserControl
+{
+ public HostsScreen() => InitializeComponent();
+
+ /// Where the keyboard lands when this screen is the one showing.
+ ///
+ /// Forwarded to the sidebar, which answers for itself: the host list can be folded away, and
+ /// Focus() on a collapsed control is measurably a no-op that is not replayed when the control is
+ /// revealed. Nothing in the right column can take the keyboard — it is a heading and three sentences.
+ ///
+ internal IInputElement KeyboardTarget => Sidebar.KeyboardTarget;
+}
diff --git a/src/DodoSSH.Client.App/Views/ImportScreen.axaml b/src/DodoSSH.Client.App/Views/ImportScreen.axaml
new file mode 100644
index 0000000..294db11
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/ImportScreen.axaml
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/ImportScreen.axaml.cs b/src/DodoSSH.Client.App/Views/ImportScreen.axaml.cs
new file mode 100644
index 0000000..74e6995
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/ImportScreen.axaml.cs
@@ -0,0 +1,37 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using DodoSSH.Client.Shell.ViewModels;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// Importing hosts from ~/.ssh/config .
+///
+///
+/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
+///
+internal sealed partial class ImportScreen : UserControl
+{
+ public ImportScreen()
+ {
+ InitializeComponent();
+
+ // The count on the import button is derived from the ticks, and a CheckBox bound with
+ // {Binding IsSelected} tells its own row and nothing else. Rather than have every row hold a
+ // reference back to the screen, the screen listens for the event they all bubble.
+ AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
+ }
+
+ /// Where the keyboard lands when this screen is the one showing.
+ internal IInputElement KeyboardTarget => this;
+
+ private void OnTickChanged(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is ImportViewModel import)
+ {
+ import.NoteSelectionChanged();
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.App/Views/KnownHostsScreen.axaml b/src/DodoSSH.Client.App/Views/KnownHostsScreen.axaml
new file mode 100644
index 0000000..ecab588
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/KnownHostsScreen.axaml
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/KnownHostsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/KnownHostsScreen.axaml.cs
new file mode 100644
index 0000000..6a2a265
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/KnownHostsScreen.axaml.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// The host keys this keychain has approved.
+///
+///
+/// Its data context is a KnownHostsViewModel , which is a screen-scoped wrapper over the vault rather
+/// than an owner of anything: the pins, the reload and the withdrawal all still belong to
+/// VaultViewModel . See that class for why.
+///
+internal sealed partial class KnownHostsScreen : UserControl
+{
+ public KnownHostsScreen() => InitializeComponent();
+
+ /// Where the keyboard lands when this screen is the one showing.
+ ///
+ /// The filter box rather than the list, unlike the keychain screen. This screen is reached to answer a
+ /// question — is this fingerprint one of mine — and the first thing anybody does is type part of it.
+ /// The box is also always there, where the list is empty on a fresh keychain, and Focus() on a
+ /// collapsed control is a no-op that is not replayed.
+ ///
+ internal IInputElement KeyboardTarget => PinFilter;
+}
diff --git a/src/DodoSSH.Client.App/Views/LogsScreen.axaml b/src/DodoSSH.Client.App/Views/LogsScreen.axaml
new file mode 100644
index 0000000..40551a3
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/LogsScreen.axaml
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/LogsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/LogsScreen.axaml.cs
new file mode 100644
index 0000000..df10241
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/LogsScreen.axaml.cs
@@ -0,0 +1,28 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+using DodoSSH.Client.Shell.ViewModels;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// What has been connected to, and what has been changed.
+///
+///
+/// Its data context is a LogsViewModel , a screen-scoped wrapper over the open session. Both logs are
+/// ordinary synced keychain items; nothing about them is local.
+///
+internal sealed partial class LogsScreen : UserControl
+{
+ public LogsScreen() => InitializeComponent();
+
+ /// Where the keyboard lands when this screen is the one showing.
+ ///
+ /// Whichever list is on screen, because this screen has no filter box and a collapsed control cannot
+ /// take focus — Focus() on one is a no-op that nothing replays when it is revealed. The lists are
+ /// focusable explicitly for the same reason the host list is: Avalonia leaves focus to the items, and an
+ /// empty list has none.
+ ///
+ internal IInputElement KeyboardTarget =>
+ DataContext is LogsViewModel { ShowsActivity: true } ? ActivityList : ConnectionList;
+}
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index f21ba9c..4e90987 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -15,7 +15,8 @@
Focusable="True">
+
-
-
+
-
+
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).
- Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).
- Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).
- Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.
- Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another.
-
-
-
-
-
-
-
-
+
+ Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
Focus() call was failing silently.
///
+ ///
+ /// The terminal answers first, and it has to, because still
+ /// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
+ /// keyboard to a host list nobody can see.
+ ///
///
- private IInputElement KeyboardHome => shell?.Screen switch
+ private IInputElement KeyboardHome => shell switch
{
- ShellScreen.Vault => VaultPane.KeyboardTarget,
- ShellScreen.Hosts => Hosts.KeyboardTarget,
+ { IsTerminalShowing: true } => Terminal,
+ { Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
+ { Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
+ { Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
+ { Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
+ { Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
+ { Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
_ => this,
};
+ ///
+ /// Asks for the terminal to take the keyboard, once layout has run.
+ ///
+ ///
+ ///
+ /// Posted, not called. Every path that reaches here has revealed the WebView in this same turn —
+ /// a session opened from another screen, a tab clicked while a page was showing, the palette closing
+ /// back onto a terminal. NativeControlHost re-pushes its bounds on the next layout pass, so
+ /// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
+ /// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
+ ///
+ ///
+ /// DispatcherPriority.Loaded runs after layout. It is the same fix and the same reasoning as
+ /// 's, which posts its own focus for the same race in the other direction.
+ ///
+ ///
+ /// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
+ /// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
+ /// into a collapsed WebView would leave the window with nothing focused at all.
+ ///
+ ///
+ private void FocusTerminalWhenLaidOut() =>
+ Dispatcher.UIThread.Post(
+ () =>
+ {
+ if (shell is { IsTerminalShowing: true })
+ {
+ Terminal.Focus();
+ }
+ },
+ DispatcherPriority.Loaded);
+
///
/// Where the keyboard belongs once the vault is no longer open.
///
@@ -160,14 +203,19 @@ internal sealed partial class MainWindow : Window
}
///
- /// A bare Focus() is the whole fix in this direction: NativeWebView.OnGotFocus pushes
- /// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
- /// a session can only be opened from the hosts screen of an unlocked vault, and that is exactly the
- /// state in which the terminal is showing. Focus() on a collapsed control is measurably a no-op and is
- /// not replayed when it is revealed.
+ /// NativeWebView.OnGotFocus pushes Win32 focus into WebView2 for us, so a Focus() call is
+ /// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
+ /// reliably is at this instant. A session can now be opened from any screen, so this event routinely
+ /// arrives in the same turn that revealed the WebView. Hence the post; see
+ /// .
///
- private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
+ private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
+ ///
+ /// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
+ /// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
+ /// point at which "does this one return early" stops being obvious to a reader.
+ ///
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (shell is not { } viewModel)
@@ -175,54 +223,117 @@ internal sealed partial class MainWindow : Window
return;
}
- if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
+ switch (e.PropertyName)
{
- var unlocked = viewModel.IsUnlocked;
+ case nameof(MainWindowViewModel.IsUnlocked):
+ OnVaultOpenedOrClosed(viewModel);
+ break;
- // Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
- // change, and reacting to all of them would move focus during setup and sign-in.
- if (wasUnlocked && !unlocked)
- {
- ReleaseKeyboardTo(ClosedVaultKeyboardHome);
- }
+ case nameof(MainWindowViewModel.IsSearching):
+ OnPaletteToggled(viewModel);
+ break;
- wasUnlocked = unlocked;
+ // One arm for both, deliberately. They mean the same thing to this handler — what the window is
+ // showing may have changed — and answering them separately would make the order of two
+ // PropertyChanged raises decide the outcome. Connecting from the palette moves both.
+ case nameof(MainWindowViewModel.Surface):
+ case nameof(MainWindowViewModel.Screen):
+ OnShowingSomethingElse(viewModel);
+ break;
+
+ case nameof(MainWindowViewModel.SelectedTab):
+ OnSelectedTabChanged(viewModel);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
+ {
+ var unlocked = viewModel.IsUnlocked;
+
+ // Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
+ // change, and reacting to all of them would move focus during setup and sign-in.
+ if (wasUnlocked && !unlocked)
+ {
+ ReleaseKeyboardTo(ClosedVaultKeyboardHome);
+ }
+
+ wasUnlocked = unlocked;
+ }
+
+ ///
+ /// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
+ /// to start typing into immediately — but the palette does that for itself when it becomes visible,
+ /// which is a moment this handler is measurably ahead of: it runs from the view model's
+ /// PropertyChanged , before the binding that reveals the control, and Focus() on a control
+ /// that is still collapsed is a no-op that is not replayed when it is revealed.
+ ///
+ private void OnPaletteToggled(MainWindowViewModel viewModel)
+ {
+ if (viewModel.IsSearching)
+ {
return;
}
- // Closing only. Opening also has to move the keyboard — the palette is a text box somebody is
- // expected to start typing into immediately — but the palette does that for itself when it becomes
- // visible, which is a moment this handler is measurably ahead of: it runs from the view model's
- // PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
- // still collapsed is a no-op that is not replayed when it is revealed.
- if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
+ // Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
+ // focus rather than the immediate one.
+ if (viewModel.IsTerminalShowing)
{
- if (!viewModel.IsSearching)
- {
- ReleaseKeyboardTo(KeyboardHome);
- }
+ FocusTerminalWhenLaidOut();
+ }
+ else
+ {
+ ReleaseKeyboardTo(KeyboardHome);
+ }
+ }
+ ///
+ /// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
+ ///
+ ///
+ /// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
+ /// are both one click away at all times.
+ ///
+ /// ReleaseKeyboardTo , not Focus() , in the page direction — and that is the whole of why
+ /// this method is worth reading. Collapsing the WebView does not release the keyboard. The native
+ /// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
+ /// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
+ /// was rare; it is the hot path now. See docs/platform-flags.md , and
+ /// for why only one direction needs the Win32 call.
+ ///
+ ///
+ private void OnShowingSomethingElse(MainWindowViewModel viewModel)
+ {
+ if (!viewModel.IsUnlocked)
+ {
return;
}
- // Switching screens moves the keyboard to whatever the new screen offers, for the same reason:
- // leaving it on a control that has just been collapsed leaves the window with nothing focused.
- if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.Screen), StringComparison.Ordinal)
- && viewModel.IsUnlocked)
+ if (viewModel.IsTerminalShowing)
{
- KeyboardHome.Focus();
- return;
+ FocusTerminalWhenLaidOut();
}
-
- // Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click
- // is what took the WebView's Win32 focus away in the first place. term.focus() in the page only
- // ever reaches document.activeElement, which does nothing for a page that no longer holds the
- // native focus, so without this the pane looks selected and every keystroke goes to the button
- // instead of the shell until the user clicks inside the terminal by hand.
- if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.SelectedTab), StringComparison.Ordinal)
- && viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
+ else
{
- Terminal.Focus();
+ ReleaseKeyboardTo(KeyboardHome);
+ }
+ }
+
+ ///
+ /// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is
+ /// what took the WebView's Win32 focus away in the first place. term.focus() in the page only
+ /// ever reaches document.activeElement , which does nothing for a page that no longer holds the
+ /// native focus, so without this the pane looks selected and every keystroke goes to the button instead
+ /// of the shell until the user clicks inside the terminal by hand.
+ ///
+ private void OnSelectedTabChanged(MainWindowViewModel viewModel)
+ {
+ if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
+ {
+ FocusTerminalWhenLaidOut();
}
}
diff --git a/src/DodoSSH.Client.App/Views/NavRail.axaml b/src/DodoSSH.Client.App/Views/NavRail.axaml
index 4cd15ef..67a8c46 100644
--- a/src/DodoSSH.Client.App/Views/NavRail.axaml
+++ b/src/DodoSSH.Client.App/Views/NavRail.axaml
@@ -5,7 +5,7 @@
x:DataType="vm:MainWindowViewModel">
-
-
+
-
-
+
+
+
+
+
+
+
+ ToolTip.Tip="Shared keychains and the people in them. Not built yet — see the screen for what is missing." />
diff --git a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
index b9d112a..e85718a 100644
--- a/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
@@ -34,7 +34,7 @@
+ Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
-
-
+ Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
@@ -77,7 +77,7 @@
+ Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
+
+
+
+
+
+
+
+
+ Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml.cs
new file mode 100644
index 0000000..eddfe7c
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// The commands this keychain has saved.
+///
+///
+/// Its data context is a SnippetsViewModel , a screen-scoped wrapper over the vault rather than an
+/// owner of anything: the list, the storage and the push all still belong to VaultViewModel .
+///
+internal sealed partial class SnippetsScreen : UserControl
+{
+ public SnippetsScreen() => InitializeComponent();
+
+ /// Where the keyboard lands when this screen is the one showing.
+ ///
+ /// The filter box rather than the list, for the reason the pins screen gives: the box is there on a
+ /// keychain with nothing saved yet, where the list is empty and Focus() on it would be a no-op
+ /// nothing replays.
+ ///
+ internal IInputElement KeyboardTarget => SnippetFilter;
+}
diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml
new file mode 100644
index 0000000..a85fccf
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs
new file mode 100644
index 0000000..190e15d
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/TeamsScreen.axaml.cs
@@ -0,0 +1,9 @@
+using Avalonia.Controls;
+
+namespace DodoSSH.Client.App.Views;
+
+/// Teams: who is in one, what they may do, and which vaults they hold a key to.
+internal sealed partial class TeamsScreen : UserControl
+{
+ public TeamsScreen() => InitializeComponent();
+}
diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml
index 5a04313..70d28b4 100644
--- a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml
+++ b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml
@@ -5,17 +5,20 @@
x:DataType="vm:MainWindowViewModel">
+
+
+
@@ -36,57 +44,85 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs
index ee94c91..797d603 100644
--- a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs
@@ -1,9 +1,56 @@
+using Avalonia;
using Avalonia.Controls;
+using Avalonia.Input;
+using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
-/// The tab strip above the terminal.
+/// The tab strip, above every screen.
internal sealed partial class TerminalTabs : UserControl
{
public TerminalTabs() => InitializeComponent();
+
+ ///
+ /// Closes a tab on a middle click.
+ ///
+ ///
+ ///
+ /// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
+ /// A middle press on the background, on the sentence, or on the button that opens a connection reaches
+ /// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
+ ///
+ ///
+ /// PointerUpdateKind , not IsMiddleButtonPressed . The latter reports button
+ /// state : it is equally true for a left press made while the middle button happens to be held,
+ /// and for every press during a middle drag. The question here is which button caused this press, and
+ /// that is the one thing only PointerUpdateKind answers.
+ ///
+ ///
+ /// On press rather than on release, which is what every browser and every terminal does. Matching a
+ /// release to its press would need capture tracking, to buy the ability to change your mind about a
+ /// middle click — a gesture nobody makes by accident and nobody aborts.
+ ///
+ ///
+ private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not Visual { DataContext: TerminalTabViewModel tab }
+ || DataContext is not MainWindowViewModel shell)
+ {
+ return;
+ }
+
+ if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
+ is not PointerUpdateKind.MiddleButtonPressed)
+ {
+ return;
+ }
+
+ // Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
+ e.Handled = true;
+
+ // Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
+ // it waits for the workspace to tear the session down — and an event handler has nowhere to await
+ // it. Its failures are the workspace's to report, not this strip's.
+ shell.CloseTabCommand.Execute(tab);
+ }
}
diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
index 49a268f..847ba66 100644
--- a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
@@ -65,15 +65,32 @@
-
+
-
+
+
+
+
+
+
@@ -87,26 +104,43 @@
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
@@ -122,7 +156,12 @@
-
+
+
@@ -204,6 +243,17 @@
Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" />
+
+
+
@@ -226,7 +276,8 @@
-
+
@@ -345,6 +396,24 @@
IsVisible="{Binding IsConnected}" />
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml.cs b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml.cs
index 2e1cd71..e580285 100644
--- a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml.cs
@@ -1,5 +1,8 @@
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -8,12 +11,42 @@ namespace DodoSSH.Client.App.Views;
/// The two-pane file browser and the transfer queue.
///
///
+///
/// Its data context is the TransfersViewModel , which the shell owns for the life of the process — a
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
/// MainWindowViewModel.LockAsync .
+///
+///
+/// Everything about drag and drop is in this file and nothing about it is policy. The handlers pull
+/// paths or rows out of a drop and hand them to QueueUploads /QueueDownloads ; what may be
+/// queued, what is skipped and what is said about it all live in the view model, where they can be tested
+/// without a window. Nothing headless can synthesise a real platform drag, so the wiring below is verified
+/// by hand — see docs/manual-checks.md .
+///
///
internal sealed partial class TransfersScreen : UserControl
{
+ ///
+ /// How remote rows travel while being dragged.
+ ///
+ ///
+ /// An in-process format, so the rows themselves cross rather than a list of path strings that would
+ /// have to be looked up again on the other side. It also cannot be confused with a drop from the
+ /// operating system: a file dragged out of the file manager arrives as DataFormat.File and never
+ /// as this, so "did this come from our own remote pane" needs no guessing.
+ ///
+ private static readonly DataFormat RemoteEntries =
+ DataFormat.CreateInProcessFormat("dodossh/remote-entries");
+
+ /// How far the pointer moves before a press becomes a drag.
+ ///
+ /// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
+ ///
+ private const double DragThreshold = 4;
+
+ private PointerPressedEventArgs? pressed;
+ private Point pressedAt;
+
public TransfersScreen()
{
InitializeComponent();
@@ -23,11 +56,32 @@ internal sealed partial class TransfersScreen : UserControl
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
LocalList.DoubleTapped += OnLocalActivated;
RemoteList.DoubleTapped += OnRemoteActivated;
+
+ // On the pane rather than on the list. A directory with nothing in it lays its ListBox out at zero
+ // height behind the empty-state sentence, and a drop handler on the list would have nothing to hit.
+ LocalPane.AddHandler(DragDrop.DragOverEvent, OnLocalDragOver);
+ LocalPane.AddHandler(DragDrop.DragLeaveEvent, OnLocalDragLeave);
+ LocalPane.AddHandler(DragDrop.DropEvent, OnLocalDrop);
+
+ RemotePane.AddHandler(DragDrop.DragOverEvent, OnRemoteDragOver);
+ RemotePane.AddHandler(DragDrop.DragLeaveEvent, OnRemoteDragLeave);
+ RemotePane.AddHandler(DragDrop.DropEvent, OnRemoteDrop);
+
+ // Tunnelling, so noting where a press started does not take the press away from the ListBox — a row
+ // still selects, and the drag only begins once the pointer has moved far enough.
+ foreach (var list in new Control[] { LocalList, RemoteList })
+ {
+ list.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
+ list.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
+ list.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
+ }
}
+ private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
+
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
- if (DataContext is TransfersViewModel transfers)
+ if (Transfers is { } transfers)
{
transfers.OpenLocalCommand.Execute(null);
}
@@ -40,9 +94,211 @@ internal sealed partial class TransfersScreen : UserControl
///
private void OnRemoteActivated(object? sender, TappedEventArgs e)
{
- if (DataContext is TransfersViewModel transfers)
+ if (Transfers is { } transfers)
{
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
}
}
+
+ // ---- Starting a drag ----
+
+ private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (e.GetCurrentPoint(this).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed)
+ {
+ pressed = e;
+ pressedAt = e.GetPosition(this);
+ }
+ }
+
+ private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => pressed = null;
+
+ ///
+ /// The drag starts here rather than on the press, because a press is also how a row is selected.
+ /// DoDragDropAsync wants the original PointerPressedEventArgs , so it is held from the
+ /// press until either the pointer moves far enough or the button comes back up.
+ ///
+ private void OnPointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (pressed is not { } origin || Transfers is not { } transfers)
+ {
+ return;
+ }
+
+ if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ pressed = null;
+ return;
+ }
+
+ var moved = e.GetPosition(this) - pressedAt;
+
+ if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
+ {
+ return;
+ }
+
+ pressed = null;
+
+ if (ReferenceEquals(sender, RemoteList))
+ {
+ StartRemoteDrag(origin, transfers);
+ }
+ else
+ {
+ _ = StartLocalDragAsync(origin, transfers);
+ }
+ }
+
+ private static void StartRemoteDrag(PointerPressedEventArgs origin, TransfersViewModel transfers)
+ {
+ if (transfers.SelectedRemoteEntry is not { } row)
+ {
+ return;
+ }
+
+ using var transfer = new DataTransfer();
+ transfer.Add(DataTransferItem.Create(RemoteEntries, new RemoteDragPayload([row])));
+
+ _ = DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy);
+ }
+
+ ///
+ /// Local files travel as the platform's own file format rather than as an in-process one, which is what
+ /// makes a single drag work both onto the remote pane and out into the file manager. It needs a real
+ /// , hence the asynchronous lookup — and hence a fire-and-forget call, because
+ /// nothing on a pointer-moved path can await.
+ ///
+ private async Task StartLocalDragAsync(PointerPressedEventArgs origin, TransfersViewModel transfers)
+ {
+ if (transfers.SelectedLocalEntry is not { IsFile: true } row
+ || TopLevel.GetTopLevel(this) is not { } top)
+ {
+ return;
+ }
+
+ var file = await top.StorageProvider.TryGetFileFromPathAsync(row.FullPath).ConfigureAwait(true);
+
+ if (file is null)
+ {
+ return;
+ }
+
+ using var transfer = new DataTransfer();
+ transfer.Add(DataTransferItem.CreateFile(file));
+
+ await DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy).ConfigureAwait(true);
+ }
+
+ // ---- Accepting a drop ----
+
+ ///
+ /// The local pane takes remote rows and nothing else. A file dragged from the file manager onto it
+ /// would be a copy from this machine to this machine, which is not what this screen is for.
+ ///
+ private void OnLocalDragOver(object? sender, DragEventArgs e)
+ {
+ var accepted = e.DataTransfer.Contains(RemoteEntries);
+
+ e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None;
+
+ if (Transfers is { } transfers)
+ {
+ transfers.IsLocalDropTarget = accepted;
+ }
+
+ e.Handled = true;
+ }
+
+ private void OnLocalDragLeave(object? sender, DragEventArgs e)
+ {
+ if (Transfers is { } transfers)
+ {
+ transfers.IsLocalDropTarget = false;
+ }
+ }
+
+ private void OnLocalDrop(object? sender, DragEventArgs e)
+ {
+ if (Transfers is not { } transfers)
+ {
+ return;
+ }
+
+ transfers.IsLocalDropTarget = false;
+ e.Handled = true;
+
+ if (e.DataTransfer.TryGetValue(RemoteEntries) is { } payload)
+ {
+ transfers.QueueDownloads(payload.Rows);
+ }
+ }
+
+ ///
+ /// The remote pane takes files: from the file manager, and from the local pane, which offers the same
+ /// platform format. A drop while disconnected is refused visibly rather than accepted and then
+ /// explained, because a red pane under the pointer is the answer arriving before the drop rather than
+ /// after it.
+ ///
+ private void OnRemoteDragOver(object? sender, DragEventArgs e)
+ {
+ var files = e.DataTransfer.Contains(DataFormat.File);
+ var connected = Transfers is { IsConnected: true };
+
+ e.DragEffects = files && connected ? DragDropEffects.Copy : DragDropEffects.None;
+
+ if (Transfers is { } transfers)
+ {
+ transfers.IsRemoteDropTarget = files && connected;
+ transfers.IsRemoteDropRefused = files && !connected;
+ }
+
+ e.Handled = true;
+ }
+
+ private void OnRemoteDragLeave(object? sender, DragEventArgs e) => ClearRemoteHighlight();
+
+ private void OnRemoteDrop(object? sender, DragEventArgs e)
+ {
+ if (Transfers is not { } transfers)
+ {
+ return;
+ }
+
+ ClearRemoteHighlight();
+ e.Handled = true;
+
+ if (e.DataTransfer.TryGetFiles() is not { } files)
+ {
+ return;
+ }
+
+ // TryGetLocalPath, because the queue reads bytes off a real path. A storage item that is not a
+ // local file — one from a cloud provider's virtual folder — has none, and dropping it is a thing
+ // this screen declines rather than a thing it half does.
+ var paths = files
+ .Select(file => file.TryGetLocalPath())
+ .OfType()
+ .ToList();
+
+ transfers.QueueUploads(paths);
+ }
+
+ private void ClearRemoteHighlight()
+ {
+ if (Transfers is { } transfers)
+ {
+ transfers.IsRemoteDropTarget = false;
+ transfers.IsRemoteDropRefused = false;
+ }
+ }
}
+
+///
+/// The remote rows carried by one drag.
+///
+///
+/// A record wrapping the list rather than the list itself, because DataFormat.CreateInProcessFormat
+/// keys on the type and a bare IReadOnlyList<T> is too general a key to be sure of.
+///
+internal sealed record RemoteDragPayload(IReadOnlyList Rows);
diff --git a/src/DodoSSH.Client.App/Views/UnlockCard.axaml b/src/DodoSSH.Client.App/Views/UnlockCard.axaml
index 5ac85bd..6a45289 100644
--- a/src/DodoSSH.Client.App/Views/UnlockCard.axaml
+++ b/src/DodoSSH.Client.App/Views/UnlockCard.axaml
@@ -19,7 +19,7 @@
-
+
+ PlaceholderText="keychain passphrase" PasswordChar="•">
@@ -52,7 +52,7 @@
Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}"
- ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
+ ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
@@ -73,7 +73,7 @@
+ Text="Locking closes the keychain, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the keychain, not the connections. Quit DodoSSH to end them." />
@@ -88,7 +88,7 @@
+ Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the keychain is on the server and comes back." />
diff --git a/src/DodoSSH.Client.App/Views/VaultScreen.axaml b/src/DodoSSH.Client.App/Views/VaultScreen.axaml
index 3b235a5..721844e 100644
--- a/src/DodoSSH.Client.App/Views/VaultScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/VaultScreen.axaml
@@ -2,25 +2,30 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
+ xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.App.Views.VaultScreen"
x:DataType="vm:VaultViewModel">
@@ -31,7 +36,7 @@
-
+
+
+ CommandParameter="{x:Static vm:VaultSection.Buckets}"
+ Classes.active="{Binding ShowsBuckets}">
-
-
+
@@ -82,18 +92,35 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ ToolTip.Tip="Pastes in a key you already have." />
+
+
@@ -218,7 +253,7 @@
empty rows, this says what is missing in one line.
-->
+ Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
@@ -226,6 +261,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
@@ -273,7 +350,7 @@
+ Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
@@ -307,6 +384,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/packages.lock.json b/src/DodoSSH.Client.App/packages.lock.json
index 052b253..be43615 100644
--- a/src/DodoSSH.Client.App/packages.lock.json
+++ b/src/DodoSSH.Client.App/packages.lock.json
@@ -349,6 +349,21 @@
"dodossh.client.domain": {
"type": "Project"
},
+ "dodossh.client.import": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Domain": "[1.0.0, )"
+ }
+ },
+ "dodossh.client.objectstore": {
+ "type": "Project",
+ "dependencies": {
+ "AWSSDK.Core": "[4.0.100.9, )",
+ "AWSSDK.S3": "[4.0.101.6, )",
+ "DodoSSH.Client.Domain": "[1.0.0, )",
+ "DodoSSH.Client.Ssh": "[1.0.0, )"
+ }
+ },
"dodossh.client.session": {
"type": "Project",
"dependencies": {
@@ -357,7 +372,8 @@
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
- "DodoSSH.Client.Sync": "[1.0.0, )"
+ "DodoSSH.Client.Sync": "[1.0.0, )",
+ "DodoSSH.Client.Terminal": "[1.0.0, )"
}
},
"dodossh.client.shell": {
@@ -365,6 +381,8 @@
"dependencies": {
"Avalonia": "[12.1.1, )",
"CommunityToolkit.Mvvm": "[8.4.2, )",
+ "DodoSSH.Client.Import": "[1.0.0, )",
+ "DodoSSH.Client.ObjectStore": "[1.0.0, )",
"DodoSSH.Client.Session": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
@@ -374,6 +392,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
+ "NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -417,6 +436,21 @@
"NSec.Cryptography": "[26.4.0, )"
}
},
+ "AWSSDK.Core": {
+ "type": "CentralTransitive",
+ "requested": "[4.0.100.9, )",
+ "resolved": "4.0.100.9",
+ "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
+ },
+ "AWSSDK.S3": {
+ "type": "CentralTransitive",
+ "requested": "[4.0.101.6, )",
+ "resolved": "4.0.101.6",
+ "contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
+ "dependencies": {
+ "AWSSDK.Core": "[4.0.100.9, 5.0.0)"
+ }
+ },
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
diff --git a/src/DodoSSH.Client.Domain/ActivityLogSecret.cs b/src/DodoSSH.Client.Domain/ActivityLogSecret.cs
new file mode 100644
index 0000000..ffe92d5
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ActivityLogSecret.cs
@@ -0,0 +1,112 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+/// What was done to an item.
+public enum ActivityOperation
+{
+ /// It was created.
+ Created = 0,
+
+ /// It was changed.
+ Updated = 1,
+
+ /// It was deleted.
+ Deleted = 2,
+}
+
+///
+/// One create, edit or delete of a keychain item, decrypted.
+///
+///
+///
+/// holds names and never values. That is the rule the whole type is built
+/// around, and it is the same one ADR 0006 imposes on the server's own detail column: an audit log
+/// that recorded what a password used to be would be a plaintext credential store with a vault drawn around
+/// it. "Password" is what somebody needs to see; the old password is what nobody does.
+///
+///
+/// is a copy, taken at the time. Deleting the item is one of the three things
+/// this records, so a lookup would resolve to nothing in exactly the case the entry matters most. It is also
+/// what makes a rename readable — an entry saying "renamed 'old-db'" is useful, and one saying "renamed
+/// 'prod-db'" because that is what it is called now is not.
+///
+///
+public sealed record ActivityLogSecret : IVaultSecret
+{
+ /// What kind of item this was about, as the sync contract names it.
+ ///
+ /// Stored as the wire enum's name rather than its number, so an entry written by a build that knows a
+ /// kind this one does not still reads as something — an unknown name is shown as itself, where an
+ /// unknown number would have to be shown as a number.
+ ///
+ public required string ItemKind { get; init; }
+
+ /// The item, so an entry can be traced to what it was about.
+ public required Guid ItemId { get; init; }
+
+ /// What the item was called at the time.
+ public required string ItemLabel { get; init; }
+
+ /// What was done.
+ public ActivityOperation Operation { get; init; }
+
+ ///
+ /// The names of the fields that changed, separated by ", " . Never their values.
+ ///
+ ///
+ ///
+ /// One string rather than a collection, and the choice is about equality. A plain
+ /// on a record gets reference equality from the compiler-generated
+ /// Equals , which is the trap exists to avoid — and a second type of that
+ /// shape is a lot of machinery for a value that is written once and only ever displayed. The separator is
+ /// unambiguous because these are C# property names, which cannot contain one.
+ ///
+ ///
+ /// Empty for a create and for a delete, where "which fields" has no meaning — every field arrived, or all
+ /// of them went. Empty is also the honest answer when an update's before and after could not be compared,
+ /// which is why nothing reading this may take empty to mean "nothing changed".
+ ///
+ ///
+ public string ChangedFields { get; init; } = string.Empty;
+
+ /// When it happened.
+ public required DateTimeOffset At { get; init; }
+
+ /// Which machine it was done from, as that machine calls itself.
+ public required string DeviceName { get; init; }
+
+ /// Which account in this organisation did it.
+ public Guid ActorUserId { get; init; }
+
+ /// What this entry is called, derived from what it records.
+ ///
+ public string Label => $"{Operation} {ItemLabel}";
+
+ /// Whether this is storable, and why not if it is not.
+ public bool TryValidate([NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(ItemKind))
+ {
+ reason = "An activity log entry needs the kind of item it was about.";
+ return false;
+ }
+
+ if (ItemId == Guid.Empty)
+ {
+ reason = "An activity log entry needs the item it was about.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(DeviceName))
+ {
+ reason = "An activity log entry needs the machine it was done from.";
+ return false;
+ }
+
+ // The label is deliberately not checked. An item somebody created and never named has an empty one,
+ // and refusing to record that would mean the log's completeness depended on the user's tidiness.
+ reason = null;
+ return true;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/ActivityLogSecretCodec.cs b/src/DodoSSH.Client.Domain/ActivityLogSecretCodec.cs
new file mode 100644
index 0000000..8f96d4e
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ActivityLogSecretCodec.cs
@@ -0,0 +1,137 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Domain;
+
+/// A decoded activity log payload, together with the schema version it was written at.
+/// The entry.
+/// The version the writing client used.
+public sealed record ActivityLogSecretDocument(ActivityLogSecret Entry, int SchemaVersion)
+{
+ ///
+ public bool IsReadOnly => SchemaVersion > ActivityLogSecretCodec.CurrentSchemaVersion;
+}
+
+///
+/// Encodes and decodes the plaintext inside an activity log entry's encrypted payload.
+///
+///
+/// travels as its name and not its number, which is the one thing
+/// here worth deciding on purpose: item kinds are an open set, so a build that has not heard of the fifth one
+/// can still show "PortForward" where a number would leave it showing "9".
+///
+public static class ActivityLogSecretCodec
+{
+ /// The schema version this build writes.
+ public const int CurrentSchemaVersion = 1;
+
+ /// Serialises an entry to the bytes that get sealed.
+ /// The entry is not valid for storage.
+ public static byte[] Encode(ActivityLogSecret entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+
+ if (!entry.TryValidate(out var reason))
+ {
+ throw new ArgumentException(reason, nameof(entry));
+ }
+
+ var document = new ActivityLogPayloadDocument
+ {
+ SchemaVersion = CurrentSchemaVersion,
+ ItemKind = entry.ItemKind,
+ ItemId = entry.ItemId,
+ ItemLabel = entry.ItemLabel,
+ Operation = (int)entry.Operation,
+ ChangedFields = entry.ChangedFields.Length == 0 ? null : entry.ChangedFields,
+ At = entry.At,
+ DeviceName = entry.DeviceName,
+ ActorUserId = entry.ActorUserId,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(
+ document, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
+ }
+
+ /// Parses a decrypted payload.
+ ///
+ public static bool TryDecode(
+ ReadOnlySpan payload,
+ [NotNullWhen(true)] out ActivityLogSecretDocument? document)
+ {
+ document = null;
+
+ ActivityLogPayloadDocument? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(
+ payload, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (parsed is null || parsed.SchemaVersion < 1)
+ {
+ return false;
+ }
+
+ var candidate = new ActivityLogSecret
+ {
+ ItemKind = parsed.ItemKind ?? string.Empty,
+ ItemId = parsed.ItemId,
+ ItemLabel = parsed.ItemLabel ?? string.Empty,
+ Operation = Enum.IsDefined((ActivityOperation)parsed.Operation)
+ ? (ActivityOperation)parsed.Operation
+ : default,
+ ChangedFields = parsed.ChangedFields ?? string.Empty,
+ At = parsed.At,
+ DeviceName = parsed.DeviceName ?? string.Empty,
+ ActorUserId = parsed.ActorUserId,
+ };
+
+ if (!candidate.TryValidate(out _))
+ {
+ return false;
+ }
+
+ document = new ActivityLogSecretDocument(candidate, parsed.SchemaVersion);
+ return true;
+ }
+}
+
+/// The serialised shape. Mutable and nullable because it models untrusted input.
+///
+internal sealed class ActivityLogPayloadDocument
+{
+ public int SchemaVersion { get; set; }
+
+ public string? ItemKind { get; set; }
+
+ public Guid ItemId { get; set; }
+
+ public string? ItemLabel { get; set; }
+
+ public int Operation { get; set; }
+
+ ///
+ /// Written as null when empty rather than as "" , so that a create and a delete — which have no
+ /// changed fields by definition — omit the property entirely instead of carrying an empty one.
+ ///
+ public string? ChangedFields { get; set; }
+
+ public DateTimeOffset At { get; set; }
+
+ public string? DeviceName { get; set; }
+
+ public Guid ActorUserId { get; set; }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(ActivityLogPayloadDocument))]
+internal sealed partial class ActivityLogPayloadJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Domain/ConnectionLogSecret.cs b/src/DodoSSH.Client.Domain/ConnectionLogSecret.cs
new file mode 100644
index 0000000..fa85922
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ConnectionLogSecret.cs
@@ -0,0 +1,147 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+
+namespace DodoSSH.Client.Domain;
+
+/// How a connection ended.
+public enum ConnectionOutcome
+{
+ /// The session ran and then ended — by the user, by the remote, or by the process closing.
+ ///
+ /// One value for all three, deliberately. From an auditor's side "this person had a shell on that machine
+ /// for eleven minutes" is the fact; which of the two ends hung up first is not something this client can
+ /// establish reliably — a tab close and a remote hangup both arrive as the pump finishing — and a field
+ /// that guessed would be worse than one that does not claim to know.
+ ///
+ Closed = 0,
+
+ /// The connection was attempted and did not open.
+ Failed = 1,
+
+ /// The host key was not the pinned one, so the client refused before authenticating.
+ ///
+ /// Its own outcome rather than a kind of , because it is the only one that means
+ /// something about the host rather than about the network or the credentials. A run of these on
+ /// one machine is the single most interesting thing a connection log can show.
+ ///
+ Refused = 2,
+}
+
+/// What kind of session a log entry is about.
+public enum ConnectionKind
+{
+ /// An interactive terminal.
+ Terminal = 0,
+
+ /// An SFTP session for moving files.
+ ///
+ /// Recorded separately and not hidden. Opening the file browser is a second login as far as the remote's
+ /// own auth.log is concerned, so a log of ours that quietly omitted it would disagree with the
+ /// host's — and the person comparing the two would be right to trust the host.
+ ///
+ Sftp = 1,
+}
+
+///
+/// One connection that was made, decrypted.
+///
+///
+///
+/// What is here, and what deliberately is not. The host's label, its item id, the address as dialled,
+/// when it started, how long it lasted, how it ended, and which user on which device did it. An audit log
+/// with no actor is not an audit log — the whole reason these sync is that an administrator will read them
+/// once teams land — so the actor is recorded and the SSH username is not. The two are different questions:
+/// "who in this organisation opened a shell" is what an audit answers, and "which account they logged in as"
+/// is a detail of the host that the host's own logs already have.
+///
+///
+/// Written once, at close. Every field is known by then, so an entry never needs a second write —
+/// which is what keeps a synced log from needing a merge, an outbox row per update, or any way to collide
+/// with itself. A connection that is still running is not in here at all; it is shown from the workspace's
+/// live state, which is the only place that knows.
+///
+///
+public sealed record ConnectionLogSecret : IVaultSecret
+{
+ /// What the host was called at the time, or a plain address when nothing named it.
+ ///
+ /// A copy rather than a lookup through , and that is the point of it: the bookmark
+ /// can be renamed or deleted, and a history that changed retroactively when somebody tidied their
+ /// keychain would be a history nobody could rely on.
+ ///
+ public required string HostLabel { get; init; }
+
+ /// The address as dialled, user@host:port style, or whatever was typed.
+ public required string Address { get; init; }
+
+ /// The host item this was, or null when the connection did not come from one.
+ public Guid? HostId { get; init; }
+
+ /// Whether this was a terminal or a file-transfer session.
+ public ConnectionKind Kind { get; init; }
+
+ /// When it started.
+ public required DateTimeOffset StartedAt { get; init; }
+
+ /// How long it lasted.
+ ///
+ /// A duration rather than an end time, because it is the thing anybody reads — and because the two clocks
+ /// involved are the same one, so storing both would be storing a value and its own arithmetic.
+ ///
+ public TimeSpan Duration { get; init; }
+
+ /// How it ended.
+ public ConnectionOutcome Outcome { get; init; }
+
+ /// Which machine it was made from, as that machine calls itself.
+ public required string DeviceName { get; init; }
+
+ /// Which account in this organisation made it.
+ public Guid ActorUserId { get; init; }
+
+ /// What this entry is called, derived from what it records.
+ ///
+ public string Label => string.Create(CultureInfo.InvariantCulture, $"{HostLabel} ({Address})");
+
+ /// Whether this is storable, and why not if it is not.
+ ///
+ /// A negative duration is refused rather than clamped. It can only come from a payload written elsewhere
+ /// — nothing here can produce one — and a log that displayed "-3 hours" would leave a reader unable to
+ /// tell a corrupt entry from a clock they should worry about.
+ ///
+ public bool TryValidate([NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(HostLabel))
+ {
+ reason = "A connection log entry needs the host it was about.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(Address))
+ {
+ reason = "A connection log entry needs the address that was dialled.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(DeviceName))
+ {
+ reason = "A connection log entry needs the machine it was made from.";
+ return false;
+ }
+
+ if (Duration < TimeSpan.Zero)
+ {
+ reason = "A connection cannot have lasted a negative amount of time.";
+ return false;
+ }
+
+ if (HostId == Guid.Empty)
+ {
+ reason = "A host reference cannot be an empty id; use no host instead.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/ConnectionLogSecretCodec.cs b/src/DodoSSH.Client.Domain/ConnectionLogSecretCodec.cs
new file mode 100644
index 0000000..2240e57
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ConnectionLogSecretCodec.cs
@@ -0,0 +1,153 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Domain;
+
+/// A decoded connection log payload, together with the schema version it was written at.
+/// The entry.
+/// The version the writing client used.
+public sealed record ConnectionLogSecretDocument(ConnectionLogSecret Entry, int SchemaVersion)
+{
+ ///
+ ///
+ /// Answered for consistency and never acted on: nothing edits a log entry, so there is no re-encode that
+ /// could drop a newer client's field. It stays because the reconciler asks every kind.
+ ///
+ public bool IsReadOnly => SchemaVersion > ConnectionLogSecretCodec.CurrentSchemaVersion;
+}
+
+///
+/// Encodes and decodes the plaintext inside a connection log entry's encrypted payload.
+///
+///
+///
+/// Mirrors . The two enums are written as numbers rather than names,
+/// unlike : they are closed sets this codec owns, where the item kind
+/// is an open one that a newer build may extend.
+///
+///
+/// An unknown enum value decodes to the default rather than failing the whole entry. A log written by a
+/// newer client that has learned a fourth outcome is still worth showing with its host, its times and its
+/// actor intact — refusing it would lose the entry to save the one field nobody could have acted on anyway.
+///
+///
+public static class ConnectionLogSecretCodec
+{
+ /// The schema version this build writes.
+ public const int CurrentSchemaVersion = 1;
+
+ /// Serialises an entry to the bytes that get sealed.
+ /// The entry is not valid for storage.
+ public static byte[] Encode(ConnectionLogSecret entry)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+
+ if (!entry.TryValidate(out var reason))
+ {
+ throw new ArgumentException(reason, nameof(entry));
+ }
+
+ var document = new ConnectionLogPayloadDocument
+ {
+ SchemaVersion = CurrentSchemaVersion,
+ HostLabel = entry.HostLabel,
+ Address = entry.Address,
+ HostId = entry.HostId,
+ Kind = (int)entry.Kind,
+ StartedAt = entry.StartedAt,
+ DurationMs = (long)entry.Duration.TotalMilliseconds,
+ Outcome = (int)entry.Outcome,
+ DeviceName = entry.DeviceName,
+ ActorUserId = entry.ActorUserId,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(
+ document, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
+ }
+
+ /// Parses a decrypted payload.
+ ///
+ public static bool TryDecode(
+ ReadOnlySpan payload,
+ [NotNullWhen(true)] out ConnectionLogSecretDocument? document)
+ {
+ document = null;
+
+ ConnectionLogPayloadDocument? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(
+ payload, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (parsed is null || parsed.SchemaVersion < 1)
+ {
+ return false;
+ }
+
+ var candidate = new ConnectionLogSecret
+ {
+ HostLabel = parsed.HostLabel ?? string.Empty,
+ Address = parsed.Address ?? string.Empty,
+ HostId = parsed.HostId,
+ Kind = Enum.IsDefined((ConnectionKind)parsed.Kind) ? (ConnectionKind)parsed.Kind : default,
+ StartedAt = parsed.StartedAt,
+ Duration = TimeSpan.FromMilliseconds(parsed.DurationMs),
+ Outcome = Enum.IsDefined((ConnectionOutcome)parsed.Outcome)
+ ? (ConnectionOutcome)parsed.Outcome
+ : default,
+ DeviceName = parsed.DeviceName ?? string.Empty,
+ ActorUserId = parsed.ActorUserId,
+ };
+
+ if (!candidate.TryValidate(out _))
+ {
+ return false;
+ }
+
+ document = new ConnectionLogSecretDocument(candidate, parsed.SchemaVersion);
+ return true;
+ }
+}
+
+/// The serialised shape. Mutable and nullable because it models untrusted input.
+///
+internal sealed class ConnectionLogPayloadDocument
+{
+ public int SchemaVersion { get; set; }
+
+ public string? HostLabel { get; set; }
+
+ public string? Address { get; set; }
+
+ public Guid? HostId { get; set; }
+
+ public int Kind { get; set; }
+
+ public DateTimeOffset StartedAt { get; set; }
+
+ ///
+ /// Milliseconds as an integer rather than a , which System.Text.Json writes
+ /// as "00:11:03.4560000" — a format whose parsing varies between platforms and whose precision
+ /// invites a round-trip that is nearly but not exactly the value written.
+ ///
+ public long DurationMs { get; set; }
+
+ public int Outcome { get; set; }
+
+ public string? DeviceName { get; set; }
+
+ public Guid ActorUserId { get; set; }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(ConnectionLogPayloadDocument))]
+internal sealed partial class ConnectionLogPayloadJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Domain/HostGroupSecret.cs b/src/DodoSSH.Client.Domain/HostGroupSecret.cs
new file mode 100644
index 0000000..0dff237
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostGroupSecret.cs
@@ -0,0 +1,48 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// A folder hosts can be filed under, decrypted.
+///
+///
+///
+/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
+/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
+/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
+///
+///
+/// No member list. Membership is a on each host, so filing two
+/// different hosts into one group on two machines is two writes to two items. Held here it would be two
+/// writes to one item, and has no set merge — the collision would resolve by one
+/// side winning outright and the other host silently leaving the group it was just put in.
+///
+///
+/// No parent. Groups are flat. Two clients can each re-parent A under B and B under A while offline,
+/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
+/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
+///
+///
+public sealed record HostGroupSecret : IVaultSecret
+{
+ /// What the group is called. The only name it has anywhere.
+ public required string Label { get; init; }
+
+ /// Whether this is storable, and why not if it is not.
+ ///
+ /// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
+ /// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
+ /// cannot tell apart.
+ ///
+ public bool TryValidate([NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(Label))
+ {
+ reason = "A group needs a name.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs b/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs
new file mode 100644
index 0000000..a23f253
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs
@@ -0,0 +1,101 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Domain;
+
+/// A decoded group payload, together with the schema version it was written at.
+/// The group.
+/// The version the writing client used.
+public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVersion)
+{
+ ///
+ public bool IsReadOnly => SchemaVersion > HostGroupSecretCodec.CurrentSchemaVersion;
+}
+
+///
+/// Encodes and decodes the plaintext inside a group item's encrypted payload.
+///
+///
+/// Mirrors , for the same reasons and with the same guarantees. One field
+/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
+/// version, which is what lets a later build add a field without every older client silently dropping it on
+/// the next edit. See .
+///
+public static class HostGroupSecretCodec
+{
+ /// The schema version this build writes.
+ public const int CurrentSchemaVersion = 1;
+
+ /// Serialises a group to the bytes that get sealed.
+ /// The group is not valid for storage.
+ public static byte[] Encode(HostGroupSecret group)
+ {
+ ArgumentNullException.ThrowIfNull(group);
+
+ if (!group.TryValidate(out var reason))
+ {
+ throw new ArgumentException(reason, nameof(group));
+ }
+
+ var document = new HostGroupPayloadDocument
+ {
+ SchemaVersion = CurrentSchemaVersion,
+ Label = group.Label,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(
+ document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
+ }
+
+ /// Parses a decrypted payload.
+ ///
+ public static bool TryDecode(
+ ReadOnlySpan payload,
+ [NotNullWhen(true)] out HostGroupSecretDocument? document)
+ {
+ document = null;
+
+ HostGroupPayloadDocument? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(
+ payload, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (parsed is null || parsed.SchemaVersion < 1)
+ {
+ return false;
+ }
+
+ var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty };
+
+ if (!candidate.TryValidate(out _))
+ {
+ return false;
+ }
+
+ document = new HostGroupSecretDocument(candidate, parsed.SchemaVersion);
+ return true;
+ }
+}
+
+/// The serialised shape. Mutable and nullable because it models untrusted input.
+///
+internal sealed class HostGroupPayloadDocument
+{
+ public int SchemaVersion { get; set; }
+
+ public string? Label { get; set; }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(HostGroupPayloadDocument))]
+internal sealed partial class HostGroupPayloadJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs b/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs
new file mode 100644
index 0000000..08a57be
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs
@@ -0,0 +1,63 @@
+namespace DodoSSH.Client.Domain;
+
+/// The merged group, and everything that had to be overridden to produce it.
+/// The group to store and push.
+/// Empty when the two sides were reconcilable field by field.
+public sealed record HostGroupMergeResult(
+ HostGroupSecret Merged,
+ IReadOnlyList Conflicts)
+{
+ /// Whether anything had to be overridden.
+ public bool HasConflicts => Conflicts.Count > 0;
+}
+
+///
+/// Merges two divergent versions of a group against the version they both started from.
+///
+///
+///
+/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it
+/// does not have to consider. Filing a host into a group does not write to the group, so two people
+/// organising the same vault at the same time never collide here — the only way to reach this code is for two
+/// people to rename the same group differently, which is a real disagreement and gets a conflict notice.
+///
+///
+/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name
+/// differed" would leave the user unable to tell which of their two names survived.
+///
+///
+public static class HostGroupSecretMerge
+{
+ /// Produces the merged group.
+ /// The version both sides branched from.
+ /// The pending local version.
+ /// The server's current version.
+ public static HostGroupMergeResult Merge(
+ HostGroupSecret ancestor,
+ HostGroupSecret local,
+ HostGroupSecret remote)
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+
+ var conflicts = new List();
+
+ var merge = ThreeWayMerge.Scalar(
+ ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
+
+ if (merge.IsConflicted)
+ {
+ // The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
+ // fixed here rather than derived from the outcome.
+ conflicts.Add(new HostFieldConflict(
+ nameof(HostGroupSecret.Label),
+ MergeSide.Local,
+ merge.Value,
+ merge.Discarded,
+ DiscardedWasRemoval: false));
+ }
+
+ return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts);
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/HostSecret.cs b/src/DodoSSH.Client.Domain/HostSecret.cs
index d8becd1..1d4c620 100644
--- a/src/DodoSSH.Client.Domain/HostSecret.cs
+++ b/src/DodoSSH.Client.Domain/HostSecret.cs
@@ -101,6 +101,32 @@ public sealed record HostSecret : IVaultSecret
///
public Guid? CredentialId { get; init; }
+ ///
+ /// The group this host is filed under, or null for none.
+ ///
+ ///
+ ///
+ /// The pointer lives on the host rather than a member list living on the group, and the reason is the
+ /// merge: filing two different hosts into one group on two machines has to be two writes to two items.
+ /// Held the other way round it would be two writes to one item, and with no set merge available the
+ /// collision would resolve by one side winning and the other host quietly leaving the group.
+ ///
+ ///
+ /// Inside the payload, and it did not have to be. SyncPlaintextFields has carried a
+ /// GroupId since the contract was frozen and the server had a column for it. Nothing ever wrote
+ /// one, the column is gone, and the server now refuses the field — because what it would hand over is a
+ /// clustering of the estate, and the one plaintext concession the design allows itself is the relay
+ /// address, which the relay genuinely cannot work without. This is not that. See ADR 0004.
+ ///
+ ///
+ /// The reference may dangle , exactly as may: a group deleted on another
+ /// machine leaves this pointing at nothing. That is handled where it is noticed — the host appears under
+ /// the ungrouped heading — rather than prevented here, because preventing it would mean one group delete
+ /// rewriting every host that named it.
+ ///
+ ///
+ public Guid? GroupId { get; init; }
+
///
/// Whether this host may be dialled through the server relay.
///
@@ -174,6 +200,12 @@ public sealed record HostSecret : IVaultSecret
return false;
}
+ if (GroupId == Guid.Empty)
+ {
+ reason = "A group reference cannot be an empty id; use no group instead.";
+ return false;
+ }
+
reason = null;
return true;
}
diff --git a/src/DodoSSH.Client.Domain/HostSecretCodec.cs b/src/DodoSSH.Client.Domain/HostSecretCodec.cs
index 0fc9627..d39b730 100644
--- a/src/DodoSSH.Client.Domain/HostSecretCodec.cs
+++ b/src/DodoSSH.Client.Domain/HostSecretCodec.cs
@@ -62,8 +62,11 @@ public static class HostSecretCodec
/// The version that introduced .
public const int CredentialIdSchemaVersion = 3;
+ /// The version that introduced .
+ public const int GroupIdSchemaVersion = 4;
+
/// The highest schema version this build can write.
- public const int CurrentSchemaVersion = CredentialIdSchemaVersion;
+ public const int CurrentSchemaVersion = GroupIdSchemaVersion;
/// Serialises a host to the bytes that get sealed.
/// The host is not valid for storage.
@@ -95,6 +98,7 @@ public static class HostSecretCodec
RelayEnabled = host.RelayEnabled,
SshKeyId = host.SshKeyId,
CredentialId = host.CredentialId,
+ GroupId = host.GroupId,
};
return JsonSerializer.SerializeToUtf8Bytes(
@@ -120,18 +124,40 @@ public static class HostSecretCodec
/// did not make every host in every vault look like a change to the sync engine.
///
///
- /// The two bindings are mutually exclusive — see — so this reads as
- /// a ladder rather than a maximum. If a future field is not exclusive with an older one, this
- /// becomes the maximum over the versions of the fields present, which is the same rule stated more
- /// generally.
+ /// A maximum, not a ladder, and the difference arrived with . The
+ /// two authentication bindings are mutually exclusive — see — so
+ /// while they were the only versioned fields, a switch that returned the first match was
+ /// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
+ /// a credential and a group, and the ladder would have answered 3 for it, writing a version that
+ /// cannot represent the group it just wrote. An older client would then decode that host as editable and
+ /// drop the field on the next save.
+ ///
+ ///
+ /// Written as a maximum over the fields actually present, which is the general form of the same rule and
+ /// stays correct however the next field relates to these.
///
///
- private static int SchemaVersionFor(HostSecret host) => host switch
+ private static int SchemaVersionFor(HostSecret host)
{
- { CredentialId: not null } => CredentialIdSchemaVersion,
- { SshKeyId: not null } => SshKeyIdSchemaVersion,
- _ => BaseSchemaVersion,
- };
+ var version = BaseSchemaVersion;
+
+ if (host.SshKeyId is not null)
+ {
+ version = Math.Max(version, SshKeyIdSchemaVersion);
+ }
+
+ if (host.CredentialId is not null)
+ {
+ version = Math.Max(version, CredentialIdSchemaVersion);
+ }
+
+ if (host.GroupId is not null)
+ {
+ version = Math.Max(version, GroupIdSchemaVersion);
+ }
+
+ return version;
+ }
///
/// Parses a decrypted payload.
@@ -199,6 +225,7 @@ public static class HostSecretCodec
RelayEnabled = parsed.RelayEnabled,
SshKeyId = parsed.SshKeyId,
CredentialId = parsed.CredentialId,
+ GroupId = parsed.GroupId,
};
if (!candidate.TryValidate(out _))
@@ -254,6 +281,9 @@ internal sealed class HostPayloadDocument
///
public Guid? CredentialId { get; set; }
+
+ ///
+ public Guid? GroupId { get; set; }
}
[JsonSourceGenerationOptions(
diff --git a/src/DodoSSH.Client.Domain/HostSecretMerge.cs b/src/DodoSSH.Client.Domain/HostSecretMerge.cs
index b689a44..48f6780 100644
--- a/src/DodoSSH.Client.Domain/HostSecretMerge.cs
+++ b/src/DodoSSH.Client.Domain/HostSecretMerge.cs
@@ -103,10 +103,35 @@ public static class HostSecretMerge
remote.RelayEnabled,
conflicts,
static enabled => enabled ? "enabled" : "disabled"),
+ };
- // The id is shown in a clash rather than redacted. It is not a secret — it names a vault item,
- // it is not the key — and hiding it would leave the user unable to tell which of two keys the
- // merge dropped.
+ return new HostMergeResult(
+ WithReferences(merged, ancestor, local, remote, conflicts), conflicts);
+ }
+
+ ///
+ /// Merges the three ids a host can point at: its key, its credential and its group.
+ ///
+ ///
+ ///
+ /// Split out for length, and they do belong together: each is a reference to another vault item, each
+ /// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on
+ /// another machine. None of that is the merge's problem — it is handled where the reference is used.
+ ///
+ ///
+ /// The ids are shown in a clash rather than redacted. An id is not a secret — it names a vault
+ /// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the
+ /// merge dropped.
+ ///
+ ///
+ private static HostSecret WithReferences(
+ HostSecret merged,
+ HostSecret ancestor,
+ HostSecret local,
+ HostSecret remote,
+ List conflicts) =>
+ merged with
+ {
SshKeyId = Field(
nameof(HostSecret.SshKeyId),
ancestor.SshKeyId,
@@ -122,10 +147,15 @@ public static class HostSecretMerge
remote.CredentialId,
conflicts,
static id => id?.ToString() ?? "no credential"),
- };
- return new HostMergeResult(merged, conflicts);
- }
+ GroupId = Field(
+ nameof(HostSecret.GroupId),
+ ancestor.GroupId,
+ local.GroupId,
+ remote.GroupId,
+ conflicts,
+ static id => id?.ToString() ?? "ungrouped"),
+ };
private static string Text(
string name,
diff --git a/src/DodoSSH.Client.Domain/LogSecretMerge.cs b/src/DodoSSH.Client.Domain/LogSecretMerge.cs
new file mode 100644
index 0000000..753b118
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/LogSecretMerge.cs
@@ -0,0 +1,112 @@
+namespace DodoSSH.Client.Domain;
+
+/// The merged entry, and everything that had to be overridden to produce it.
+/// The entry to store and push.
+/// Empty when the two sides were reconcilable field by field.
+public sealed record ConnectionLogMergeResult(
+ ConnectionLogSecret Merged,
+ IReadOnlyList Conflicts)
+{
+ /// Whether anything had to be overridden.
+ public bool HasConflicts => Conflicts.Count > 0;
+}
+
+///
+public sealed record ActivityLogMergeResult(
+ ActivityLogSecret Merged,
+ IReadOnlyList Conflicts)
+{
+ /// Whether anything had to be overridden.
+ public bool HasConflicts => Conflicts.Count > 0;
+}
+
+///
+/// Merges two divergent versions of a connection log entry.
+///
+///
+///
+/// This exists because the item-kind pipeline requires it, and it should never run. A log entry is
+/// written once, at the moment a connection closes, and nothing updates one — so there is no second version
+/// for a first to diverge from. Reaching this code means two clients wrote different records under one
+/// entity id, and entity ids are v7 GUIDs minted independently on each machine.
+///
+///
+/// It is still a real merge rather than a throw. The reconciler runs inside a sync pass, and an exception
+/// there would strand every item queued behind this one — for a situation that is a bug in some client and
+/// not an emergency. So the remote side wins, the difference is recorded like any other, and somebody reads
+/// a conflict notice about a log entry, which is the loudest signal this could reasonably give.
+///
+///
+/// Nothing is redacted. Every field is already an audit record of something that happened, and a notice that
+/// hid which of two records was dropped would defeat the point of noticing.
+///
+///
+public static class ConnectionLogSecretMerge
+{
+ /// Produces the merged entry.
+ /// The version both sides branched from.
+ /// The pending local version.
+ /// The server's current version.
+ public static ConnectionLogMergeResult Merge(
+ ConnectionLogSecret ancestor,
+ ConnectionLogSecret local,
+ ConnectionLogSecret remote)
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+
+ // Whole-value, not field by field. The fields of one entry describe one event, and a merge that took
+ // the host from one side and the duration from the other would invent a connection nobody made —
+ // which is a worse outcome than losing the record this machine happened to hold.
+ if (local == remote)
+ {
+ return new ConnectionLogMergeResult(remote, []);
+ }
+
+ return new ConnectionLogMergeResult(
+ remote,
+ [
+ new HostFieldConflict(
+ "Entry",
+ MergeSide.Local,
+ remote.Label,
+ local.Label,
+ DiscardedWasRemoval: false),
+ ]);
+ }
+}
+
+///
+/// Merges two divergent versions of an activity log entry.
+///
+///
+public static class ActivityLogSecretMerge
+{
+ ///
+ public static ActivityLogMergeResult Merge(
+ ActivityLogSecret ancestor,
+ ActivityLogSecret local,
+ ActivityLogSecret remote)
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+
+ if (local == remote)
+ {
+ return new ActivityLogMergeResult(remote, []);
+ }
+
+ return new ActivityLogMergeResult(
+ remote,
+ [
+ new HostFieldConflict(
+ "Entry",
+ MergeSide.Local,
+ remote.Label,
+ local.Label,
+ DiscardedWasRemoval: false),
+ ]);
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/ObjectStoreSecret.cs b/src/DodoSSH.Client.Domain/ObjectStoreSecret.cs
new file mode 100644
index 0000000..e70454f
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ObjectStoreSecret.cs
@@ -0,0 +1,120 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// An S3-compatible bucket and the credentials that reach it, decrypted.
+///
+///
+///
+/// Named for the protocol rather than for Amazon, because everything here works the same against MinIO, R2,
+/// Backblaze or Ceph — and for those, is an address on somebody's own network. The
+/// interface says S3, which is what people call the protocol; the type says what it is.
+///
+///
+/// is a password, and everything this codebase does about passwords applies
+/// to it. It is inside the encrypted payload, it never appears in a log line — the activity log records
+/// the field's name and not its value — and the merge reports that it differed rather than what it was.
+///
+///
+public sealed record ObjectStoreSecret : IVaultSecret
+{
+ /// What the user calls this bucket. The only name it has anywhere.
+ public required string Label { get; init; }
+
+ /// The bucket.
+ public required string Bucket { get; init; }
+
+ /// The access key id.
+ public required string AccessKeyId { get; init; }
+
+ /// The secret access key.
+ public required string SecretAccessKey { get; init; }
+
+ ///
+ /// The region, or null to let the endpoint decide.
+ ///
+ ///
+ /// Required by AWS and ignored by several S3-compatible services, which is why it is nullable rather than
+ /// defaulted to us-east-1 . A default would be a guess presented as configuration, and the guess is
+ /// wrong for exactly the self-hosted case this field exists to support.
+ ///
+ public string? Region { get; init; }
+
+ ///
+ /// The service endpoint, or null for Amazon's own.
+ ///
+ ///
+ /// Null means AWS and the SDK resolves the host from . Anything else is a URL, and it
+ /// is the field that makes this work against a MinIO in a cupboard.
+ ///
+ public string? Endpoint { get; init; }
+
+ ///
+ /// Whether to address the bucket as a path rather than as a subdomain.
+ ///
+ ///
+ /// https://endpoint/bucket/key instead of https://bucket.endpoint/key . Off for AWS, on for
+ /// nearly every self-hosted service — MinIO in its default configuration has no wildcard DNS, so
+ /// virtual-host addressing simply does not resolve. It is a setting rather than a guess because getting
+ /// it wrong produces a name-resolution failure that says nothing about buckets.
+ ///
+ public bool UsePathStyle { get; init; }
+
+ /// Free-text notes.
+ public string? Notes { get; init; }
+
+ /// Whether this is storable, and why not if it is not.
+ ///
+ /// The endpoint is checked for being a well-formed absolute URL when it is set at all. A relative one, or
+ /// a bare hostname, produces an SDK failure at the first request whose message names neither the field
+ /// nor this bucket — and the person reading it has typically just typed the value.
+ ///
+ public bool TryValidate([NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(Label))
+ {
+ reason = "A bucket needs a name.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(Bucket))
+ {
+ reason = "A bucket needs the bucket it points at.";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(AccessKeyId) || string.IsNullOrWhiteSpace(SecretAccessKey))
+ {
+ reason = "A bucket needs an access key id and a secret access key.";
+ return false;
+ }
+
+ if (Endpoint is not null)
+ {
+ if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpoint))
+ {
+ reason = "The endpoint has to be a full URL, like https://minio.internal:9000.";
+ return false;
+ }
+
+ if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
+ && !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
+ {
+ reason = "The endpoint has to be http or https.";
+ return false;
+ }
+ }
+
+ if (Region is null && Endpoint is null)
+ {
+ // With neither, the SDK has nothing to resolve a host from and fails at the first request with
+ // a message about a missing region rather than about this bucket.
+ reason = "A bucket needs a region, an endpoint, or both.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/ObjectStoreSecretCodec.cs b/src/DodoSSH.Client.Domain/ObjectStoreSecretCodec.cs
new file mode 100644
index 0000000..0b89534
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ObjectStoreSecretCodec.cs
@@ -0,0 +1,129 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Domain;
+
+/// A decoded bucket payload, together with the schema version it was written at.
+/// The bucket.
+/// The version the writing client used.
+public sealed record ObjectStoreSecretDocument(ObjectStoreSecret Store, int SchemaVersion)
+{
+ ///
+ public bool IsReadOnly => SchemaVersion > ObjectStoreSecretCodec.CurrentSchemaVersion;
+}
+
+///
+/// Encodes and decodes the plaintext inside a bucket item's encrypted payload.
+///
+///
+/// Mirrors , for the same reasons and with the same guarantees.
+///
+public static class ObjectStoreSecretCodec
+{
+ /// The schema version this build writes.
+ public const int CurrentSchemaVersion = 1;
+
+ /// Serialises a bucket to the bytes that get sealed.
+ /// The bucket is not valid for storage.
+ public static byte[] Encode(ObjectStoreSecret store)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+
+ if (!store.TryValidate(out var reason))
+ {
+ throw new ArgumentException(reason, nameof(store));
+ }
+
+ var document = new ObjectStorePayloadDocument
+ {
+ SchemaVersion = CurrentSchemaVersion,
+ Label = store.Label,
+ Bucket = store.Bucket,
+ AccessKeyId = store.AccessKeyId,
+ SecretAccessKey = store.SecretAccessKey,
+ Region = store.Region,
+ Endpoint = store.Endpoint,
+ UsePathStyle = store.UsePathStyle,
+ Notes = store.Notes,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(
+ document, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
+ }
+
+ /// Parses a decrypted payload.
+ ///
+ public static bool TryDecode(
+ ReadOnlySpan payload,
+ [NotNullWhen(true)] out ObjectStoreSecretDocument? document)
+ {
+ document = null;
+
+ ObjectStorePayloadDocument? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(
+ payload, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (parsed is null || parsed.SchemaVersion < 1)
+ {
+ return false;
+ }
+
+ var candidate = new ObjectStoreSecret
+ {
+ Label = parsed.Label ?? string.Empty,
+ Bucket = parsed.Bucket ?? string.Empty,
+ AccessKeyId = parsed.AccessKeyId ?? string.Empty,
+ SecretAccessKey = parsed.SecretAccessKey ?? string.Empty,
+ Region = parsed.Region,
+ Endpoint = parsed.Endpoint,
+ UsePathStyle = parsed.UsePathStyle,
+ Notes = parsed.Notes,
+ };
+
+ if (!candidate.TryValidate(out _))
+ {
+ return false;
+ }
+
+ document = new ObjectStoreSecretDocument(candidate, parsed.SchemaVersion);
+ return true;
+ }
+}
+
+/// The serialised shape. Mutable and nullable because it models untrusted input.
+///
+internal sealed class ObjectStorePayloadDocument
+{
+ public int SchemaVersion { get; set; }
+
+ public string? Label { get; set; }
+
+ public string? Bucket { get; set; }
+
+ public string? AccessKeyId { get; set; }
+
+ public string? SecretAccessKey { get; set; }
+
+ public string? Region { get; set; }
+
+ public string? Endpoint { get; set; }
+
+ public bool UsePathStyle { get; set; }
+
+ public string? Notes { get; set; }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(ObjectStorePayloadDocument))]
+internal sealed partial class ObjectStorePayloadJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Domain/ObjectStoreSecretMerge.cs b/src/DodoSSH.Client.Domain/ObjectStoreSecretMerge.cs
new file mode 100644
index 0000000..636e9db
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/ObjectStoreSecretMerge.cs
@@ -0,0 +1,107 @@
+namespace DodoSSH.Client.Domain;
+
+/// The merged bucket, and everything that had to be overridden to produce it.
+/// The bucket to store and push.
+/// Empty when the two sides were reconcilable field by field.
+public sealed record ObjectStoreMergeResult(
+ ObjectStoreSecret Merged,
+ IReadOnlyList Conflicts)
+{
+ /// Whether anything had to be overridden.
+ public bool HasConflicts => Conflicts.Count > 0;
+}
+
+///
+/// Merges two divergent versions of a bucket against the version they both started from.
+///
+///
+///
+/// Every field is a scalar, so this is 's shape and it reuses
+/// for the same reason.
+///
+///
+/// The secret access key never reaches the conflict log , exactly as a password does not: a discarded
+/// one is very often still live on the service it belongs to. The access key id is shown, because it
+/// is an identifier rather than a secret and knowing which of two key pairs the merge dropped is the whole
+/// content of the notice.
+///
+///
+public static class ObjectStoreSecretMerge
+{
+ /// Produces the merged bucket.
+ /// The version both sides branched from.
+ /// The pending local version.
+ /// The server's current version.
+ public static ObjectStoreMergeResult Merge(
+ ObjectStoreSecret ancestor,
+ ObjectStoreSecret local,
+ ObjectStoreSecret remote)
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+
+ var conflicts = new List();
+
+ var merged = new ObjectStoreSecret
+ {
+ // Null-forgiving on the required fields, as the neighbouring merges do: the merge returns one of
+ // its three inputs, and all three are non-null by construction.
+ Label = Resolve(
+ nameof(ObjectStoreSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
+ Bucket = Resolve(
+ nameof(ObjectStoreSecret.Bucket), ancestor.Bucket, local.Bucket, remote.Bucket, conflicts)!,
+ AccessKeyId = Resolve(
+ nameof(ObjectStoreSecret.AccessKeyId),
+ ancestor.AccessKeyId,
+ local.AccessKeyId,
+ remote.AccessKeyId,
+ conflicts)!,
+ SecretAccessKey = Resolve(
+ nameof(ObjectStoreSecret.SecretAccessKey),
+ ancestor.SecretAccessKey,
+ local.SecretAccessKey,
+ remote.SecretAccessKey,
+ conflicts,
+ redact: true)!,
+ Region = Resolve(
+ nameof(ObjectStoreSecret.Region), ancestor.Region, local.Region, remote.Region, conflicts),
+ Endpoint = Resolve(
+ nameof(ObjectStoreSecret.Endpoint),
+ ancestor.Endpoint,
+ local.Endpoint,
+ remote.Endpoint,
+ conflicts),
+ UsePathStyle = ThreeWayMerge
+ .Scalar(ancestor.UsePathStyle, local.UsePathStyle, remote.UsePathStyle)
+ .Value,
+ Notes = Resolve(
+ nameof(ObjectStoreSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
+ };
+
+ return new ObjectStoreMergeResult(merged, conflicts);
+ }
+
+ private static string? Resolve(
+ string name,
+ string? ancestor,
+ string? local,
+ string? remote,
+ List conflicts,
+ bool redact = false)
+ {
+ var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
+
+ if (merge.IsConflicted)
+ {
+ conflicts.Add(new HostFieldConflict(
+ name,
+ MergeSide.Local,
+ redact ? "(kept the server's value)" : merge.Value ?? "(none)",
+ redact ? "(a different value was discarded)" : merge.Discarded ?? "(none)",
+ DiscardedWasRemoval: false));
+ }
+
+ return merge.Value;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/SnippetSecret.cs b/src/DodoSSH.Client.Domain/SnippetSecret.cs
new file mode 100644
index 0000000..c2b5969
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/SnippetSecret.cs
@@ -0,0 +1,76 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// A saved command, decrypted.
+///
+///
+///
+/// is the field this type exists to get right. A terminal is one input
+/// stream with no notion of "at a prompt": the remote may be inside vi , or at a sudo password
+/// prompt with echo off, and without shell integration the client cannot tell. So inserting a snippet is
+/// always "type this into whatever is there", never "run this command" — and whether a newline follows the
+/// text is the difference between the user reading what appeared and deciding, and something happening.
+/// It defaults to , which makes that decision the user's Enter key.
+///
+///
+/// is stored verbatim. No trimming, no newline normalisation — the same rule
+/// follows, for a related reason: a heredoc's trailing newline is
+/// load-bearing, and a shell that receives a here-document terminator with the whitespace tidied off it hangs
+/// waiting for one that never comes.
+///
+///
+/// Deliberately not in this version, each with a reason rather than an omission: host scoping , which
+/// needs a set merge that does not have; tags , which are their own reserved
+/// item kind; and parameter substitution , which would make this a template language expanding into a
+/// root shell — a second security surface for a feature whose first one is already the hard part.
+///
+///
+public sealed record SnippetSecret : IVaultSecret
+{
+ /// What the snippet is called. The only name it has anywhere.
+ public required string Label { get; init; }
+
+ /// The text to insert. May be several lines.
+ public required string Command { get; init; }
+
+ /// Free-text notes.
+ public string? Notes { get; init; }
+
+ ///
+ /// Whether inserting this also presses Enter.
+ ///
+ ///
+ /// Off unless the user turns it on, per snippet. A vault-wide preference was the alternative and it is
+ /// worse: the setting belongs to the command, because ls -la and rm -rf /var/lib/postgresql
+ /// do not want the same answer, and a single switch would eventually be left on by whoever needed it for
+ /// the first of those.
+ ///
+ public bool RunsOnInsert { get; init; }
+
+ /// Whether this is storable, and why not if it is not.
+ ///
+ /// is checked for being blank but for nothing else. What makes a valid command is
+ /// the remote shell's business, this client does not know which shell that is, and a validator guessing
+ /// at it would refuse the legitimate cases — a bare \x03 , a partial line meant to be completed by
+ /// hand — while catching nothing that matters.
+ ///
+ public bool TryValidate([NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(Label))
+ {
+ reason = "A snippet needs a name.";
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(Command))
+ {
+ reason = "A snippet needs something to insert.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/SnippetSecretCodec.cs b/src/DodoSSH.Client.Domain/SnippetSecretCodec.cs
new file mode 100644
index 0000000..849a5d0
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/SnippetSecretCodec.cs
@@ -0,0 +1,121 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Domain;
+
+/// A decoded snippet payload, together with the schema version it was written at.
+/// The snippet.
+/// The version the writing client used.
+public sealed record SnippetSecretDocument(SnippetSecret Snippet, int SchemaVersion)
+{
+ ///
+ public bool IsReadOnly => SchemaVersion > SnippetSecretCodec.CurrentSchemaVersion;
+}
+
+///
+/// Encodes and decodes the plaintext inside a snippet item's encrypted payload.
+///
+///
+/// Mirrors . The one thing to be careful about here is
+/// : it is a , so a payload that omits it decodes
+/// as — which is the safe direction, and deliberately the one a malformed or
+/// truncated write falls in.
+///
+public static class SnippetSecretCodec
+{
+ /// The schema version this build writes.
+ public const int CurrentSchemaVersion = 1;
+
+ /// Serialises a snippet to the bytes that get sealed.
+ /// The snippet is not valid for storage.
+ public static byte[] Encode(SnippetSecret snippet)
+ {
+ ArgumentNullException.ThrowIfNull(snippet);
+
+ if (!snippet.TryValidate(out var reason))
+ {
+ throw new ArgumentException(reason, nameof(snippet));
+ }
+
+ var document = new SnippetPayloadDocument
+ {
+ SchemaVersion = CurrentSchemaVersion,
+ Label = snippet.Label,
+ Command = snippet.Command,
+ Notes = snippet.Notes,
+ RunsOnInsert = snippet.RunsOnInsert,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(
+ document, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
+ }
+
+ /// Parses a decrypted payload.
+ ///
+ public static bool TryDecode(
+ ReadOnlySpan payload,
+ [NotNullWhen(true)] out SnippetSecretDocument? document)
+ {
+ document = null;
+
+ SnippetPayloadDocument? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(
+ payload, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (parsed is null || parsed.SchemaVersion < 1)
+ {
+ return false;
+ }
+
+ var candidate = new SnippetSecret
+ {
+ Label = parsed.Label ?? string.Empty,
+ Command = parsed.Command ?? string.Empty,
+ Notes = parsed.Notes,
+ RunsOnInsert = parsed.RunsOnInsert,
+ };
+
+ if (!candidate.TryValidate(out _))
+ {
+ return false;
+ }
+
+ document = new SnippetSecretDocument(candidate, parsed.SchemaVersion);
+ return true;
+ }
+}
+
+/// The serialised shape. Mutable and nullable because it models untrusted input.
+///
+internal sealed class SnippetPayloadDocument
+{
+ public int SchemaVersion { get; set; }
+
+ public string? Label { get; set; }
+
+ public string? Command { get; set; }
+
+ public string? Notes { get; set; }
+
+ ///
+ /// Not nullable, so its absence is rather than a third state. The field decides
+ /// whether inserting a snippet also presses Enter, and "we could not tell" has to resolve to the answer
+ /// that does nothing.
+ ///
+ public bool RunsOnInsert { get; set; }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(SnippetPayloadDocument))]
+internal sealed partial class SnippetPayloadJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Domain/SnippetSecretMerge.cs b/src/DodoSSH.Client.Domain/SnippetSecretMerge.cs
new file mode 100644
index 0000000..69c4885
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/SnippetSecretMerge.cs
@@ -0,0 +1,94 @@
+namespace DodoSSH.Client.Domain;
+
+/// The merged snippet, and everything that had to be overridden to produce it.
+/// The snippet to store and push.
+/// Empty when the two sides were reconcilable field by field.
+public sealed record SnippetMergeResult(
+ SnippetSecret Merged,
+ IReadOnlyList Conflicts)
+{
+ /// Whether anything had to be overridden.
+ public bool HasConflicts => Conflicts.Count > 0;
+}
+
+///
+/// Merges two divergent versions of a snippet against the version they both started from.
+///
+///
+///
+/// Three strings and a flag, so the shape is 's and it reuses
+/// for the same reason. Nothing is redacted: a snippet is a command somebody
+/// wrote down on purpose, and a notice that hid the discarded version would leave the user unable to tell
+/// whether the one that survived is the one they wanted to keep.
+///
+///
+/// cannot conflict, and it is worth knowing why rather than
+/// assuming it. A three-way clash needs local and remote each to differ from the ancestor and
+/// from one another; with only two possible values, the first two conditions force the third to fail. So this
+/// field always resolves to whichever side actually changed it, and a merge can never turn a snippet into one
+/// that runs on its own — the outcome the ordinary rule would have made possible if the field had a third
+/// state. An earlier draft special-cased it to resolve to on a clash; the branch was
+/// unreachable, and unreachable safety code is worse than none, because it reads as protection.
+///
+///
+public static class SnippetSecretMerge
+{
+ /// Produces the merged snippet.
+ /// The version both sides branched from.
+ /// The pending local version.
+ /// The server's current version.
+ public static SnippetMergeResult Merge(
+ SnippetSecret ancestor,
+ SnippetSecret local,
+ SnippetSecret remote)
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+
+ var conflicts = new List();
+
+ var merged = new SnippetSecret
+ {
+ // Null-forgiving on the two required fields, as the neighbouring merges do for the same reason:
+ // the merge returns one of its three inputs, and all three are non-null by construction.
+ Label = Text(
+ nameof(SnippetSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
+ Command = Text(
+ nameof(SnippetSecret.Command),
+ ancestor.Command,
+ local.Command,
+ remote.Command,
+ conflicts)!,
+ Notes = Text(
+ nameof(SnippetSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
+ RunsOnInsert = ThreeWayMerge
+ .Scalar(ancestor.RunsOnInsert, local.RunsOnInsert, remote.RunsOnInsert)
+ .Value,
+ };
+
+ return new SnippetMergeResult(merged, conflicts);
+ }
+
+ private static string? Text(
+ string name,
+ string? ancestor,
+ string? local,
+ string? remote,
+ List conflicts)
+ {
+ var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
+
+ if (merge.IsConflicted)
+ {
+ conflicts.Add(new HostFieldConflict(
+ name,
+ MergeSide.Local,
+ merge.Value ?? "(none)",
+ merge.Discarded ?? "(none)",
+ DiscardedWasRemoval: false));
+ }
+
+ return merge.Value;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/Uuid7Timestamp.cs b/src/DodoSSH.Client.Domain/Uuid7Timestamp.cs
new file mode 100644
index 0000000..0fb25c4
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/Uuid7Timestamp.cs
@@ -0,0 +1,57 @@
+namespace DodoSSH.Client.Domain;
+
+///
+/// Reads the moment a version 7 identifier was created back out of it.
+///
+///
+///
+/// Why this exists. No vault item carries a timestamp. VaultItem is an id, a secret, a version
+/// and three sync flags, and the server's created_at is deliberately not handed back — so a screen
+/// that wants to say when something was added has nothing to read. Every id this client mints goes through
+/// , which is banned-symbol policy rather than preference (see
+/// BannedSymbols.txt ), and RFC 9562 puts 48 bits of Unix milliseconds in the first six bytes of one.
+/// That is a real creation time, already stored, costing nothing.
+///
+///
+/// What it is not. It is when the item was created , never when it was last changed — an
+/// update keeps the id. A screen showing this has to say so, or it is quietly presenting a creation date as
+/// a modification date. And an id minted anywhere else, by an older client or another implementation, is not
+/// a v7 at all; that case answers null rather than a number derived from bytes that mean something else.
+///
+///
+public static class Uuid7Timestamp
+{
+ /// Where the version nibble lives in the RFC byte order.
+ private const int VersionByte = 6;
+
+ ///
+ /// The creation time recorded in a version 7 identifier, or null if it is not one.
+ ///
+ public static DateTimeOffset? Of(Guid id)
+ {
+ Span bytes = stackalloc byte[16];
+
+ // Big-endian, which is the whole reason this is not two lines of shifting. Guid's own layout stores
+ // its first three fields in the host's byte order, so the little-endian overload scrambles exactly
+ // the six bytes being read here — and does it silently, producing dates in the year 30000 rather
+ // than an error.
+ if (!id.TryWriteBytes(bytes, bigEndian: true, out _))
+ {
+ return null;
+ }
+
+ if ((bytes[VersionByte] & 0xF0) != 0x70)
+ {
+ return null;
+ }
+
+ long milliseconds = 0;
+
+ for (var i = 0; i < 6; i++)
+ {
+ milliseconds = (milliseconds << 8) | bytes[i];
+ }
+
+ return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
+ }
+}
diff --git a/src/DodoSSH.Client.Import/DodoSSH.Client.Import.csproj b/src/DodoSSH.Client.Import/DodoSSH.Client.Import.csproj
new file mode 100644
index 0000000..caadcee
--- /dev/null
+++ b/src/DodoSSH.Client.Import/DodoSSH.Client.Import.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Import/ImportedHost.cs b/src/DodoSSH.Client.Import/ImportedHost.cs
new file mode 100644
index 0000000..4d7bd91
--- /dev/null
+++ b/src/DodoSSH.Client.Import/ImportedHost.cs
@@ -0,0 +1,102 @@
+using DodoSSH.Client.Domain;
+
+namespace DodoSSH.Client.Import;
+
+///
+/// One host an ssh_config describes, resolved and ready to be looked at.
+///
+///
+/// Deliberately not a . This is a candidate somebody has not agreed to import yet,
+/// and it carries things a stored host has no field for — the identity file's path, the jump alias by name,
+/// and the warnings that go beside a row in the preview.
+///
+///
+/// The name from the Host line, which is what the user types after ssh and so the name they
+/// will recognise.
+///
+///
+/// What HostName said, or the alias when it said nothing — which is OpenSSH's own default and the
+/// reason Host db.internal with no other directive works.
+///
+/// What User said, if anything.
+/// What Port said, defaulting to 22.
+/// Every IdentityFile path, in the order they were given.
+/// The ProxyJump value verbatim, if any.
+/// Everything else, as SSH directives.
+/// What could not be represented, per host.
+public sealed record ImportedHost(
+ string Alias,
+ string Hostname,
+ string? Username,
+ int Port,
+ IReadOnlyList IdentityFiles,
+ string? ProxyJump,
+ HostOptions Options,
+ IReadOnlyList Warnings)
+{
+ /// The address this would dial, for a preview row.
+ public string Address => Username is { Length: > 0 } user
+ ? $"{user}@{Hostname}:{Port}"
+ : $"{Hostname}:{Port}";
+
+ /// Turns this into the host that would be stored.
+ ///
+ ///
+ /// The identity file becomes a note and a directive, not a key. Reading somebody's
+ /// ~/.ssh/id_ed25519 into a keychain is exactly the act this product exists to make deliberate,
+ /// and doing it as a side effect of "import my config" is the wrong default. The path is recorded so it
+ /// is not lost; importing the material is a separate, per-row choice.
+ ///
+ ///
+ /// ProxyJump records intent and changes nothing about connecting. The SSH layer has no jump
+ /// hosts — ISshConnection offers OpenShellAsync and nothing else, and
+ /// SshConnectionRequest has no route field. So it is kept as a directive and a note, and the
+ /// preview says so; a bastion topology that imported and quietly did not route would be worse than one
+ /// that was not imported.
+ ///
+ ///
+ public HostSecret ToSecret()
+ {
+ var options = new List(Options);
+ var notes = new List();
+
+ if (IdentityFiles.Count > 0)
+ {
+ options.Add(new HostOption("IdentityFile", IdentityFiles[0]));
+
+ notes.Add(IdentityFiles.Count == 1
+ ? $"ssh_config used the key at {IdentityFiles[0]}."
+ : $"ssh_config listed {IdentityFiles.Count} keys, the first being {IdentityFiles[0]}.");
+ }
+
+ if (ProxyJump is { Length: > 0 } jump)
+ {
+ options.Add(new HostOption("ProxyJump", jump));
+ notes.Add($"ssh_config reached this through {jump}. DodoSSH does not route through a jump host yet.");
+ }
+
+ return new HostSecret
+ {
+ Label = Alias,
+ Hostname = Hostname,
+ Port = Port,
+ Username = Username,
+ Notes = notes.Count == 0 ? null : string.Join(" ", notes),
+ Options = HostOptions.Create(options),
+ };
+ }
+}
+
+///
+/// Everything an ssh_config yielded: the hosts it can offer, and what it could not.
+///
+/// The importable candidates, in file order.
+///
+/// Host patterns that are patterns rather than names. They contribute defaults and are not
+/// importable: a bookmark called *.internal is one nothing can dial.
+///
+/// Document-level notes, including the parser's own.
+public sealed record SshConfigImport(
+ IReadOnlyList Hosts,
+ IReadOnlyList SkippedPatterns,
+ IReadOnlyList Warnings);
diff --git a/src/DodoSSH.Client.Import/SshConfigDocument.cs b/src/DodoSSH.Client.Import/SshConfigDocument.cs
new file mode 100644
index 0000000..5533e66
--- /dev/null
+++ b/src/DodoSSH.Client.Import/SshConfigDocument.cs
@@ -0,0 +1,31 @@
+namespace DodoSSH.Client.Import;
+
+/// One Keyword Value line, with the keyword as written.
+/// The directive name. SSH keywords are case-insensitive; the case here is the file's.
+/// Everything after the keyword, unquoted but otherwise verbatim.
+public sealed record SshConfigDirective(string Keyword, string Value);
+
+///
+/// One Host block: the patterns it applies to and the directives under it.
+///
+///
+/// Every token on the Host line. One line can name several — Host web1 web2 web3 — and any of
+/// them may be a pattern rather than a name.
+///
+/// The directives under it, in file order.
+public sealed record SshConfigBlock(
+ IReadOnlyList Patterns,
+ IReadOnlyList Directives);
+
+///
+/// A parsed ssh_config , plus what could not be honoured.
+///
+/// Every Host block, in the order OpenSSH would read them.
+///
+/// What was skipped or flattened, in the words the preview will show. Everything this parser cannot
+/// represent ends up here rather than being dropped quietly — a config that half-imported without saying so
+/// is worse than one that refused.
+///
+public sealed record SshConfigDocument(
+ IReadOnlyList Blocks,
+ IReadOnlyList Warnings);
diff --git a/src/DodoSSH.Client.Import/SshConfigLocator.cs b/src/DodoSSH.Client.Import/SshConfigLocator.cs
new file mode 100644
index 0000000..6951a3a
--- /dev/null
+++ b/src/DodoSSH.Client.Import/SshConfigLocator.cs
@@ -0,0 +1,80 @@
+namespace DodoSSH.Client.Import;
+
+///
+/// Finds and reads the user's OpenSSH client configuration.
+///
+///
+/// The only type here that touches a disk, which is what keeps and
+/// testable against strings.
+///
+public sealed class SshConfigLocator
+{
+ private readonly string sshDirectory;
+
+ ///
+ /// Where to look. Defaults to ~/.ssh , which is the location on Windows as well as everywhere
+ /// else — OpenSSH on Windows uses the profile directory, not %APPDATA% .
+ ///
+ public SshConfigLocator(string? sshDirectory = null) =>
+ this.sshDirectory = sshDirectory ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".ssh");
+
+ /// The file this would read.
+ public string ConfigPath => Path.Combine(sshDirectory, "config");
+
+ /// Whether there is anything to read.
+ public bool Exists => File.Exists(ConfigPath);
+
+ /// Reads and resolves the configuration.
+ /// There is no configuration file.
+ public async Task ReadAsync(CancellationToken cancellationToken)
+ {
+ var text = await File.ReadAllTextAsync(ConfigPath, cancellationToken).ConfigureAwait(false);
+
+ return SshConfigResolver.Resolve(SshConfigParser.Parse(text, ReadIncluded));
+ }
+
+ ///
+ /// Reads every file an Include pattern names.
+ ///
+ ///
+ ///
+ /// A relative pattern resolves against ~/.ssh , which is OpenSSH's rule for the user file. Glob
+ /// characters are handled by enumerating the directory rather than by matching by hand — a pattern like
+ /// conf.d/*.conf is the common shape and is what the enumeration overload is for.
+ ///
+ ///
+ /// Everything here swallows its own failures and returns nothing. An Include naming a file that
+ /// does not exist is not an error to OpenSSH, and an unreadable one is a reason to import less rather
+ /// than a reason to import nothing — the parser records the shortfall in its warnings either way.
+ ///
+ ///
+ private IReadOnlyList ReadIncluded(string pattern)
+ {
+ try
+ {
+ var rooted = Path.IsPathRooted(pattern) ? pattern : Path.Combine(sshDirectory, pattern);
+ var directory = Path.GetDirectoryName(rooted);
+ var mask = Path.GetFileName(rooted);
+
+ if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(mask) || !Directory.Exists(directory))
+ {
+ return [];
+ }
+
+ return [.. Directory
+ .EnumerateFiles(directory, mask, SearchOption.TopDirectoryOnly)
+ .Order(StringComparer.Ordinal)
+ .Select(File.ReadAllText)];
+ }
+ catch (IOException)
+ {
+ return [];
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return [];
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Import/SshConfigParser.cs b/src/DodoSSH.Client.Import/SshConfigParser.cs
new file mode 100644
index 0000000..23cc03c
--- /dev/null
+++ b/src/DodoSSH.Client.Import/SshConfigParser.cs
@@ -0,0 +1,295 @@
+using System.Globalization;
+
+namespace DodoSSH.Client.Import;
+
+///
+/// Reads an OpenSSH client configuration into blocks and directives.
+///
+///
+///
+/// Pure, and takes its include reader as a parameter. That is what makes Include — the one
+/// directive whose behaviour depends on the file system — testable without a file system, and it keeps the
+/// recursion depth cap and the cycle detection here, next to the recursion, rather than in whatever happens
+/// to be doing the reading.
+///
+///
+/// Deliberately not a complete implementation of ssh_config, and the gaps are reported rather than
+/// hidden. Match blocks are not evaluated: Match exec runs a command, Match host
+/// depends on what is being connected to, and Match final depends on the result of everything else —
+/// none of which is knowable while looking at a file. Token expansion beyond ~ ,
+/// CanonicalizeHostname and negated patterns are all out of scope for the same reason: this is an
+/// importer producing bookmarks somebody will check, not a second SSH client.
+///
+///
+public static class SshConfigParser
+{
+ /// How deep Include may nest before this gives up.
+ ///
+ /// OpenSSH's own limit is 16. Matching it means a config this refuses is one ssh refuses too,
+ /// which is a better answer than a different arbitrary number.
+ ///
+ private const int MaximumIncludeDepth = 16;
+
+ ///
+ /// Parses configuration text.
+ ///
+ /// The file's contents.
+ ///
+ /// Resolves an Include pattern to the contents of every file it names, in order. Return an empty
+ /// sequence for a pattern that matches nothing, which is what OpenSSH does — an Include naming no
+ /// file is not an error.
+ ///
+ public static SshConfigDocument Parse(string text, Func>? includeReader = null)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ var blocks = new List();
+ var warnings = new List();
+ var visited = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ ParseInto(text, includeReader, blocks, warnings, visited, depth: 0);
+
+ return new SshConfigDocument(blocks, warnings);
+ }
+
+ private static void ParseInto(
+ string text,
+ Func>? includeReader,
+ List blocks,
+ List warnings,
+ HashSet visited,
+ int depth)
+ {
+ List? patterns = null;
+ var directives = new List();
+
+ // A Match block is "everything until the next Host or Match", and while one is open its directives
+ // are dropped rather than attributed to whatever block came before — which is what a naive parser
+ // does, and it silently gives one host another host's settings.
+ var insideMatch = false;
+ var matchBlocks = 0;
+
+ foreach (var raw in text.Split('\n'))
+ {
+ var (keyword, value) = Tokenise(raw);
+
+ if (keyword is null)
+ {
+ continue;
+ }
+
+ if (Is(keyword, "Host"))
+ {
+ Flush(blocks, patterns, directives);
+
+ patterns = SplitPatterns(value);
+ directives = [];
+ insideMatch = false;
+ }
+ else if (Is(keyword, "Match"))
+ {
+ Flush(blocks, patterns, directives);
+
+ patterns = null;
+ directives = [];
+ insideMatch = true;
+ matchBlocks++;
+ }
+ else if (insideMatch)
+ {
+ continue;
+ }
+ else if (Is(keyword, "Include"))
+ {
+ // Flushed first, so the included file's blocks land between this block and the next — which
+ // is where OpenSSH puts them, and it matters because the first value seen for a keyword is
+ // the one that wins.
+ Flush(blocks, patterns, directives);
+ patterns = null;
+ directives = [];
+
+ Include(value, includeReader, blocks, warnings, visited, depth);
+ }
+ else
+ {
+ directives.Add(new SshConfigDirective(keyword, value));
+ }
+ }
+
+ Flush(blocks, patterns, directives);
+
+ WarnAboutMatchBlocks(matchBlocks, warnings);
+ }
+
+ ///
+ /// Counted rather than listed. What a reader needs is that some of their file was not honoured and why;
+ /// naming each Match condition would be repeating the file back at them.
+ ///
+ private static void WarnAboutMatchBlocks(int matchBlocks, List warnings)
+ {
+ if (matchBlocks == 0)
+ {
+ return;
+ }
+
+ warnings.Add(string.Create(
+ CultureInfo.CurrentCulture,
+ $"{matchBlocks} Match block(s) were ignored. Whether one applies depends on what is being connected to, or on a command's output, so it cannot be decided from the file alone."));
+ }
+
+ private static bool Is(string keyword, string name) =>
+ string.Equals(keyword, name, StringComparison.OrdinalIgnoreCase);
+
+ private static void Include(
+ string pattern,
+ Func>? includeReader,
+ List blocks,
+ List warnings,
+ HashSet visited,
+ int depth)
+ {
+ if (includeReader is null)
+ {
+ warnings.Add($"Include {pattern} was skipped: nothing was supplied to read included files.");
+ return;
+ }
+
+ if (depth >= MaximumIncludeDepth)
+ {
+ warnings.Add($"Include {pattern} was skipped: includes are nested more than {MaximumIncludeDepth} deep.");
+ return;
+ }
+
+ // Cycles are the reason this is a set rather than a counter. A file that includes itself — directly
+ // or through a chain — would otherwise recurse until the depth cap, importing the same hosts sixteen
+ // times before stopping, which reads as a bug in the importer rather than in the config.
+ if (!visited.Add(pattern))
+ {
+ warnings.Add($"Include {pattern} was skipped: it is already being read further up.");
+ return;
+ }
+
+ try
+ {
+ foreach (var included in includeReader(pattern))
+ {
+ ParseInto(included, includeReader, blocks, warnings, visited, depth + 1);
+ }
+ }
+ finally
+ {
+ visited.Remove(pattern);
+ }
+ }
+
+ private static void Flush(
+ List blocks,
+ List? patterns,
+ List directives)
+ {
+ if (patterns is { Count: > 0 })
+ {
+ blocks.Add(new SshConfigBlock(patterns, directives));
+ }
+ }
+
+ ///
+ /// Splits one line into a keyword and a value, or nothing.
+ ///
+ ///
+ /// OpenSSH accepts Keyword Value , Keyword=Value and Keyword = Value , allows leading
+ /// whitespace, treats # as a comment, and lets a value be double-quoted. The quoting is what this
+ /// has to get right rather than approximately right: IdentityFile "~/my keys/id_ed25519" is one
+ /// path, and splitting it on whitespace produces two that do not exist.
+ ///
+ private static (string? Keyword, string Value) Tokenise(string line)
+ {
+ // A BOM on the first line, and CR on every line of a CRLF file. Both are invisible and both would
+ // otherwise end up inside the first keyword, where nothing matches them.
+ var trimmed = line.Trim('', '\r').Trim();
+
+ if (trimmed.Length == 0 || trimmed[0] == '#')
+ {
+ return (null, string.Empty);
+ }
+
+ var separator = trimmed.AsSpan().IndexOfAny(" \t=");
+
+ if (separator < 0)
+ {
+ return (trimmed, string.Empty);
+ }
+
+ var keyword = trimmed[..separator];
+ var rest = trimmed[separator..].TrimStart(' ', '\t');
+
+ if (rest.StartsWith('='))
+ {
+ rest = rest[1..].TrimStart(' ', '\t');
+ }
+
+ return (keyword, Unquote(rest));
+ }
+
+ private static string Unquote(string value)
+ {
+ var trimmed = value.Trim();
+
+ return trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"'
+ ? trimmed[1..^1]
+ : trimmed;
+ }
+
+ ///
+ /// Each token unquoted separately, because Host "my server" other is two patterns and one of them
+ /// contains a space.
+ ///
+ private static List SplitPatterns(string value)
+ {
+ var patterns = new List();
+ var span = value.AsSpan();
+ var index = 0;
+
+ while (index < span.Length)
+ {
+ while (index < span.Length && char.IsWhiteSpace(span[index]))
+ {
+ index++;
+ }
+
+ if (index >= span.Length)
+ {
+ break;
+ }
+
+ int end;
+
+ if (span[index] == '"')
+ {
+ index++;
+ end = index;
+
+ while (end < span.Length && span[end] != '"')
+ {
+ end++;
+ }
+
+ patterns.Add(span[index..end].ToString());
+ index = end + 1;
+ continue;
+ }
+
+ end = index;
+
+ while (end < span.Length && !char.IsWhiteSpace(span[end]))
+ {
+ end++;
+ }
+
+ patterns.Add(span[index..end].ToString());
+ index = end;
+ }
+
+ return patterns;
+ }
+}
diff --git a/src/DodoSSH.Client.Import/SshConfigResolver.cs b/src/DodoSSH.Client.Import/SshConfigResolver.cs
new file mode 100644
index 0000000..811d8c6
--- /dev/null
+++ b/src/DodoSSH.Client.Import/SshConfigResolver.cs
@@ -0,0 +1,238 @@
+using System.Buffers;
+using System.Globalization;
+using DodoSSH.Client.Domain;
+
+namespace DodoSSH.Client.Import;
+
+///
+/// Turns parsed blocks into the hosts an import can offer.
+///
+///
+///
+/// First value wins. That is the actual OpenSSH rule and it is not the intuitive one — a later
+/// Host * block supplies defaults for keywords nothing earlier set, and cannot override a keyword an
+/// earlier block already set. Getting it backwards produces an import where every host has the wildcard
+/// block's username.
+///
+///
+/// A block whose patterns are all wildcards contributes defaults and is not itself importable.
+/// Host *.internal is a rule about names, not a machine — a bookmark by that name could not be
+/// dialled. Those are reported so the preview can say what was used and not imported, rather than leaving
+/// somebody to wonder why six blocks produced four hosts.
+///
+///
+public static class SshConfigResolver
+{
+ private static readonly SearchValues PatternCharacters = SearchValues.Create("*?!");
+
+ /// Resolves every importable host in a parsed configuration.
+ public static SshConfigImport Resolve(SshConfigDocument document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ var hosts = new List();
+ var skipped = new List();
+ var warnings = new List(document.Warnings);
+
+ foreach (var pattern in document.Blocks.SelectMany(block => block.Patterns).Where(IsPattern))
+ {
+ if (!skipped.Contains(pattern, StringComparer.Ordinal))
+ {
+ skipped.Add(pattern);
+ }
+ }
+
+ foreach (var alias in document.Blocks.SelectMany(block => block.Patterns).Where(name => !IsPattern(name)))
+ {
+ if (hosts.Any(host => string.Equals(host.Alias, alias, StringComparison.OrdinalIgnoreCase)))
+ {
+ continue;
+ }
+
+ hosts.Add(Resolve(alias, document));
+ }
+
+ if (skipped.Count > 0)
+ {
+ var named = string.Join(", ", skipped);
+
+ warnings.Add(string.Create(
+ CultureInfo.CurrentCulture,
+ $"{skipped.Count} pattern block(s) — {named} — supplied defaults but were not imported as hosts. A pattern names a rule, not a machine."));
+ }
+
+ return new SshConfigImport(hosts, skipped, warnings);
+ }
+
+ private static ImportedHost Resolve(string alias, SshConfigDocument document)
+ {
+ // Case-insensitive, because SSH keywords are and HostOption.NameComparer already says so. Two
+ // spellings of ServerAliveInterval reaching HostOptions.Create would be a duplicate-name throw.
+ var settled = new Dictionary(HostOption.NameComparer);
+ var identityFiles = new List();
+ var warnings = new List();
+
+ Settle(alias, document, settled, identityFiles, warnings);
+
+ var port = ResolvePort(settled, warnings);
+ var hostname = Take(settled, "HostName") ?? alias;
+ var username = Take(settled, "User");
+ var proxyJump = Take(settled, "ProxyJump");
+
+ if (Take(settled, "ProxyCommand") is { } proxyCommand)
+ {
+ // Not put into Options: it would look like a setting that does something. Nothing in this
+ // application runs a ProxyCommand, and a directive sitting in a host's editor implying otherwise
+ // is worse than a sentence saying it was dropped.
+ warnings.Add($"ProxyCommand was dropped: nothing here runs one. It was '{proxyCommand}'.");
+ }
+
+ return new ImportedHost(
+ alias,
+ hostname,
+ username,
+ port,
+ identityFiles,
+ proxyJump,
+ HostOptions.Create(settled.Select(entry => new HostOption(entry.Key, entry.Value))),
+ warnings);
+ }
+
+ /// Walks every block that applies to an alias, keeping the first value for each keyword.
+ private static void Settle(
+ string alias,
+ SshConfigDocument document,
+ Dictionary settled,
+ List identityFiles,
+ List warnings)
+ {
+ var duplicates = new HashSet(HostOption.NameComparer);
+
+ foreach (var block in document.Blocks.Where(block => block.Patterns.Any(pattern => Matches(pattern, alias))))
+ {
+ foreach (var directive in block.Directives)
+ {
+ // IdentityFile is the one keyword that legitimately repeats — ssh tries each in turn — so it
+ // accumulates instead of settling, and is not reported as a duplicate.
+ if (string.Equals(directive.Keyword, "IdentityFile", StringComparison.OrdinalIgnoreCase))
+ {
+ identityFiles.Add(ExpandHome(directive.Value));
+ continue;
+ }
+
+ if (!settled.TryAdd(directive.Keyword, directive.Value))
+ {
+ duplicates.Add(directive.Keyword);
+ }
+ }
+ }
+
+ foreach (var keyword in duplicates.Order(HostOption.NameComparer))
+ {
+ // HostOptions is unique by name and cannot hold a repeat, which is a stated M1 limitation whose
+ // own remarks require the import path to surface it rather than quietly keep one. The first is
+ // kept because that is what ssh would have used.
+ warnings.Add($"{keyword} was set more than once; the first value was kept.");
+ }
+ }
+
+ private static int ResolvePort(Dictionary settled, List warnings)
+ {
+ if (Take(settled, "Port") is not { } portText)
+ {
+ return HostSecret.DefaultPort;
+ }
+
+ if (int.TryParse(portText, CultureInfo.InvariantCulture, out var parsed) && parsed is > 0 and <= 65535)
+ {
+ return parsed;
+ }
+
+ warnings.Add($"Port '{portText}' is not a usable port number; 22 was used.");
+
+ return HostSecret.DefaultPort;
+ }
+
+ ///
+ /// Removed as it is read, so a keyword that maps onto a first-class field does not also end up
+ /// in Options . A host carrying both a Port of 2222 and a Port directive saying 2222
+ /// has two places to change it and one of them will be forgotten.
+ ///
+ private static string? Take(Dictionary settled, string keyword)
+ {
+ if (!settled.Remove(keyword, out var value))
+ {
+ return null;
+ }
+
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ private static bool IsPattern(string name) => name.AsSpan().ContainsAny(PatternCharacters);
+
+ ///
+ /// Whether a Host pattern applies to an alias.
+ ///
+ ///
+ /// * and ? only. Negation is not implemented — a ! pattern is treated as not
+ /// matching, which errs towards importing a host with fewer defaults rather than towards silently
+ /// applying a block the user had excluded.
+ ///
+ private static bool Matches(string pattern, string alias)
+ {
+ if (pattern.StartsWith('!'))
+ {
+ return false;
+ }
+
+ return !pattern.AsSpan().ContainsAny(PatternCharacters)
+ ? string.Equals(pattern, alias, StringComparison.OrdinalIgnoreCase)
+ : Glob(pattern.AsSpan(), alias.AsSpan());
+ }
+
+ private static bool Glob(ReadOnlySpan pattern, ReadOnlySpan value)
+ {
+ if (pattern.IsEmpty)
+ {
+ return value.IsEmpty;
+ }
+
+ if (pattern[0] == '*')
+ {
+ for (var skip = 0; skip <= value.Length; skip++)
+ {
+ if (Glob(pattern[1..], value[skip..]))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ if (value.IsEmpty)
+ {
+ return false;
+ }
+
+ return (pattern[0] == '?' || char.ToUpperInvariant(pattern[0]) == char.ToUpperInvariant(value[0]))
+ && Glob(pattern[1..], value[1..]);
+ }
+
+ ///
+ /// Tilde only. %h , %p and the rest are left alone: they are expanded per connection
+ /// against values this importer does not have, and a path with a literal %h in it is at least
+ /// visibly unexpanded rather than wrong.
+ ///
+ private static string ExpandHome(string path)
+ {
+ if (!path.StartsWith("~/", StringComparison.Ordinal) && !path.StartsWith("~\\", StringComparison.Ordinal))
+ {
+ return path;
+ }
+
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+ return Path.Combine(home, path[2..]);
+ }
+}
diff --git a/src/DodoSSH.Client.Import/packages.lock.json b/src/DodoSSH.Client.Import/packages.lock.json
new file mode 100644
index 0000000..a11804f
--- /dev/null
+++ b/src/DodoSSH.Client.Import/packages.lock.json
@@ -0,0 +1,22 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Meziantou.Analyzer": {
+ "type": "Direct",
+ "requested": "[3.0.137, )",
+ "resolved": "3.0.137",
+ "contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
+ },
+ "Microsoft.CodeAnalysis.BannedApiAnalyzers": {
+ "type": "Direct",
+ "requested": "[5.6.0, )",
+ "resolved": "5.6.0",
+ "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
+ },
+ "dodossh.client.domain": {
+ "type": "Project"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj b/src/DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj
new file mode 100644
index 0000000..54aae1c
--- /dev/null
+++ b/src/DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.ObjectStore/ObjectKeys.cs b/src/DodoSSH.Client.ObjectStore/ObjectKeys.cs
new file mode 100644
index 0000000..f67f4b4
--- /dev/null
+++ b/src/DodoSSH.Client.ObjectStore/ObjectKeys.cs
@@ -0,0 +1,57 @@
+namespace DodoSSH.Client.ObjectStore;
+
+///
+/// Translating between the paths a file browser uses and the keys a bucket has.
+///
+///
+///
+/// A bucket has no directories. It has keys, which are strings, and a convention that / in a
+/// key means what it means in a path. Everything in this class is that convention written down in one place,
+/// because the alternative is the same three lines of trimming repeated at every call site with one of them
+/// subtly different.
+///
+///
+/// The browser's side is an absolute POSIX path — /reports/2026/q3.csv — because that is what the
+/// screen, the breadcrumb trail and the transfer queue already speak. The bucket's side is a key with no
+/// leading slash: reports/2026/q3.csv . The root is / on one side and the empty string on the
+/// other, which is the case every one of these methods is really about.
+///
+///
+internal static class ObjectKeys
+{
+ /// The path a file browser opens on.
+ internal const string Root = "/";
+
+ /// The object key for a browser path.
+ internal static string ToKey(string path) => path.TrimStart('/');
+
+ /// The browser path for an object key.
+ internal static string ToPath(string key) => Root + key.TrimStart('/');
+
+ ///
+ /// The prefix that lists one directory's immediate contents.
+ ///
+ ///
+ /// Trailing slash, always, and empty for the root. Without it a listing of /reports would also
+ /// return /reports-archive , because a prefix match knows nothing about path segments.
+ ///
+ internal static string ToPrefix(string path)
+ {
+ var key = ToKey(path);
+
+ return key.Length == 0 || key.EndsWith('/') ? key : key + "/";
+ }
+
+ /// The last segment of a key, which is what a row shows.
+ ///
+ /// Trailing slashes are removed first, so the common prefix reports/2026/ yields 2026
+ /// rather than an empty string.
+ ///
+ internal static string NameOf(string key)
+ {
+ var trimmed = key.TrimEnd('/');
+ var slash = trimmed.LastIndexOf('/');
+
+ return slash < 0 ? trimmed : trimmed[(slash + 1)..];
+ }
+}
diff --git a/src/DodoSSH.Client.ObjectStore/ObjectStoreFactory.cs b/src/DodoSSH.Client.ObjectStore/ObjectStoreFactory.cs
new file mode 100644
index 0000000..f6aa5df
--- /dev/null
+++ b/src/DodoSSH.Client.ObjectStore/ObjectStoreFactory.cs
@@ -0,0 +1,69 @@
+using Amazon;
+using Amazon.Runtime;
+using Amazon.S3;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.ObjectStore;
+
+/// Opens a bucket as a place with files in it.
+///
+/// An interface so the file screen can be tested without a bucket, exactly as ISftpSessionFactory is
+/// what lets it be tested without a host.
+///
+public interface IObjectStoreFactory
+{
+ /// Builds a client for one bucket.
+ /// The bucket and its credentials, decrypted.
+ ///
+ /// Synchronous and cheap: nothing is contacted here. S3 is request-per-operation, so there is no
+ /// connect step to fail — the first thing that can fail is the first listing, which is where the
+ /// credentials and the endpoint are actually tested.
+ ///
+ IRemoteFileStore Open(ObjectStoreSecret store);
+}
+
+/// Opens buckets with the AWS SDK.
+public sealed class S3ObjectStoreFactory : IObjectStoreFactory
+{
+ ///
+ public IRemoteFileStore Open(ObjectStoreSecret store)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+
+ if (!store.TryValidate(out var reason))
+ {
+ throw new ArgumentException(reason, nameof(store));
+ }
+
+ var config = new AmazonS3Config
+ {
+ // On for nearly every self-hosted service and off for AWS. It is a stored setting rather than
+ // something inferred from the endpoint, because inferring it wrongly produces a DNS failure that
+ // says nothing about buckets — see ObjectStoreSecret.UsePathStyle.
+ ForcePathStyle = store.UsePathStyle,
+ };
+
+ if (store.Endpoint is { } endpoint)
+ {
+ config.ServiceURL = endpoint;
+
+ // Still set when there is one, because SigV4 signs the region into every request and several
+ // S3-compatible services check it. The ones that do not, ignore it.
+ if (store.Region is { } named)
+ {
+ config.AuthenticationRegion = named;
+ }
+ }
+ else
+ {
+ // No endpoint means Amazon, and then the region is what resolves the host. Validation has
+ // already refused the case where neither is set.
+ config.RegionEndpoint = RegionEndpoint.GetBySystemName(store.Region!);
+ }
+
+ var credentials = new BasicAWSCredentials(store.AccessKeyId, store.SecretAccessKey);
+
+ return new S3FileStore(new AmazonS3Client(credentials, config), store.Bucket);
+ }
+}
diff --git a/src/DodoSSH.Client.ObjectStore/S3FileStore.cs b/src/DodoSSH.Client.ObjectStore/S3FileStore.cs
new file mode 100644
index 0000000..13fbacb
--- /dev/null
+++ b/src/DodoSSH.Client.ObjectStore/S3FileStore.cs
@@ -0,0 +1,449 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using Amazon.S3.Transfer;
+using DodoSSH.Client.Ssh;
+
+namespace DodoSSH.Client.ObjectStore;
+
+///
+/// One S3-compatible bucket, as a place with files in it.
+///
+///
+///
+/// A bucket is not a filesystem, and the three places that matter are documented on the members rather
+/// than smoothed over. There are no directories, only keys with slashes in them; an object cannot be
+/// appended to, so an interrupted upload cannot resume; and there is no rename, only copy-then-delete. Each
+/// is refused with a reason or implemented with its cost stated, because a file browser that quietly did
+/// something adjacent would be worse than one that said no.
+///
+///
+/// Listings are one page. ListObjectsV2 returns up to a thousand keys and this asks for one
+/// page, so a prefix with more than that in it is shown truncated — which the screen says out loud. Paging
+/// the whole way through a bucket with a million objects under one prefix is a request storm behind a
+/// scrollbar nobody asked for; the filter box is the answer, and a prefix that large is not a directory
+/// anybody browses.
+///
+///
+internal sealed class S3FileStore : IRemoteFileStore
+{
+ ///
+ /// The most keys one listing asks for.
+ ///
+ ///
+ /// The service's own maximum. Asking for less would page more often for no benefit; asking for more is
+ /// not possible.
+ ///
+ private const int PageSize = 1000;
+
+ private readonly IAmazonS3 client;
+ private readonly string bucket;
+ private int disposed;
+
+ internal S3FileStore(IAmazonS3 client, string bucket)
+ {
+ this.client = client;
+ this.bucket = bucket;
+ }
+
+ ///
+ /// Always true, because there is no connection to be up.
+ ///
+ ///
+ /// S3 is request-per-operation over HTTPS; there is no session to drop and nothing to poll. Answering
+ /// false when the network is down would be a claim this type cannot make without a request of its own,
+ /// and every operation already reports its own failure.
+ ///
+ public bool IsConnected => Volatile.Read(ref disposed) == 0;
+
+ ///
+ public string HomeDirectory => ObjectKeys.Root;
+
+ ///
+ /// Lists one prefix: its immediate sub-prefixes as directories, its immediate keys as files.
+ ///
+ ///
+ /// The delimiter is what makes this a directory listing rather than a recursive walk — without it, a
+ /// listing of the root returns every object in the bucket. Common prefixes come back as directories;
+ /// the marker object some tools write for a "folder" is dropped, because it is the directory itself and
+ /// showing it would put an empty-named row inside every one.
+ ///
+ public async Task> ListAsync(string path, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ var prefix = ObjectKeys.ToPrefix(path);
+
+ ListObjectsV2Response response;
+ try
+ {
+ response = await client.ListObjectsV2Async(
+ new ListObjectsV2Request
+ {
+ BucketName = bucket,
+ Prefix = prefix,
+ Delimiter = "/",
+ MaxKeys = PageSize,
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (AmazonS3Exception exception)
+ {
+ throw new SftpPathException(path, Describe(exception), exception);
+ }
+
+ return Project(response, prefix);
+ }
+
+ /// Turns one listing into rows a file browser can show.
+ ///
+ /// Directories first and then by name, which is the order every caller of this interface expects and
+ /// what saves the screen sorting it again.
+ ///
+ private static IReadOnlyList Project(ListObjectsV2Response response, string prefix)
+ {
+ var entries = new List();
+
+ foreach (var common in response.CommonPrefixes ?? [])
+ {
+ entries.Add(new SftpEntry(
+ ObjectKeys.NameOf(common),
+ ObjectKeys.ToPath(common),
+ SftpEntryKind.Directory,
+ Length: 0,
+ LastWriteTimeUtc: default,
+
+ // Blank rather than invented. A bucket has no POSIX mode, and printing drwxr-xr-x beside a
+ // prefix would be a fact this store made up.
+ Permissions: string.Empty));
+ }
+
+ foreach (var item in response.S3Objects ?? [])
+ {
+ // The marker object for this prefix itself, which several tools write to make a folder appear
+ // in a web console. It is this directory, not something in it.
+ if (string.Equals(item.Key, prefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ entries.Add(new SftpEntry(
+ ObjectKeys.NameOf(item.Key),
+ ObjectKeys.ToPath(item.Key),
+ SftpEntryKind.File,
+ item.Size ?? 0,
+ Utc(item.LastModified),
+ Permissions: string.Empty));
+ }
+
+ return
+ [
+ .. entries
+ .OrderByDescending(entry => entry.Kind is SftpEntryKind.Directory)
+ .ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase),
+ ];
+ }
+
+ ///
+ /// The SDK's timestamp as an unambiguous instant.
+ ///
+ ///
+ /// Stated rather than converted implicitly. S3 returns Last-Modified in UTC and the SDK hands it
+ /// over as a whose Kind is not reliably set — so an implicit conversion
+ /// would read it as local time on some paths and shift every timestamp in the listing by the machine's
+ /// offset. The file browser shows this column beside an SFTP one.
+ ///
+ private static DateTimeOffset Utc(DateTime? moment) =>
+ moment is { } value
+ ? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
+ : default;
+
+ ///
+ /// What one path is, or null when nothing is there.
+ ///
+ ///
+ /// Two requests in the worst case, because a bucket cannot answer "is this a directory" directly: a
+ /// HEAD tells us whether an object with that exact key exists, and only a listing can tell us whether
+ /// anything lives under it as a prefix. The order matters — a key can be both, and the object is the
+ /// more specific answer.
+ ///
+ public async Task StatAsync(string path, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ var key = ObjectKeys.ToKey(path);
+
+ if (key.Length == 0)
+ {
+ return new SftpEntry(
+ string.Empty, ObjectKeys.Root, SftpEntryKind.Directory, 0, default, string.Empty);
+ }
+
+ try
+ {
+ var head = await client.GetObjectMetadataAsync(
+ new GetObjectMetadataRequest { BucketName = bucket, Key = key },
+ cancellationToken).ConfigureAwait(false);
+
+ return new SftpEntry(
+ ObjectKeys.NameOf(key),
+ ObjectKeys.ToPath(key),
+ SftpEntryKind.File,
+ head.ContentLength,
+ Utc(head.LastModified),
+ Permissions: string.Empty);
+ }
+ catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Not an object. It may still be a prefix with things under it, which is what a browser means
+ // by a directory.
+ }
+
+ var listing = await client.ListObjectsV2Async(
+ new ListObjectsV2Request
+ {
+ BucketName = bucket,
+ Prefix = ObjectKeys.ToPrefix(path),
+ MaxKeys = 1,
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ return listing.KeyCount > 0
+ ? new SftpEntry(
+ ObjectKeys.NameOf(key),
+ ObjectKeys.ToPath(key),
+ SftpEntryKind.Directory,
+ 0,
+ default,
+ string.Empty)
+ : null;
+ }
+
+ ///
+ public async Task OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+
+ var request = new GetObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(path) };
+
+ if (offset > 0)
+ {
+ // A ranged GET, which is what makes an interrupted download resumable — and the one place where
+ // a bucket is better at this than SFTP, because the range is part of the protocol rather than a
+ // seek on an open handle.
+ request.ByteRange = new ByteRange(offset, long.MaxValue);
+ }
+
+ try
+ {
+ var response = await client.GetObjectAsync(request, cancellationToken).ConfigureAwait(false);
+
+ return response.ResponseStream;
+ }
+ catch (AmazonS3Exception exception)
+ {
+ throw new SftpPathException(path, Describe(exception), exception);
+ }
+ }
+
+ ///
+ /// Opens an object for writing, from the beginning.
+ ///
+ ///
+ ///
+ /// A non-zero offset is refused, and this is the one capability a bucket genuinely does not have.
+ /// Objects are immutable: there is no append, and no way to write into the middle of one. Multipart
+ /// upload can rebuild an interrupted transfer, but only by keeping the upload id and every part's ETag
+ /// across the interruption — state this store would have to persist somewhere, on behalf of a queue that
+ /// already has its own idea of what resuming means. Refusing with a reason is the honest answer;
+ /// silently starting from zero would corrupt a resumed file.
+ ///
+ ///
+ /// The returned stream is the writing half of a pipe. A background upload reads the other half and
+ /// chunks it into parts, so a large file never lands on disk twice and memory stays bounded by the part
+ /// size — which is what the alternative, buffering to a temporary file and putting it afterwards, would
+ /// have cost.
+ ///
+ ///
+ public Task OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ if (offset != 0)
+ {
+ throw new SftpPathException(
+ path,
+ "An object cannot be written to from the middle, so an interrupted upload to a bucket "
+ + "starts again rather than resuming.");
+ }
+
+ return Task.FromResult(
+ new S3UploadStream(client, bucket, ObjectKeys.ToKey(path), cancellationToken));
+ }
+
+ ///
+ /// Creates the marker object that makes an empty prefix visible.
+ ///
+ ///
+ /// A zero-byte object whose key ends in / , which is the convention every S3 console and most
+ /// tools use. It is not a directory — nothing in the service knows what one is — and it disappears by
+ /// itself once real objects live under the prefix, which is why the listing above drops it.
+ ///
+ public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ var prefix = ObjectKeys.ToPrefix(path);
+
+ if (prefix.Length == 0)
+ {
+ throw new SftpPathException(path, "The root of a bucket already exists.");
+ }
+
+ try
+ {
+ await client.PutObjectAsync(
+ new PutObjectRequest
+ {
+ BucketName = bucket,
+ Key = prefix,
+ ContentBody = string.Empty,
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (AmazonS3Exception exception)
+ {
+ throw new SftpPathException(path, Describe(exception), exception);
+ }
+ }
+
+ ///
+ /// Deletes one object, or an empty prefix's marker.
+ ///
+ ///
+ /// Deliberately not recursive, matching SFTP's own rule and for the same reason: a recursive delete
+ /// against a bucket is the one operation on this screen that can destroy something no undo reaches. A
+ /// prefix with anything under it is refused and says so.
+ ///
+ public async Task DeleteAsync(string path, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+
+ var key = ObjectKeys.ToKey(path);
+
+ if (key.Length == 0)
+ {
+ throw new SftpPathException(path, "A bucket cannot delete its own root.");
+ }
+
+ if (await StatAsync(path, cancellationToken).ConfigureAwait(false) is { Kind: SftpEntryKind.Directory })
+ {
+ var listing = await client.ListObjectsV2Async(
+ new ListObjectsV2Request
+ {
+ BucketName = bucket,
+ Prefix = ObjectKeys.ToPrefix(path),
+ MaxKeys = 2,
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ // One key is the marker object for this prefix itself; anything more is contents.
+ if (listing.KeyCount > 1)
+ {
+ throw new SftpPathException(
+ path, "There are still objects under this prefix, so it was not deleted.");
+ }
+
+ key = ObjectKeys.ToPrefix(path);
+ }
+
+ try
+ {
+ await client.DeleteObjectAsync(
+ new DeleteObjectRequest { BucketName = bucket, Key = key },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (AmazonS3Exception exception)
+ {
+ throw new SftpPathException(path, Describe(exception), exception);
+ }
+ }
+
+ ///
+ /// Copies to the new key and deletes the old one, which is what a bucket has instead of rename.
+ ///
+ ///
+ ///
+ /// Not atomic, and it cannot be. Between the two requests both keys exist; if the delete fails, both
+ /// still do. The copy is server-side — no bytes come to this machine — so the window is short, but it is
+ /// real and a failure leaves a duplicate rather than a loss, which is the safe direction.
+ ///
+ ///
+ /// Only objects. Renaming a prefix means copying every key under it, which is a bulk operation wearing
+ /// a rename's clothing, and the failure mode is a half-moved directory.
+ ///
+ ///
+ public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fromPath);
+ ArgumentNullException.ThrowIfNull(toPath);
+
+ if (await StatAsync(fromPath, cancellationToken).ConfigureAwait(false)
+ is not { Kind: SftpEntryKind.File })
+ {
+ throw new SftpPathException(
+ fromPath,
+ "Only an object can be renamed in a bucket. A prefix would have to be copied key by key.");
+ }
+
+ try
+ {
+ await client.CopyObjectAsync(
+ new CopyObjectRequest
+ {
+ SourceBucket = bucket,
+ SourceKey = ObjectKeys.ToKey(fromPath),
+ DestinationBucket = bucket,
+ DestinationKey = ObjectKeys.ToKey(toPath),
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ await client.DeleteObjectAsync(
+ new DeleteObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(fromPath) },
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (AmazonS3Exception exception)
+ {
+ throw new SftpPathException(fromPath, Describe(exception), exception);
+ }
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) == 0)
+ {
+ client.Dispose();
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ /// What went wrong, in words that name the bucket rather than the protocol.
+ ///
+ ///
+ /// The SDK's own messages are accurate and unhelpful at a file browser: "The specified key does not
+ /// exist" is fine, and "Access Denied" against a bucket somebody has just typed the keys for is the
+ /// moment to say which of the two is more likely.
+ ///
+ private static string Describe(AmazonS3Exception exception) => exception.StatusCode switch
+ {
+ System.Net.HttpStatusCode.NotFound => "There is nothing at that key.",
+ System.Net.HttpStatusCode.Forbidden =>
+ "The bucket refused that. Check the access key and what it is allowed to do.",
+ System.Net.HttpStatusCode.BadRequest when exception.ErrorCode is "AuthorizationHeaderMalformed" =>
+ "The bucket is in a different region to the one configured.",
+ _ => exception.Message,
+ };
+}
diff --git a/src/DodoSSH.Client.ObjectStore/S3UploadStream.cs b/src/DodoSSH.Client.ObjectStore/S3UploadStream.cs
new file mode 100644
index 0000000..f780543
--- /dev/null
+++ b/src/DodoSSH.Client.ObjectStore/S3UploadStream.cs
@@ -0,0 +1,204 @@
+using System.IO.Pipelines;
+using Amazon.S3;
+using Amazon.S3.Transfer;
+
+namespace DodoSSH.Client.ObjectStore;
+
+///
+/// A stream you write an object into.
+///
+///
+///
+/// The direction is the whole problem. The transfer queue asks for somewhere to write and then copies
+/// a local file into it; the S3 SDK wants a stream it can read from. Something has to bridge the two, and
+/// there are only three ways to do it: buffer the whole object to a temporary file and upload afterwards
+/// (correct, and doubles the disk a big upload costs), hold it in memory (correct until somebody uploads a
+/// disc image), or run the upload concurrently and hand back the writing half of a pipe.
+///
+///
+/// This is the third. reads the pipe and splits it into multipart chunks, so
+/// memory stays bounded by the part size however large the object is, and nothing lands on disk twice.
+///
+///
+/// Completion is on , and it is not optional. The upload is only finished
+/// when the pipe is completed and the background task has been awaited — so a caller that abandons this
+/// stream without disposing it leaves an upload running against a bucket. That is the same contract every
+/// stream has; it is written down because the consequence here is remote rather than local.
+///
+///
+/// A failed upload has to surface at the writer. If the service refuses halfway, the reading half
+/// stops and this stream's next WriteAsync would otherwise block for ever — so the background task's
+/// completion also completes the pipe's reader with the exception, which is what makes the write throw with
+/// the real reason rather than hang.
+///
+///
+internal sealed class S3UploadStream : Stream
+{
+ private readonly Pipe pipe = new();
+ private readonly Task upload;
+ private readonly CancellationToken cancellationToken;
+ private int disposed;
+
+ internal S3UploadStream(
+ IAmazonS3 client,
+ string bucket,
+ string key,
+ CancellationToken cancellationToken)
+ {
+ this.cancellationToken = cancellationToken;
+
+ upload = UploadAsync(client, bucket, key);
+ }
+
+ ///
+ public override bool CanRead => false;
+
+ ///
+ public override bool CanSeek => false;
+
+ ///
+ public override bool CanWrite => Volatile.Read(ref disposed) == 0;
+
+ /// Not answerable: an object's length is not known until it has all been written.
+ public override long Length => throw new NotSupportedException();
+
+ ///
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ ///
+ public override async ValueTask WriteAsync(
+ ReadOnlyMemory buffer,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await pipe.Writer.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ if (result.IsCompleted)
+ {
+ // The reader has stopped, which means the upload ended — almost always because the service
+ // refused it. Awaiting the task surfaces that exception here, at the write, instead of leaving
+ // the caller to discover it at disposal after copying a whole file into nothing.
+ await upload.ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Refused: this stream is asynchronous all the way down.
+ ///
+ ///
+ /// Blocking on the pipe from a synchronous write is a deadlock waiting for a thread-pool starvation to
+ /// find it — the other half of the pipe is being read by a task that needs a thread to run on. The only
+ /// caller is the transfer queue, which copies asynchronously, so this is unreachable rather than
+ /// inconvenient. Throwing says which; blocking would say nothing until a large upload hung.
+ ///
+ public override void Write(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException(
+ "An upload to a bucket is written asynchronously; use WriteAsync.");
+
+ ///
+ /// Nothing, deliberately.
+ ///
+ ///
+ /// A flush cannot mean what a caller would want it to here — the object does not exist until the upload
+ /// completes, so there is no partial state to make durable. The pipe's own writes are already handed to
+ /// the reader as they arrive.
+ ///
+ public override void Flush()
+ {
+ }
+
+ ///
+ public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ ///
+ public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ ///
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ ///
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ ///
+ public override async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) == 1)
+ {
+ return;
+ }
+
+ // Completing the writer is what tells the upload there is no more, so it must happen before the
+ // await — and it must happen even when the caller is abandoning a failed transfer, or the background
+ // task never ends.
+ await pipe.Writer.CompleteAsync().ConfigureAwait(false);
+
+ try
+ {
+ await upload.ConfigureAwait(false);
+ }
+ finally
+ {
+ await base.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Refused when it would have to finish an upload.
+ ///
+ ///
+ ///
+ /// Completing this stream means completing the pipe and awaiting the upload, and doing that from a
+ /// synchronous Dispose is the deadlock the synchronous Write above avoids. The alternative
+ /// — completing the writer and abandoning the task — silently drops whatever the service was about to
+ /// say, including a refusal, and reports a transfer as finished that never landed.
+ ///
+ ///
+ /// So a using rather than an await using throws, which is loud, immediate and correct. The
+ /// only caller already uses await using ; this is what stops a second one being written by
+ /// accident.
+ ///
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && Volatile.Read(ref disposed) == 0)
+ {
+ throw new NotSupportedException(
+ "An upload to a bucket finishes asynchronously; use await using rather than using.");
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private async Task UploadAsync(IAmazonS3 client, string bucket, string key)
+ {
+ using var transfer = new TransferUtility(client);
+
+ try
+ {
+ await transfer.UploadAsync(
+ new TransferUtilityUploadRequest
+ {
+ BucketName = bucket,
+ Key = key,
+ InputStream = pipe.Reader.AsStream(),
+
+ // The stream has no length, so the utility has to be told not to look for one. It reads
+ // until the pipe completes and splits what it read into parts.
+ AutoCloseStream = false,
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ await pipe.Reader.CompleteAsync().ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ // Completing the reader *with* the exception is what unblocks a writer that is still copying:
+ // its next write sees a completed pipe and awaits this task, which rethrows this.
+ await pipe.Reader.CompleteAsync(exception).ConfigureAwait(false);
+ throw;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.ObjectStore/packages.lock.json b/src/DodoSSH.Client.ObjectStore/packages.lock.json
new file mode 100644
index 0000000..99de2a2
--- /dev/null
+++ b/src/DodoSSH.Client.ObjectStore/packages.lock.json
@@ -0,0 +1,88 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "AWSSDK.Core": {
+ "type": "Direct",
+ "requested": "[4.0.100.9, )",
+ "resolved": "4.0.100.9",
+ "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
+ },
+ "AWSSDK.S3": {
+ "type": "Direct",
+ "requested": "[4.0.101.6, )",
+ "resolved": "4.0.101.6",
+ "contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
+ "dependencies": {
+ "AWSSDK.Core": "[4.0.100.9, 5.0.0)"
+ }
+ },
+ "Meziantou.Analyzer": {
+ "type": "Direct",
+ "requested": "[3.0.137, )",
+ "resolved": "3.0.137",
+ "contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
+ },
+ "Microsoft.CodeAnalysis.BannedApiAnalyzers": {
+ "type": "Direct",
+ "requested": "[5.6.0, )",
+ "resolved": "5.6.0",
+ "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.2",
+ "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.0.3",
+ "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
+ }
+ },
+ "dodossh.client.domain": {
+ "type": "Project"
+ },
+ "dodossh.client.ssh": {
+ "type": "Project",
+ "dependencies": {
+ "NSec.Cryptography": "[26.4.0, )",
+ "SSH.NET": "[2025.1.0, )"
+ }
+ },
+ "BouncyCastle.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[2.6.2, )",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ },
+ "NSec.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[26.4.0, )",
+ "resolved": "26.4.0",
+ "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
+ "dependencies": {
+ "libsodium": "[1.0.22, 1.0.23)"
+ }
+ },
+ "SSH.NET": {
+ "type": "CentralTransitive",
+ "requested": "[2025.1.0, )",
+ "resolved": "2025.1.0",
+ "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
+ "dependencies": {
+ "BouncyCastle.Cryptography": "2.6.2",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DodoSSH.Client.Session/ActivityRecorder.cs b/src/DodoSSH.Client.Session/ActivityRecorder.cs
new file mode 100644
index 0000000..59c10d7
--- /dev/null
+++ b/src/DodoSSH.Client.Session/ActivityRecorder.cs
@@ -0,0 +1,165 @@
+using System.Threading.Channels;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Sync;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Session;
+
+///
+/// Records keychain changes into the vault they happened in, without making the save wait.
+///
+///
+///
+/// 's shape, and the reason is the same one stated a different way: the
+/// caller is a Save the user is watching, and an encrypt-and-write on that path would put the log's cost
+/// into every edit. So posts to a bounded channel and returns, and one background task
+/// does the work.
+///
+///
+/// Session-scoped, unlike the connection recorder. This one is created with the vault and dies with
+/// it — there is no equivalent of a shell that outlives a lock, because an edit is finished by the time it
+/// is recorded. That is why it is owned by rather than by the shell.
+///
+///
+/// Every failure is swallowed. A log write that failed and surfaced would fail a save, and the whole
+/// premise of the outbox is that saving works offline and cannot be refused. What is lost when this drops
+/// something is one advisory line.
+///
+///
+internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable
+{
+ ///
+ private const int QueueDepth = 512;
+
+ private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
+
+ private readonly Channel pending = Channel.CreateBounded(
+ new BoundedChannelOptions(QueueDepth)
+ {
+ FullMode = BoundedChannelFullMode.DropOldest,
+ SingleReader = true,
+ });
+
+ private readonly ActivityLogRepository log;
+ private readonly Guid vaultId;
+ private readonly Guid actorUserId;
+ private readonly string deviceName;
+ private readonly TimeProvider clock;
+ private readonly CancellationTokenSource lifetime = new();
+ private readonly Task drain;
+
+ private int disposed;
+
+ /// Where entries go.
+ /// The vault they belong to.
+ /// Which account is making them.
+ /// What this machine calls itself.
+ /// Time source.
+ internal ActivityRecorder(
+ ActivityLogRepository log,
+ Guid vaultId,
+ Guid actorUserId,
+ string deviceName,
+ TimeProvider clock)
+ {
+ this.log = log;
+ this.vaultId = vaultId;
+ this.actorUserId = actorUserId;
+ this.deviceName = deviceName;
+ this.clock = clock;
+
+ drain = DrainAsync(lifetime.Token);
+ }
+
+ ///
+ public void Record(
+ Guid vaultId,
+ SyncEntityType kind,
+ Guid entityId,
+ string label,
+ ActivityOperation operation,
+ IReadOnlyList changedFields)
+ {
+ ArgumentNullException.ThrowIfNull(changedFields);
+
+ if (vaultId != this.vaultId)
+ {
+ // A write to a vault this recorder is not for. Not currently reachable — one session, one active
+ // vault — and refused rather than filed under the wrong one, because that is the failure that
+ // would be hardest to notice once shared vaults land.
+ return;
+ }
+
+ var entry = new ActivityLogSecret
+ {
+ // The name rather than the number, so a build that has never heard of a kind still shows
+ // something a person can read. See ActivityLogSecretCodec.
+ ItemKind = Enum.GetName(kind) ?? kind.ToString(),
+ ItemId = entityId,
+ ItemLabel = label,
+ Operation = operation,
+ ChangedFields = string.Join(", ", changedFields),
+ At = clock.GetUtcNow(),
+ DeviceName = deviceName,
+ ActorUserId = actorUserId,
+ };
+
+ pending.Writer.TryWrite(entry);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) == 1)
+ {
+ return;
+ }
+
+ pending.Writer.TryComplete();
+
+ try
+ {
+ await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
+ {
+ // Whatever is left goes unwritten, which is the same trade the queue's own DropOldest makes.
+ }
+
+ await lifetime.CancelAsync().ConfigureAwait(false);
+
+ try
+ {
+ await drain.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected: cancelling is how the loop is asked to stop.
+ }
+
+ lifetime.Dispose();
+ }
+
+ private async Task DrainAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var entry in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
+ {
+ try
+ {
+ await log.CreateAsync(vaultId, entry, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ // Swallowed. There is no caller left to tell, and the realistic failure is a cache that
+ // has gone away underneath a session being disposed.
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Session/ConnectionRecorder.cs b/src/DodoSSH.Client.Session/ConnectionRecorder.cs
new file mode 100644
index 0000000..abdebaf
--- /dev/null
+++ b/src/DodoSSH.Client.Session/ConnectionRecorder.cs
@@ -0,0 +1,410 @@
+using System.Threading.Channels;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Sync;
+using DodoSSH.Client.Terminal;
+
+namespace DodoSSH.Client.Session;
+
+/// A connection that has started and has no log entry yet, because it has not ended.
+/// What the host is called.
+/// The address as dialled.
+/// When it opened.
+public sealed record OpenConnection(string HostLabel, string Address, DateTimeOffset StartedAt);
+
+///
+/// Records connections into whichever vault is open, without ever making the caller wait.
+///
+///
+///
+/// A process-lifetime object with session-scoped contents , exactly like
+/// and for the same reason: the workspace that calls it is composed once at startup and outlives every lock,
+/// so a recorder created per session would have to be threaded through an object that must not know about
+/// vaults at all. on unlock, on lock.
+///
+///
+/// Nothing on the calling thread does any work. Both interface methods take a lock, touch a
+/// dictionary, and post to a bounded channel; one background task drains it and does the encrypting and
+/// writing. That is not tidiness — Closed is called from a finally unwinding on a thread-pool
+/// thread while the application is shutting down, once per open tab, and an encrypt-and-write there is
+/// exactly how closing an application comes to take four seconds.
+///
+///
+/// A shell can outlive the vault, so close-out has to as well. A tab opened before a lock and closed
+/// after it still deserves its entry — the connection genuinely happened — so the ticket keeps the repository
+/// it was opened against rather than reading whichever one is current. The write then fails if the session
+/// behind it has been disposed, which is swallowed like every other failure here: an advisory log line is
+/// never worth surfacing an error over.
+///
+///
+/// The queue is bounded and drops the oldest when full. An unbounded one would turn a stuck write into
+/// unbounded memory, and blocking would turn it into a hung shutdown. Losing the oldest few entries of a
+/// backlog that is already thousands deep is the least bad of the three, and it is the direction that keeps
+/// the newest — which is what somebody reading a log actually wants.
+///
+///
+public sealed class ConnectionRecorder : IConnectionLogSink, IAsyncDisposable
+{
+ ///
+ /// How many close-outs may be waiting to be written.
+ ///
+ ///
+ /// Far more than the tabs anybody has open, so the cap is only ever reached by a write path that has
+ /// stopped draining — which is the case it exists for.
+ ///
+ private const int QueueDepth = 256;
+
+ /// How long waits for the queue to be written.
+ ///
+ /// Long enough for the handful of entries a normal exit produces — each is one encrypt and one local
+ /// write — and short enough that a stuck cache cannot become a window that will not close.
+ ///
+ private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
+
+ private readonly Channel pending = Channel.CreateBounded(
+ new BoundedChannelOptions(QueueDepth)
+ {
+ FullMode = BoundedChannelFullMode.DropOldest,
+ SingleReader = true,
+ });
+
+ private readonly Dictionary tickets = [];
+ private readonly Lock gate = new();
+ private readonly TimeProvider clock;
+ private readonly string deviceName;
+ private readonly Task drain;
+ private readonly CancellationTokenSource lifetime = new();
+
+ private Binding? binding;
+ private int disposed;
+
+ /// Time source. Used only for a duration this type did not receive.
+ /// What this machine calls itself, recorded on every entry.
+ public ConnectionRecorder(TimeProvider clock, string deviceName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
+
+ this.clock = clock;
+ this.deviceName = deviceName;
+
+ drain = DrainAsync(lifetime.Token);
+ }
+
+ ///
+ /// The connections that have opened and not yet been recorded.
+ ///
+ ///
+ /// For the logs screen, which shows these above the finished entries. It reads them from here rather
+ /// than from the tab strip because these are exactly the tickets the log is waiting to close — so a row
+ /// on that screen appears and disappears in step with the entry that will replace it, rather than in
+ /// step with a tab, which is a different thing that merely usually agrees.
+ ///
+ public IReadOnlyList Open()
+ {
+ lock (gate)
+ {
+ return
+ [
+ .. tickets.Values
+ .Select(ticket => new OpenConnection(
+ ticket.HostLabel, ticket.Address, ticket.StartedAt))
+ .OrderByDescending(open => open.StartedAt),
+ ];
+ }
+ }
+
+ /// Whether a vault is open behind this recorder.
+ public bool IsOpen
+ {
+ get
+ {
+ lock (gate)
+ {
+ return binding is not null;
+ }
+ }
+ }
+
+ /// Starts recording into an unlocked vault.
+ /// The unlocked session. Its active vault is the one written to.
+ /// Which account this is, recorded on every entry.
+ public void Open(VaultSession session, Guid actorUserId)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+
+ lock (gate)
+ {
+ binding = new Binding(session.ConnectionLog, session.ActiveVaultId, actorUserId);
+ }
+ }
+
+ ///
+ /// Stops recording new connections.
+ ///
+ ///
+ /// Open tickets are deliberately not discarded. Each already holds the repository it was opened
+ /// against, so a shell still running when the vault locks closes out into the vault it was made from —
+ /// which is the honest record. What is dropped is the ability to start a ticket, because a
+ /// connection made while locked has no vault to belong to.
+ ///
+ public void Close()
+ {
+ lock (gate)
+ {
+ binding = null;
+ }
+ }
+
+ ///
+ public void Opened(uint sessionId, string address, DateTimeOffset startedAt)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(address);
+
+ lock (gate)
+ {
+ if (binding is not { } open)
+ {
+ return;
+ }
+
+ // The address stands in for the name until Identify supplies one, so a connection made by
+ // something that never calls it is still recorded — with a worse label, which beats no entry.
+ tickets[sessionId] = new OpenTicket(
+ open, address, address, HostId: null, ConnectionKind.Terminal, startedAt);
+ }
+ }
+
+ ///
+ /// Names the host an already-open session belongs to.
+ ///
+ /// The session, as the workspace knows it.
+ /// What the host is called in the keychain.
+ /// The host item.
+ ///
+ ///
+ /// The workspace takes an SshConnectionRequest , which has no notion of a keychain item, so it
+ /// knows an address and nothing else. The label and the id arrive here instead, from the view model that
+ /// does know — and as an amendment rather than a second ticket, so the start time stays the one the
+ /// workspace recorded rather than the slightly later one this call would carry.
+ ///
+ ///
+ /// A session id with no ticket is ignored, which is what a connection made while the vault was locked
+ /// looks like.
+ ///
+ ///
+ public void Identify(uint sessionId, string hostLabel, Guid? hostId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
+
+ lock (gate)
+ {
+ if (tickets.TryGetValue(sessionId, out var ticket))
+ {
+ tickets[sessionId] = ticket with { HostLabel = hostLabel, HostId = hostId };
+ }
+ }
+ }
+
+ ///
+ public void Closed(uint sessionId, DateTimeOffset endedAt)
+ {
+ OpenTicket ticket;
+
+ lock (gate)
+ {
+ if (!tickets.Remove(sessionId, out var found))
+ {
+ // Never opened, already closed, or opened while the vault was locked. All three mean there
+ // is nothing to record, and none of them is an error.
+ return;
+ }
+
+ ticket = found;
+ }
+
+ Queue(ticket, endedAt, ConnectionOutcome.Closed);
+ }
+
+ ///
+ /// Records a connection that was never a workspace session.
+ ///
+ /// The address that was dialled.
+ /// What the host is called.
+ /// The host item, if there was one.
+ /// Which sort of session it was.
+ /// When it began.
+ /// When it ended, which is the same instant for an attempt that failed.
+ /// How it ended.
+ ///
+ ///
+ /// Two callers, both outside the terminal workspace's id space, which is why this takes no session id:
+ /// a connection that never opened — the workspace throws out of ConnectAsync before an id exists,
+ /// so there is nothing to open a ticket for — and an SFTP session, which is a separate connection
+ /// entirely and would collide with a terminal's id if it borrowed one.
+ ///
+ ///
+ /// A run of refusals against one host is the single most interesting thing a connection log can show,
+ /// which is why the failures are recorded at all rather than only the sessions that worked.
+ ///
+ ///
+ public void Record(
+ string address,
+ string hostLabel,
+ Guid? hostId,
+ ConnectionKind kind,
+ DateTimeOffset startedAt,
+ DateTimeOffset endedAt,
+ ConnectionOutcome outcome)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(address);
+ ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
+
+ Binding open;
+
+ lock (gate)
+ {
+ if (binding is not { } current)
+ {
+ return;
+ }
+
+ open = current;
+ }
+
+ Queue(new OpenTicket(open, address, hostLabel, hostId, kind, startedAt), endedAt, outcome);
+ }
+
+ ///
+ /// Closes out every still-open connection and writes what is queued, within a bounded wait.
+ ///
+ ///
+ ///
+ /// Closing the application is the ordinary way a session ends , and without this every one of them
+ /// would be lost: the workspace's own close-outs happen while it tears its sessions down, which is after
+ /// the vault they would be written into has gone. So the tickets are closed here instead, while there is
+ /// still something to write to, and the durations run to the moment of exit — which is what actually
+ /// happened.
+ ///
+ ///
+ /// The wait is bounded and the remainder is dropped. An advisory log is never worth making a
+ /// process refuse to exit, so a queue that will not drain costs its entries rather than the user's
+ /// patience.
+ ///
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref disposed, 1) == 1)
+ {
+ return;
+ }
+
+ OpenTicket[] remaining;
+
+ lock (gate)
+ {
+ remaining = [.. tickets.Values];
+ tickets.Clear();
+ binding = null;
+ }
+
+ var at = clock.GetUtcNow();
+
+ foreach (var ticket in remaining)
+ {
+ Queue(ticket, at, ConnectionOutcome.Closed);
+ }
+
+ pending.Writer.TryComplete();
+
+ try
+ {
+ await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
+ {
+ // Whatever is left goes unwritten. Stated rather than logged: there is nowhere left to log it.
+ }
+
+ await lifetime.CancelAsync().ConfigureAwait(false);
+
+ try
+ {
+ await drain.ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected: cancelling is how the loop is asked to stop.
+ }
+
+ lifetime.Dispose();
+ }
+
+ private void Queue(OpenTicket ticket, DateTimeOffset endedAt, ConnectionOutcome outcome)
+ {
+ // A duration rather than an end time, and clamped at zero: the two stamps come from the same clock,
+ // but a machine that resumed from sleep between them can still produce a negative one, and the
+ // payload refuses those outright.
+ var duration = endedAt > ticket.StartedAt ? endedAt - ticket.StartedAt : TimeSpan.Zero;
+
+ var entry = new ConnectionLogSecret
+ {
+ HostLabel = ticket.HostLabel,
+ Address = ticket.Address,
+ HostId = ticket.HostId,
+ Kind = ticket.Kind,
+ StartedAt = ticket.StartedAt,
+ Duration = duration,
+ Outcome = outcome,
+ DeviceName = deviceName,
+ ActorUserId = ticket.Binding.ActorUserId,
+ };
+
+ // TryWrite, never WriteAsync. The whole contract of this type is that the caller does not wait, and
+ // a bounded channel with DropOldest never refuses anyway.
+ pending.Writer.TryWrite(new PendingEntry(ticket.Binding, entry));
+ }
+
+ private async Task DrainAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var item in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
+ {
+ try
+ {
+ await item.Binding.Log
+ .CreateAsync(item.Binding.VaultId, item.Entry, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ // Swallowed, and this is the rule rather than an omission: a log entry is advisory, and
+ // there is no caller left to tell. The realistic failures are a session disposed between
+ // the queue and the write — a shell closed after the vault locked — and a cache that has
+ // gone away underneath it. Neither is worth an unobserved exception on a background task.
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+ }
+
+ /// Which vault entries go to, and who is making them.
+ private sealed record Binding(ConnectionLogRepository Log, Guid VaultId, Guid ActorUserId);
+
+ /// A connection that has started and not yet been recorded.
+ ///
+ /// It carries its own rather than reading the current one at close time, which is
+ /// what lets a session outlive the vault it was opened in without being filed into the next one.
+ ///
+ private sealed record OpenTicket(
+ Binding Binding,
+ string Address,
+ string HostLabel,
+ Guid? HostId,
+ ConnectionKind Kind,
+ DateTimeOffset StartedAt);
+
+ private sealed record PendingEntry(Binding Binding, ConnectionLogSecret Entry);
+}
diff --git a/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj b/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
index d1854ef..c6a43a0 100644
--- a/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
+++ b/src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj
@@ -24,6 +24,14 @@
+
+
diff --git a/src/DodoSSH.Client.Session/LogRetention.cs b/src/DodoSSH.Client.Session/LogRetention.cs
new file mode 100644
index 0000000..8f1f5c9
--- /dev/null
+++ b/src/DodoSSH.Client.Session/LogRetention.cs
@@ -0,0 +1,123 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Sync;
+
+namespace DodoSSH.Client.Session;
+
+/// How much log a vault keeps.
+/// How far back entries are kept.
+/// How many entries of each kind are kept, whatever their age.
+///
+///
+/// Two limits rather than one, and whichever bites first wins. An age alone lets somebody who connects two
+/// hundred times a day accumulate a log nobody wants to sync; a count alone means a quiet month of work
+/// disappears the week somebody has a busy afternoon.
+///
+///
+/// Retention is not optional here the way it is for a local log file. These entries sync, so keeping
+/// them for ever costs every machine in the vault the bandwidth and the storage — which is the price of the
+/// decision that made them auditable in the first place.
+///
+///
+public sealed record LogRetention(TimeSpan MaxAge, int MaxEntries)
+{
+ /// Ninety days, or five thousand entries of each kind.
+ public static LogRetention Default { get; } = new(TimeSpan.FromDays(90), 5_000);
+}
+
+/// What one pruning pass removed.
+/// Connection entries deleted.
+/// Activity entries deleted.
+public sealed record LogPruneResult(int Connections, int Activity)
+{
+ /// Whether anything went.
+ public bool RemovedAnything => Connections > 0 || Activity > 0;
+}
+
+///
+/// Removes log entries a vault has agreed to stop keeping.
+///
+///
+///
+/// A real tombstone delete that pushes , because these are synced items — so pruning is not a local
+/// tidy-up and cannot be run on a whim. It goes once when a vault opens and at most once per auto-sync tick
+/// behind a last-pruned stamp; the alternative, a timer of its own, would be a second thing waking a laptop
+/// up to write to a server.
+///
+///
+/// Age is read from the entry, not from the item. A connection entry knows when the connection
+/// started and an activity entry knows when the change happened, and both are the times a person means. The
+/// item id's own v7 timestamp is close but not the same — it is when the entry was written , which
+/// for a connection is when it ended.
+///
+///
+public static class LogPruner
+{
+ /// Deletes whatever falls outside the retention policy.
+ /// The open vault.
+ /// What to keep.
+ /// The moment to measure age from.
+ /// Cancellation.
+ ///
+ /// Reads both logs in full, which is what makes the count limit possible at all: neither the server nor
+ /// the local mirror can order encrypted entries, so the only place that can decide which five thousand
+ /// to keep is a client that has decrypted them.
+ ///
+ public static async Task PruneAsync(
+ VaultSession session,
+ LogRetention retention,
+ DateTimeOffset now,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentNullException.ThrowIfNull(retention);
+
+ var connections = await session.ConnectionLog
+ .ListAsync(session.ActiveVaultId, cancellationToken)
+ .ConfigureAwait(false);
+
+ var activity = await session.ActivityLog
+ .ListAsync(session.ActiveVaultId, cancellationToken)
+ .ConfigureAwait(false);
+
+ var cutoff = now - retention.MaxAge;
+
+ var staleConnections = Stale(
+ connections.Items, retention, cutoff, entry => entry.Secret.StartedAt);
+
+ var staleActivity = Stale(activity.Items, retention, cutoff, entry => entry.Secret.At);
+
+ foreach (var entry in staleConnections)
+ {
+ await session.ConnectionLog
+ .DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ foreach (var entry in staleActivity)
+ {
+ await session.ActivityLog
+ .DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return new LogPruneResult(staleConnections.Count, staleActivity.Count);
+ }
+
+ /// The ids of the entries that fall outside the policy, newest kept.
+ private static IReadOnlyList Stale(
+ IReadOnlyList> entries,
+ LogRetention retention,
+ DateTimeOffset cutoff,
+ Func, DateTimeOffset> at)
+ where TSecret : class, IVaultSecret
+ {
+ var ordered = entries.OrderByDescending(at).ToArray();
+
+ return
+ [
+ .. ordered
+ .Where((entry, index) => index >= retention.MaxEntries || at(entry) < cutoff)
+ .Select(entry => entry.EntityId),
+ ];
+ }
+}
diff --git a/src/DodoSSH.Client.Session/ServerConnection.cs b/src/DodoSSH.Client.Session/ServerConnection.cs
index 5ba0c43..43f784b 100644
--- a/src/DodoSSH.Client.Session/ServerConnection.cs
+++ b/src/DodoSSH.Client.Session/ServerConnection.cs
@@ -134,6 +134,22 @@ public interface IVaultServer : IDisposable
/// Pull and push.
ISyncApi Sync { get; }
+ /// Teams, their members, and the vaults they own.
+ ITeamApi Teams { get; }
+
+ ///
+ /// The public-key directory, and the key log that makes an answer from it checkable.
+ ///
+ ///
+ /// Exposed as one member because the two are only ever used together: a directory answer is a claim
+ /// the server makes about somebody else's key, and the log is what turns it into something a client
+ /// can verify. See KeyLogAudit .
+ ///
+ IDirectoryApi Directory { get; }
+
+ /// Vault key grants: who can open a vault, and who let them.
+ IVaultGrantApi Grants { get; }
+
/// Obtains the identity provider's signature over a key statement.
IKeyBindingAuthorizer KeyBinding { get; }
@@ -213,6 +229,15 @@ public sealed class ServerConnection : IVaultServer
///
public ISyncApi Sync => Api;
+ ///
+ public ITeamApi Teams => Api;
+
+ ///
+ public IDirectoryApi Directory => Api;
+
+ ///
+ public IVaultGrantApi Grants => Api;
+
///
public IKeyBindingAuthorizer KeyBinding => Oidc;
diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs
index 3089c14..a36a43e 100644
--- a/src/DodoSSH.Client.Session/VaultSession.cs
+++ b/src/DodoSSH.Client.Session/VaultSession.cs
@@ -26,6 +26,25 @@ public sealed record ConflictNotice(
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.
///
@@ -41,13 +60,23 @@ public sealed record ConflictNotice(
/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
///
///
-public sealed class VaultSession : IAsyncDisposable
+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(
@@ -78,21 +107,51 @@ public sealed class VaultSession : IAsyncDisposable
Vault = new VaultStore(caches, clock);
Unlock = new UnlockStore(caches, clock);
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, 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);
+ // 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.
- public IReadOnlyList Vaults { get; }
+ ///
+ /// 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 the interface is showing. The personal one, for now.
+ ///
+ /// 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; }
@@ -114,6 +173,36 @@ public sealed class VaultSession : IAsyncDisposable
///
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;
@@ -179,10 +268,14 @@ public sealed class VaultSession : IAsyncDisposable
return SignIn.ForgetAsync(cancellationToken);
}
- /// Runs one synchronisation pass over the active vault.
+ /// 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, CancellationToken cancellationToken)
+ public Task SyncAsync(
+ ISyncApi api,
+ Guid vaultId,
+ CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(disposed, this);
ArgumentNullException.ThrowIfNull(api);
@@ -190,7 +283,51 @@ public sealed class VaultSession : IAsyncDisposable
var engine = new SyncEngine(
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
- return engine.SyncAsync(ActiveVaultId, cancellationToken);
+ 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;
}
///
@@ -316,7 +453,8 @@ public sealed class VaultSession : IAsyncDisposable
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.
+ // 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);
@@ -369,33 +507,53 @@ public sealed class VaultSession : IAsyncDisposable
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
}
- /// How many local changes are waiting to be pushed.
+ ///
+ /// 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;
+
+ return pending.Count(operation => operation.EntityType is not (
+ SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry));
}
///
- public ValueTask DisposeAsync()
+ public async ValueTask DisposeAsync()
{
if (disposed)
{
- return ValueTask.CompletedTask;
+ 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();
-
- return ValueTask.CompletedTask;
}
private static ConflictNotice Describe(StoredConflict conflict)
diff --git a/src/DodoSSH.Client.Session/VaultSharing.cs b/src/DodoSSH.Client.Session/VaultSharing.cs
new file mode 100644
index 0000000..794b141
--- /dev/null
+++ b/src/DodoSSH.Client.Session/VaultSharing.cs
@@ -0,0 +1,293 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Sync;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Session;
+
+/// What a share attempt did.
+/// Whether a grant was recorded.
+///
+/// How the recipient's key was checked. Present whether or not the share went ahead, because a refusal
+/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
+///
+/// One line for a person. Never contains key material.
+public sealed record ShareOutcome(
+ bool Shared,
+ RecipientVerification Verification,
+ string Message);
+
+///
+/// Sharing, from the side that holds the keys.
+///
+///
+///
+/// These live on rather than in a service above it for the reason
+/// registering a device does: wrapping a vault key is the one step only an unlocked session can
+/// perform, and this type is the keyring's custodian. Everything else — the calls, the directory —
+/// arrives as a parameter, so the session still knows nothing about how either is implemented.
+///
+///
+/// Nothing here trusts the server's answer about somebody else's key. Every share reads the
+/// whole key log, verifies its hash chain, and refuses unless the directory's answer appears in it
+/// unchanged. That check is the difference between end-to-end encryption and a server that can read
+/// everything by handing out a key of its own; see and ADR 0001.
+///
+///
+public sealed partial class VaultSession
+{
+ ///
+ /// Creates a vault owned by a team, generating its key here.
+ ///
+ /// The team calls.
+ /// The owning team.
+ /// Display name. Plaintext, as all vault names are.
+ /// Cancellation token.
+ /// The new vault, already readable by this session.
+ ///
+ /// The key never leaves this process in the clear: it is generated here, sealed to this user's own
+ /// encryption key, and the seal is what the server stores. The creator's grant carries no key log
+ /// head, exactly as a personal vault's does not — there is no third party whose key could have been
+ /// substituted when you wrap something to yourself.
+ ///
+ public async Task CreateTeamVaultAsync(
+ ITeamApi api,
+ Guid teamId,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ ArgumentNullException.ThrowIfNull(api);
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ var vaultId = Guid.CreateVersion7();
+ var vaultKey = VaultKeys.Create();
+ var now = clock.GetUtcNow();
+
+ try
+ {
+ var request = BuildCreateRequest(vaultId, vaultKey, name, now);
+
+ var summary = await api.CreateTeamVaultAsync(teamId, request, cancellationToken)
+ .ConfigureAwait(false);
+
+ var stored = ToStored(summary);
+
+ await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
+
+ // Adopted rather than unwrapped from the response: this process generated the key, so
+ // unwrapping the server's copy of our own seal would be a round trip to learn something we
+ // already know. The keyring takes ownership from here.
+ keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
+
+ Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
+
+ return stored;
+ }
+ catch
+ {
+ // Never reached the keyring, so this is the only thing that can release it.
+ CryptographicOperations.ZeroMemory(vaultKey);
+ throw;
+ }
+ }
+
+ ///
+ /// Wraps a vault's key to another member, after verifying their published key.
+ ///
+ /// The grant calls.
+ /// The directory and the key log that makes it checkable.
+ /// The vault to share.
+ /// Who to share it with.
+ /// Cancellation token.
+ ///
+ ///
+ /// The verification is not optional and is not a parameter. A caller that could pass
+ /// skipChecks: true is a caller that will, on the day the log is briefly unreachable, and the
+ /// resulting grant is indistinguishable from a correct one afterwards.
+ ///
+ ///
+ /// What this still cannot promise is that the key belongs to the person you meant. Compare
+ /// with them over a channel this server does not carry;
+ /// that is the only step that closes the gap, and the outcome message says so.
+ ///
+ ///
+ public async Task ShareVaultAsync(
+ IVaultGrantApi grants,
+ IDirectoryApi directory,
+ Guid vaultId,
+ Guid recipientUserId,
+ CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ ArgumentNullException.ThrowIfNull(grants);
+ ArgumentNullException.ThrowIfNull(directory);
+
+ if (!keyring.TryGet(vaultId, out var vaultKey, out var keyGeneration))
+ {
+ throw new VaultUnreadableException(vaultId);
+ }
+
+ var entry = await directory.LookupByIdAsync(recipientUserId, cancellationToken)
+ .ConfigureAwait(false);
+
+ var log = await KeyLogAudit.ReadAsync(directory, cancellationToken).ConfigureAwait(false);
+ var verification = KeyLogAudit.Verify(log, entry);
+
+ if (!verification.IsVerified)
+ {
+ return new ShareOutcome(false, verification, verification.Message);
+ }
+
+ var recipient = verification.Recipient!;
+
+ await IssueAsync(grants, vaultId, vaultKey, keyGeneration, recipient, cancellationToken)
+ .ConfigureAwait(false);
+
+ return new ShareOutcome(
+ true,
+ verification,
+ "Shared. Check the fingerprint with them out of band — everything the client can verify on "
+ + "its own only proves this server has been consistent with itself.");
+ }
+
+ ///
+ /// Re-reads which vaults the server says are reachable, and opens any that have become readable.
+ ///
+ /// How many vaults this call made readable that were not before.
+ ///
+ /// Called after a share and on a periodic pass. A vault somebody shared a minute ago arrives as a
+ /// new entry with a wrapped key attached; one whose grant was revoked arrives without one, and is
+ /// marked unreadable rather than quietly dropped so the interface can say what happened. Items
+ /// already pulled are deliberately left alone — see .
+ ///
+ public async Task RefreshVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ ArgumentNullException.ThrowIfNull(api);
+
+ var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
+
+ await Vault.ReplaceAllAsync([.. me.Vaults.Select(ToStored)], cancellationToken)
+ .ConfigureAwait(false);
+
+ Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
+
+ var admitted = 0;
+
+ foreach (var vault in Vaults)
+ {
+ if (keyring.CanRead(vault.VaultId))
+ {
+ continue;
+ }
+
+ if (keyring.TryAdmit(bundle, vault))
+ {
+ admitted++;
+ }
+ else
+ {
+ keyring.MarkUnreadable(vault.VaultId);
+ }
+ }
+
+ return admitted;
+ }
+
+ /// Signs and posts one grant.
+ private async Task IssueAsync(
+ IVaultGrantApi grants,
+ Guid vaultId,
+ ReadOnlyMemory vaultKey,
+ uint keyGeneration,
+ VerifiedRecipient recipient,
+ CancellationToken cancellationToken)
+ {
+ var now = clock.GetUtcNow();
+ var entry = recipient.Entry;
+
+ var wrapped = VaultKeys.WrapTo(
+ vaultKey.Span, entry.EncryptionPublicKey, vaultId, keyGeneration);
+
+ var ownFingerprint = DshCrypto.ComputeFingerprint(
+ bundle.EncryptionPublicKey, bundle.SigningPublicKey);
+
+ var canonical = GrantStatementCodec.Encode(
+ vaultId,
+ keyGeneration,
+ GrantPurpose.Member,
+ granteeUserId: entry.UserId,
+ granteeKeyFingerprint: recipient.Fingerprint,
+ wrappedKey: wrapped,
+ granterUserId: Profile.UserId,
+ granterKeyFingerprint: ownFingerprint,
+
+ // Present, unlike a self-grant's. This is the third-party case the head exists for: it
+ // records which view of the key log this client held while wrapping, so a server showing
+ // two clients different logs has to keep both stories straight for ever after.
+ keyLogHead: recipient.KeyLogHead,
+ grantedAt: now);
+
+ await grants.IssueVaultGrantAsync(
+ vaultId,
+ new IssueVaultGrantRequest(
+ RecipientUserId: entry.UserId,
+ RecipientKeyFingerprint: recipient.Fingerprint,
+ KeyGeneration: keyGeneration,
+ WrappedVaultKey: wrapped,
+ KeyLogHead: recipient.KeyLogHead,
+ GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
+ GrantedAt: now),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// The signature covers the vault id, so the id has to be chosen before anything is wrapped — which
+ /// is also what makes a create whose response was lost safe to send again.
+ ///
+ private CreateTeamVaultRequest BuildCreateRequest(
+ Guid vaultId,
+ byte[] vaultKey,
+ string name,
+ DateTimeOffset now)
+ {
+ var wrapped = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
+
+ var fingerprint = DshCrypto.ComputeFingerprint(
+ bundle.EncryptionPublicKey, bundle.SigningPublicKey);
+
+ var canonical = GrantStatementCodec.Encode(
+ vaultId,
+ keyGeneration: 1,
+ GrantPurpose.Member,
+ granteeUserId: Profile.UserId,
+ granteeKeyFingerprint: fingerprint,
+ wrappedKey: wrapped,
+ granterUserId: Profile.UserId,
+ granterKeyFingerprint: fingerprint,
+ keyLogHead: default,
+ grantedAt: now);
+
+ return new CreateTeamVaultRequest(
+ VaultId: vaultId,
+ Name: name,
+ WrappedVaultKey: wrapped,
+ GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
+ GrantedAt: now);
+ }
+
+ private static StoredVault ToStored(VaultSummary summary) =>
+ new(
+ summary.VaultId,
+ summary.Name,
+ summary.IsPersonal,
+ summary.TeamId,
+ summary.KeyGeneration,
+ summary.Permissions,
+ summary.WrappedVaultKey,
+ summary.RekeyRequired);
+}
diff --git a/src/DodoSSH.Client.Session/packages.lock.json b/src/DodoSSH.Client.Session/packages.lock.json
index 074aff7..0b12ecc 100644
--- a/src/DodoSSH.Client.Session/packages.lock.json
+++ b/src/DodoSSH.Client.Session/packages.lock.json
@@ -141,6 +141,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
+ "NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -163,6 +164,12 @@
"DodoSSH.Crypto": "[1.0.0, )"
}
},
+ "dodossh.client.terminal": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Ssh": "[1.0.0, )"
+ }
+ },
"dodossh.contracts": {
"type": "Project"
},
diff --git a/src/DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj b/src/DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj
index 3938921..a8dbe38 100644
--- a/src/DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj
+++ b/src/DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj
@@ -27,6 +27,19 @@
+
+
+
+
diff --git a/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
new file mode 100644
index 0000000..570145f
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
@@ -0,0 +1,229 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Import;
+
+namespace DodoSSH.Client.Shell.ViewModels;
+
+/// One host an ssh_config offered, as a row somebody decides about.
+///
+/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
+/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
+/// rather than an action.
+///
+internal sealed partial class ImportRowViewModel : ObservableObject
+{
+ private readonly ImportedHost host;
+
+ internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
+ {
+ this.host = host;
+ AlreadyPresent = alreadyPresent;
+
+ // A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
+ // for one machine is a thing people genuinely want — but it should take a click rather than be the
+ // default.
+ IsSelected = !alreadyPresent;
+ }
+
+ internal ImportedHost Host => host;
+
+ internal string Alias => host.Alias;
+
+ internal string Address => host.Address;
+
+ /// Whether a host with this address is already in the keychain.
+ internal bool AlreadyPresent { get; }
+
+ internal string Badge => AlreadyPresent ? "already here" : string.Empty;
+
+ internal bool HasBadge => AlreadyPresent;
+
+ /// How this would authenticate, in the terms the preview can honestly offer.
+ ///
+ /// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
+ /// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
+ /// connection that does not work.
+ ///
+ internal string Authentication => host.IdentityFiles.Count switch
+ {
+ 0 => "password",
+ 1 => $"a key on disk · {host.IdentityFiles[0]}",
+ var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
+ };
+
+ internal bool HasWarnings => host.Warnings.Count > 0;
+
+ internal string Warnings => string.Join(" ", host.Warnings);
+
+ [ObservableProperty]
+ private bool isSelected;
+}
+
+///
+/// Reading ~/.ssh/config and offering what it found.
+///
+///
+///
+/// Two steps, and the first one writes nothing. Scanning reads the file and shows what it means;
+/// importing is a separate press. That split is the feature: an ssh_config is a file this
+/// application did not write and may contain forty entries for machines that no longer exist, so the
+/// interesting question is not "can it be parsed" but "which of these did you actually want".
+///
+///
+/// Nothing reads a private key. An IdentityFile becomes a directive and a note recording the
+/// path. Pulling someone's ~/.ssh/id_ed25519 into a keychain as a side effect of importing a config
+/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
+/// for making one deliberately, and pasting an existing one is a deliberate act too.
+///
+///
+internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
+{
+ internal ObservableCollection Rows { get; } = [];
+
+ /// What was skipped or flattened, at document level.
+ internal ObservableCollection Warnings { get; } = [];
+
+ /// The file this would read, shown so nobody has to guess which one it means.
+ internal string ConfigPath => locator.ConfigPath;
+
+ [ObservableProperty]
+ private string status = string.Empty;
+
+ [ObservableProperty]
+ private bool hasScanned;
+
+ [ObservableProperty]
+ private bool isBusy;
+
+ internal bool HasRows => Rows.Count > 0;
+
+ internal bool HasWarnings => Warnings.Count > 0;
+
+ internal int SelectedCount => Rows.Count(row => row.IsSelected);
+
+ internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
+
+ /// Reads the file and shows what it found. Writes nothing.
+ [RelayCommand]
+ private async Task ScanAsync(CancellationToken cancellationToken)
+ {
+ Rows.Clear();
+ Warnings.Clear();
+ HasScanned = false;
+
+ if (!locator.Exists)
+ {
+ Status = $"There is no {locator.ConfigPath} on this machine.";
+ RaiseListState();
+ return;
+ }
+
+ IsBusy = true;
+
+ try
+ {
+ var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);
+
+ foreach (var host in import.Hosts)
+ {
+ Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
+ }
+
+ foreach (var warning in import.Warnings)
+ {
+ Warnings.Add(warning);
+ }
+
+ HasScanned = true;
+
+ Status = Rows.Count == 0
+ ? "Nothing in that file could be imported as a host."
+ : $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
+ }
+ catch (IOException failure)
+ {
+ Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
+ }
+ catch (UnauthorizedAccessException failure)
+ {
+ Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
+ }
+ finally
+ {
+ IsBusy = false;
+ RaiseListState();
+ }
+ }
+
+ /// Stores the ticked hosts.
+ [RelayCommand]
+ private async Task ImportAsync(CancellationToken cancellationToken)
+ {
+ var chosen = Rows.Where(row => row.IsSelected).ToList();
+
+ if (chosen.Count == 0)
+ {
+ Status = "Nothing is ticked.";
+ return;
+ }
+
+ IsBusy = true;
+
+ try
+ {
+ var imported = await vault
+ .ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
+ .ConfigureAwait(true);
+
+ // Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
+ // pressing the button twice harmless and visible rather than harmless and confusing.
+ foreach (var row in Rows.ToList())
+ {
+ Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
+ }
+
+ Status = $"Imported {imported} host(s). They are on the Hosts screen.";
+ }
+ finally
+ {
+ IsBusy = false;
+ RaiseListState();
+ }
+ }
+
+ /// Ticks or unticks everything at once.
+ [RelayCommand]
+ private void ToggleAll()
+ {
+ var target = SelectedCount < Rows.Count;
+
+ foreach (var row in Rows)
+ {
+ row.IsSelected = target;
+ }
+
+ RaiseListState();
+ }
+
+ internal void NoteSelectionChanged() => RaiseListState();
+
+ ///
+ /// Matched on where a host points rather than on what it is called. Two entries with different aliases
+ /// for one machine are the ordinary shape of an ssh_config , and matching on the name would offer
+ /// to import a duplicate of something already stored under another name.
+ ///
+ private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
+ string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
+ && existing.Host.Port == host.Port
+ && string.Equals(existing.Host.Username, host.Username, StringComparison.OrdinalIgnoreCase));
+
+ private void RaiseListState()
+ {
+ OnPropertyChanged(nameof(HasRows));
+ OnPropertyChanged(nameof(HasWarnings));
+ OnPropertyChanged(nameof(SelectedCount));
+ OnPropertyChanged(nameof(ImportLabel));
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs
new file mode 100644
index 0000000..c69c8a3
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/ViewModels/KnownHostsViewModel.cs
@@ -0,0 +1,245 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.Globalization;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Sync;
+
+namespace DodoSSH.Client.Shell.ViewModels;
+
+/// One pinned host key, as a row in the list.
+///
+///
+/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
+/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
+/// leaves its pin, and so does changing a host's address. Both are correct as trust decisions: the
+/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
+/// What was wrong was that nothing ever showed them.
+///
+///
+/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
+/// of pinning one is to compare it with what they published.
+///
+///
+internal sealed class KnownHostRowViewModel(
+ VaultItem pin,
+ bool isDialledByAHost,
+ Guid vaultId,
+ string vaultName)
+{
+ /// Which vault this pin lives in. See .
+ internal Guid VaultId => vaultId;
+
+ /// The vault's display name.
+ internal string VaultName => vaultName;
+
+ internal Guid EntityId => pin.EntityId;
+
+ internal KnownHostSecret Pin => pin.Secret;
+
+ internal string Host => pin.Secret.Host;
+
+ internal int Port => pin.Secret.Port;
+
+ internal string Algorithm => pin.Secret.Algorithm;
+
+ /// The endpoint and algorithm, which is what a pin actually identifies.
+ internal string Label => pin.Secret.Label;
+
+ /// The fingerprint, in full.
+ ///
+ /// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
+ /// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
+ /// this whole mechanism exists to replace.
+ ///
+ internal string Fingerprint => pin.Secret.Fingerprint;
+
+ ///
+ /// Whether any host in this vault actually dials the endpoint this pin is for.
+ ///
+ ///
+ /// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
+ /// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
+ /// worth deleting on the user's behalf.
+ ///
+ internal bool IsDialledByAHost { get; } = isDialledByAHost;
+
+ internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
+
+ internal string Badge => IsDialledByAHost
+ ? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
+ : "no host uses this";
+
+ ///
+ /// When this pin was approved, as far as anything here can tell.
+ ///
+ ///
+ /// Derived from the entity id, which this client mints with — see
+ /// . No vault item carries a timestamp, so the alternative was no column at
+ /// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
+ /// created and not when it was last re-approved, and an id minted by anything that does not use v7
+ /// renders as a dash rather than as a guess.
+ ///
+ internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
+ ? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
+ : "—";
+}
+
+///
+/// The host keys this keychain has approved, and how to withdraw one.
+///
+///
+///
+/// A wrapper over the vault rather than a view model of its own. Everything about a pin — reading
+/// them, forgetting one, pushing the change — already lives on , wired into its
+/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
+/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
+/// it produces, neither of which the vault has any use for.
+///
+///
+/// The filter matches fingerprints, deliberately. The workflow this screen exists for is "the
+/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
+/// answer a question nobody is asking.
+///
+///
+internal sealed partial class KnownHostsViewModel : ObservableObject
+{
+ private readonly VaultViewModel vault;
+
+ internal KnownHostsViewModel(VaultViewModel vault)
+ {
+ this.vault = vault;
+
+ // The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
+ // copy of a trust decision is the one kind of staleness that matters here.
+ vault.KnownHostPins.CollectionChanged += OnPinsChanged;
+
+ Rebuild();
+ }
+
+ /// The pins this filter admits, in the order the vault produced them.
+ ///
+ /// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
+ /// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
+ /// — host, then port, then algorithm — is the one worth keeping.
+ ///
+ internal ObservableCollection VisiblePins { get; } = [];
+
+ [ObservableProperty]
+ private string filter = string.Empty;
+
+ /// The row the list has selected, mirrored onto the vault so its command can act on it.
+ ///
+ /// Pushed down rather than duplicated: ForgetPinCommand reads VaultViewModel.SelectedKnownHost
+ /// and there is no reason for it to learn about this screen.
+ ///
+ [ObservableProperty]
+ private KnownHostRowViewModel? selected;
+
+ internal bool HasPins => vault.KnownHostPins.Count > 0;
+
+ internal bool HasVisiblePins => VisiblePins.Count > 0;
+
+ internal bool HasSelection => Selected is not null;
+
+ /// What the whole list amounts to, in one line.
+ ///
+ /// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
+ /// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
+ /// might want to act on, and counting them is cheaper than reading a badge column.
+ ///
+ internal string Summary
+ {
+ get
+ {
+ var total = vault.KnownHostPins.Count;
+
+ if (total == 0)
+ {
+ return string.Empty;
+ }
+
+ var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
+ var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
+
+ return unused == 0
+ ? pins
+ : string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
+ }
+ }
+
+ internal string EmptyMessage => HasPins
+ ? "No approved host key matches that."
+ : "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
+ + "check — approving it puts it here.";
+
+ /// Withdraws trust in the selected pin.
+ ///
+ /// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
+ /// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
+ /// server are the other ones.
+ ///
+ [RelayCommand]
+ private async Task ForgetSelectedAsync()
+ {
+ if (Selected is null)
+ {
+ return;
+ }
+
+ await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
+ }
+
+ internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
+
+ partial void OnFilterChanged(string value) => Rebuild();
+
+ partial void OnSelectedChanged(KnownHostRowViewModel? value)
+ {
+ vault.SelectedKnownHost = value;
+ OnPropertyChanged(nameof(HasSelection));
+ }
+
+ private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
+
+ private void Rebuild()
+ {
+ // Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
+ // way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
+ // writes that null straight back before the refill can matter.
+ var selectedId = Selected?.EntityId;
+
+ VisiblePins.Clear();
+
+ foreach (var pin in vault.KnownHostPins.Where(Matches))
+ {
+ VisiblePins.Add(pin);
+ }
+
+ Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
+
+ OnPropertyChanged(nameof(HasPins));
+ OnPropertyChanged(nameof(HasVisiblePins));
+ OnPropertyChanged(nameof(Summary));
+ OnPropertyChanged(nameof(EmptyMessage));
+ }
+
+ private bool Matches(KnownHostRowViewModel pin)
+ {
+ if (string.IsNullOrWhiteSpace(Filter))
+ {
+ return true;
+ }
+
+ var needle = Filter.Trim();
+
+ return Contains(pin.Host, needle)
+ || Contains(pin.Algorithm, needle)
+ || Contains(pin.Fingerprint, needle)
+ || Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
+ }
+
+ private static bool Contains(string haystack, string needle) =>
+ haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/LogsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/LogsViewModel.cs
new file mode 100644
index 0000000..fce71a6
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/ViewModels/LogsViewModel.cs
@@ -0,0 +1,293 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Sync;
+
+namespace DodoSSH.Client.Shell.ViewModels;
+
+/// Which log the screen is showing.
+internal enum LogSection
+{
+ /// Connections that were made.
+ Connections,
+
+ /// Changes made to keychain items.
+ Activity,
+}
+
+/// One connection, as a row.
+internal sealed class ConnectionLogRowViewModel(VaultItem entry, bool isLive)
+{
+ internal Guid EntityId => entry.EntityId;
+
+ internal string HostLabel => entry.Secret.HostLabel;
+
+ internal string Address => entry.Secret.Address;
+
+ /// When it started, in the reader's own conventions.
+ ///
+ /// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
+ /// difference is the reason each is right. There, two panes are read against one another and a
+ /// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
+ /// machine", which is a question about the reader's own day. InvariantGlobalization is false in
+ /// the client csproj precisely so this works.
+ ///
+ internal string Started =>
+ entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
+
+ ///
+ /// How long it lasted, or that it has not finished.
+ ///
+ ///
+ /// "still open" and not a dash. A dash reads as "nothing was recorded", and the two are opposite
+ /// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
+ /// session has no entry at all until it closes, so this state comes from the workspace rather than from
+ /// the vault; see .
+ ///
+ internal string Duration => isLive
+ ? "still open"
+ : Humanise(entry.Secret.Duration);
+
+ internal bool IsLive => isLive;
+
+ internal string Outcome => entry.Secret.Outcome switch
+ {
+ ConnectionOutcome.Failed => "failed",
+ ConnectionOutcome.Refused => "host key refused",
+ _ => string.Empty,
+ };
+
+ internal bool HasOutcome => Outcome.Length > 0;
+
+ /// Whether this was a terminal or the file browser.
+ internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
+
+ internal string DeviceName => entry.Secret.DeviceName;
+
+ ///
+ /// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
+ /// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
+ /// rounding themselves.
+ ///
+ private static string Humanise(TimeSpan duration)
+ {
+ if (duration < TimeSpan.FromMinutes(1))
+ {
+ return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
+ }
+
+ if (duration < TimeSpan.FromHours(1))
+ {
+ return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
+ }
+
+ return string.Create(
+ CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
+ }
+}
+
+/// One keychain change, as a row.
+internal sealed class ActivityLogRowViewModel(VaultItem entry)
+{
+ internal Guid EntityId => entry.EntityId;
+
+ internal string ItemLabel => entry.Secret.ItemLabel;
+
+ internal string ItemKind => entry.Secret.ItemKind;
+
+ internal string Operation => entry.Secret.Operation switch
+ {
+ ActivityOperation.Created => "created",
+ ActivityOperation.Deleted => "deleted",
+ _ => "changed",
+ };
+
+ ///
+ internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
+
+ /// Which fields changed. Never what they changed to.
+ internal string ChangedFields => entry.Secret.ChangedFields;
+
+ internal bool HasChangedFields => ChangedFields.Length > 0;
+
+ internal string DeviceName => entry.Secret.DeviceName;
+}
+
+///
+/// What has been connected to, and what has been changed.
+///
+///
+///
+/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
+/// section switch and one thing neither log knows: which connections are happening now . An entry is
+/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
+/// workspace, and this screen is where the two are put side by side.
+///
+///
+/// Read on demand rather than kept in step. Unlike the host list, a log is not something a background
+/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
+/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
+/// nobody is looking at.
+///
+///
+internal sealed partial class LogsViewModel : ObservableObject
+{
+ private readonly VaultSession session;
+ private readonly Func> live;
+
+ /// The open vault, which holds both logs.
+ ///
+ /// The connections that are open right now. A function rather than a list, because tabs open and close
+ /// while this screen is showing and it is not told about either.
+ ///
+ internal LogsViewModel(VaultSession session, Func> live)
+ {
+ this.session = session;
+ this.live = live;
+ }
+
+ /// Connections, newest first, with anything still open at the top.
+ internal ObservableCollection Connections { get; } = [];
+
+ /// Keychain changes, newest first.
+ internal ObservableCollection Activity { get; } = [];
+
+ ///
+ /// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
+ /// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
+ /// a command can refuse it.
+ ///
+ [ObservableProperty]
+ private LogSection section;
+
+ [ObservableProperty]
+ private bool isBusy;
+
+ [ObservableProperty]
+ private string status = string.Empty;
+
+ internal bool ShowsConnections => Section is LogSection.Connections;
+
+ internal bool ShowsActivity => Section is LogSection.Activity;
+
+ internal bool HasConnections => Connections.Count > 0;
+
+ internal bool HasActivity => Activity.Count > 0;
+
+ internal string EmptyMessage => Section is LogSection.Connections
+ ? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
+ + "top and gets its line when you close the tab."
+ : "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
+ + "names of the fields that changed, never their contents.";
+
+ /// Shows one of the two logs.
+ [RelayCommand]
+ private void ShowSection(LogSection section) => Section = section;
+
+ /// Re-reads both logs.
+ [RelayCommand]
+ private async Task RefreshAsync(CancellationToken cancellationToken)
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ IsBusy = true;
+
+ try
+ {
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ Status = string.Empty;
+ }
+ catch (OperationCanceledException)
+ {
+ // Leaving the screen.
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ Status = exception.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ /// Reads both logs into the lists.
+ internal async Task ReloadAsync(CancellationToken cancellationToken)
+ {
+ var connections = await session.ConnectionLog
+ .ListAsync(session.ActiveVaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ var activity = await session.ActivityLog
+ .ListAsync(session.ActiveVaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ Connections.Clear();
+
+ // The live ones first and above everything, because they are the only rows in this list that are
+ // still changing. They carry no entity id — there is no vault item for them yet — which is why they
+ // are built from a different source and marked as live rather than merged into the same shape.
+ foreach (var open in live())
+ {
+ Connections.Add(new ConnectionLogRowViewModel(
+ new VaultItem(
+ Guid.Empty,
+ new ConnectionLogSecret
+ {
+ HostLabel = open.HostLabel,
+ Address = open.Address,
+ StartedAt = open.StartedAt,
+ DeviceName = open.DeviceName,
+ },
+ Version: 0,
+ HasUnsyncedChanges: false,
+ IsBlocked: false,
+ IsReadOnly: false),
+ isLive: true));
+ }
+
+ foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
+ {
+ Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
+ }
+
+ Activity.Clear();
+
+ foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
+ {
+ Activity.Add(new ActivityLogRowViewModel(entry));
+ }
+
+ OnPropertyChanged(nameof(HasConnections));
+ OnPropertyChanged(nameof(HasActivity));
+ }
+
+ partial void OnSectionChanged(LogSection value)
+ {
+ OnPropertyChanged(nameof(ShowsConnections));
+ OnPropertyChanged(nameof(ShowsActivity));
+ OnPropertyChanged(nameof(EmptyMessage));
+ }
+}
+
+/// A connection that is open right now.
+/// What the host is called.
+/// The address as dialled.
+/// When it opened.
+/// This machine.
+///
+/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
+/// that is still running has no entry there, because an entry is written once and at close — which is what
+/// keeps a synced log from needing a merge.
+///
+internal sealed record LiveConnection(
+ string HostLabel,
+ string Address,
+ DateTimeOffset StartedAt,
+ string DeviceName);
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index 4347427..bb791f3 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -6,6 +6,8 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Auth;
+using DodoSSH.Client.Import;
+using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
@@ -61,7 +63,7 @@ internal enum ShellState
///
internal enum ShellScreen
{
- /// The host list and the terminals, which is where the application opens.
+ /// The host list, which is where the application opens.
Hosts = 0,
/// File transfer over SFTP: two directory panes and a queue.
@@ -75,6 +77,52 @@ internal enum ShellScreen
/// Preferences.
Preferences = 4,
+
+ /// The host keys this keychain has approved.
+ ///
+ /// Appended rather than slotted in beside the keychain screen it came out of. These values are written
+ /// into NavRail.axaml as x:Static literals and read by tests; renumbering them would be a
+ /// silent change to what every one of those means.
+ ///
+ KnownHosts = 5,
+
+ /// Importing hosts from the machine's own ~/.ssh/config .
+ ///
+ /// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
+ /// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
+ /// something almost nobody is looking at.
+ ///
+ Import = 6,
+
+ /// The saved commands in this keychain.
+ ///
+ Snippets = 7,
+
+ /// What has been connected to, and what has been changed.
+ ///
+ Logs = 8,
+}
+
+///
+/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
+///
+///
+///
+/// Two properties rather than a sixth , and the reason is that a terminal is not a
+/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
+/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
+/// means "which page" has to survive "a terminal is showing". Folding the terminal into
+/// would need a private field remembering the page underneath, which is this pair
+/// with one half hidden.
+///
+///
+internal enum ShellSurface
+{
+ /// The screen named by .
+ Page = 0,
+
+ /// The pane of the tab named by .
+ Terminal = 1,
}
///
@@ -126,6 +174,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
+ ///
+ /// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
+ /// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
+ /// rather than one that fails silently — see .
+ ///
+ private readonly Func? copyToClipboard;
+
///
/// Created once and kept for the life of the process, like and for the same
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
@@ -134,6 +189,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
private readonly TransfersViewModel transfers;
+ ///
+ /// Where connections are recorded, for as long as a vault is open to record them into.
+ ///
+ ///
+ /// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
+ /// and for the same reason: the thing that calls it — the workspace — outlives every lock.
+ ///
+ private readonly ConnectionRecorder connectionLog;
+
+ private readonly TeamsViewModel teams;
+
private IVaultServer? connection;
/// The refresh token last written to the cache, so a rotation is noticed without reading it back.
@@ -188,7 +254,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TimeProvider clock,
ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null,
- ResumeHandler? resume = null)
+ ResumeHandler? resume = null,
+ Func? copyToClipboard = null)
{
this.paths = paths;
this.caches = caches;
@@ -199,9 +266,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
this.resume = resume;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
+ this.copyToClipboard = copyToClipboard;
transfers = new TransfersViewModel(sftpSessions, clock);
+ // Built once, like the workspace it writes for, and given a vault only while one is open. It has to
+ // outlive every lock for the same reason the workspace does: a shell opened before a lock is still
+ // running after it, and the entry it eventually produces belongs to the vault it was made in.
+ connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
+ this.workspace.ConnectionLog = connectionLog;
+
+ // Both dependencies as functions rather than values: the connection arrives after sign-in and the
+ // session after unlock, and both go away again on lock. Capturing either would give this screen a
+ // reference that outlives what it points at — which for a session means holding vault keys past the
+ // moment locking is supposed to have zeroed them.
+ teams = new TeamsViewModel(() => connection, () => Vault?.Session);
+
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
@@ -273,6 +353,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private VaultViewModel? vault;
+ /// The approved-host-keys screen, which exists exactly as long as the vault behind it does.
+ ///
+ /// Assigned from and nowhere else, so the three paths that open or close a
+ /// vault — unlocking, locking and signing out — cannot get out of step with it.
+ ///
+ [ObservableProperty]
+ private KnownHostsViewModel? knownHostsScreen;
+
+ ///
+ [ObservableProperty]
+ private ImportViewModel? importScreen;
+
+ ///
+ [ObservableProperty]
+ private SnippetsViewModel? snippetsScreen;
+
+ ///
+ [ObservableProperty]
+ private LogsViewModel? logsScreen;
+
+ ///
+ /// The teams screen, which the window binds to whether or not a vault is open.
+ ///
+ ///
+ /// Not nullable and never replaced, for the reason is not: the screen reads a
+ /// server rather than a vault, and both of its dependencies are fetched through a function at the
+ /// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
+ /// to rebuild it, and the list it is showing survives both.
+ ///
+ internal TeamsViewModel Teams => teams;
+
/// The transfers screen, which the window binds to whether or not a vault is open.
///
/// Not nullable and never replaced, unlike . The screen is unreachable while locked —
@@ -370,9 +481,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// ---- Which screen is showing ----
+ ///
+ /// Which of the nav rail's screens the page area holds.
+ ///
+ ///
+ /// This always names a page, even while a terminal is showing over it — see .
+ /// It is what dismissing a terminal returns to.
+ ///
[ObservableProperty]
private ShellScreen screen;
+ ///
+ /// Whether the page area is showing rather than a terminal.
+ ///
+ ///
+ /// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
+ /// IsHostsScreen && IsShowingPages in a binding, so the alternative is five compound
+ /// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
+ /// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
+ /// cannot be clicked. See .
+ ///
+ internal bool IsShowingPages => Surface is ShellSurface.Page;
+
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
///
@@ -387,6 +517,51 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
+ ///
+ internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
+
+ ///
+ internal bool IsImportScreen => Screen is ShellScreen.Import;
+
+ ///
+ internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
+
+ ///
+ internal bool IsLogsScreen => Screen is ShellScreen.Logs;
+
+ ///
+ /// Whether the nav rail should light its Hosts entry.
+ ///
+ ///
+ /// Not the same question as , and the rail has to ask this one. A terminal
+ /// opened from the hosts screen leaves on Hosts — deliberately, so closing the tab
+ /// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
+ /// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
+ /// at once is one too many.
+ ///
+ internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
+
+ ///
+ internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
+
+ ///
+ internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
+
+ ///
+ internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
+
+ ///
+ internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
+
+ ///
+ internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
+
+ ///
+ internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
+
+ ///
+ internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
+
///
/// Whether the terminal's WebView may be on screen at this instant.
///
@@ -396,16 +571,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
- /// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
- /// screens all use the full width), and the quick-connect palette.
+ /// locked vault (the unlock card), the page area (every screen uses the full width), and the
+ /// quick-connect palette.
///
///
- /// Not gated on there being a tab. That was tried, so that the empty terminal could carry a
- /// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
- /// Focus() that hands it the keyboard, which is the one moment on the connect path that has to
- /// work. NativeControlHost re-pushes its bounds on the next layout pass, so focusing a control
- /// that became visible microseconds earlier is a race against exactly the thing it depends on. The
- /// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
+ /// The terminal and the pages are exclusive, and that is the whole of the rule. They share one
+ /// rectangle, so exactly one of and this may be true. That is why
+ /// exists as a single enum rather than as two independent flags a caller could set
+ /// to the same value.
+ ///
+ ///
+ /// Not gated on there being a tab. Closing the last tab returns to
+ /// instead, so the empty case never arises — and gating here as well
+ /// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
+ /// Avalonia draws and nothing occludes.
+ ///
+ ///
+ /// Revealing and focusing now happen in the same turn, routinely. Opening a terminal from the
+ /// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
+ /// want the keyboard. NativeControlHost re-pushes its bounds on the next layout pass, so focusing
+ /// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
+ /// posting the focus at DispatcherPriority.Loaded — see MainWindow.axaml.cs . It is not
+ /// answered here, and it cannot be: this property has no way to know when layout ran.
///
///
/// Collapsing is cheap and safe. NativeControlHost creates the native control on attach rather
@@ -414,11 +601,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// safe — that detaches it and destroys the whole WebView2 process tree.
///
///
- internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
+ internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
+
+ ///
+ [ObservableProperty]
+ private ShellSurface surface;
/// Points the nav rail at a screen.
+ ///
+ /// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
+ /// something else" and a rail click that changed a screen nobody could see would do nothing visible.
+ /// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
+ ///
[RelayCommand]
- private void ShowScreen(ShellScreen target) => Screen = target;
+ private void ShowScreen(ShellScreen target)
+ {
+ Screen = target;
+ Surface = ShellSurface.Page;
+ }
// ---- Open terminals ----
@@ -470,6 +670,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
+ // The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
+ // terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
+ // leave a visible WebView with no pane in it, which reads as the application having broken.
+ if (Tabs.Count == 0)
+ {
+ Surface = ShellSurface.Page;
+ }
+
RaiseTabState();
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
@@ -566,7 +774,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
CloseSearch();
+ // The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
+ // changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
+ // screen — so connecting from the files screen without this would put the question behind the screen
+ // that asked it, with the connection blocked on an answer the user cannot reach. The session opening
+ // is what moves the surface to the terminal, and only if there is one.
Screen = ShellScreen.Hosts;
+ Surface = ShellSurface.Page;
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
@@ -746,7 +960,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
await RunAsync(
- "Creating your vault. This deliberately takes a moment…",
+ "Creating your keychain. This deliberately takes a moment…",
async () =>
{
var chosen = Passphrase;
@@ -796,7 +1010,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
if (Passphrase.Length == 0)
{
- StatusMessage = "Enter your vault passphrase.";
+ StatusMessage = "Enter your keychain passphrase.";
return;
}
@@ -950,23 +1164,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{
- // Before the vault view model, so the first connection after an unlock already knows which host keys
- // this user has approved. Reading them is one listing; doing it here rather than lazily is what
- // keeps it off the SSH handshake thread.
- try
- {
- await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
- }
- catch
- {
- // Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
- // session is vault keys left in memory for the life of the process, which is precisely what
- // unlocking must be able to undo.
- await session.DisposeAsync().ConfigureAwait(true);
- throw;
- }
+ await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
- Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
+ Vault = new VaultViewModel(
+ session,
+ workspace,
+ knownHosts,
+ () => connection,
+ ReconnectAsync,
+ copyToClipboard,
+ connectionLog);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -984,7 +1191,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
- transfers.Attach(Vault, knownHosts);
+ transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
@@ -1007,6 +1214,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Vault.StartAutoSync();
}
+ ///
+ /// Points the two process-lifetime stores at the session that has just opened.
+ ///
+ ///
+ /// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
+ /// called by the workspace — so both are attached here rather than constructed per session, and both are
+ /// released together on every path that closes a vault.
+ ///
+ private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
+ {
+ // Before the vault view model, so the first connection after an unlock already knows which host keys
+ // this user has approved. Reading them is one listing; doing it here rather than lazily is what
+ // keeps it off the SSH handshake thread.
+ try
+ {
+ await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
+ }
+ catch
+ {
+ // Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
+ // session is vault keys left in memory for the life of the process, which is precisely what
+ // unlocking must be able to undo.
+ await session.DisposeAsync().ConfigureAwait(true);
+ throw;
+ }
+
+ // The actor is the account that unlocked, which is what makes this an audit record rather than a
+ // list of events with nobody attached to them.
+ connectionLog.Open(session, session.Profile.UserId);
+ }
+
///
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
///
@@ -1214,6 +1452,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// reappearing behind a lock screen.
knownHosts.Close();
+ // Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
+ // about to be disposed. Tickets already open keep the repository they were opened against, so a
+ // shell still running closes out into the vault it was actually made in.
+ connectionLog.Close();
+
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
// holding references to them. What it does not give up is its connection or its queue — a transfer
// in flight is exactly the work this method exists not to destroy.
@@ -1264,7 +1507,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
(false, _) =>
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
- + "counted from here, because the vault is locked.",
+ + "counted from here, because the keychain is locked.",
(true, 0) =>
"Everything this machine has changed has reached the server, so nothing will be lost.",
(true, 1) =>
@@ -1328,6 +1571,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// As Lock does, and before the session it reads from goes.
knownHosts.Close();
+ connectionLog.Close();
// The same detach locking does, and the same reasoning carried one step further: the host
// rows go because the vault behind them is about to be disposed, and the session and its
@@ -1364,8 +1608,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
- StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
- + "itself is untouched. Sign in to set this machine up again.";
+ StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
+ + "keychain itself is untouched. Sign in to set this machine up again.";
}).ConfigureAwait(true);
}
@@ -1412,6 +1656,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
knownHosts.Close();
+ // Detached before it is disposed, so a session torn down after this point finds nothing to post to
+ // rather than a completed channel. Disposed rather than merely closed, because it owns a background
+ // task — and it waits only as long as that task takes to stop, never for the queue to drain.
+ workspace.ConnectionLog = null;
+ await connectionLog.DisposeAsync().ConfigureAwait(false);
+
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
// that reached it.
@@ -1547,6 +1797,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
}
+ // Built from the vault and thrown away with it, here rather than at each of the three places a
+ // vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
+ // would keep a disposed vault alive and repaint a screen nobody can reach.
+ KnownHostsScreen?.Detach();
+ KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
+ ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
+
+ SnippetsScreen?.Detach();
+ SnippetsScreen = newValue is null
+ ? null
+ : new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
+
+ LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
+
RaiseSyncState();
}
@@ -1579,13 +1843,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
///
- /// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
- /// runs against a tab strip that already shows the session it is focusing.
+ /// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
+ /// them and nothing more — everything about becoming a tab is in .
///
- private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
- {
- var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
+ private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
+ AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
+ ///
+ /// Takes a newly opened session into the tab strip and shows it.
+ ///
+ ///
+ /// One method rather than one per way of opening a session, so the order of these four steps is decided
+ /// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
+ /// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
+ ///
+ private void AdoptTab(TerminalTabViewModel tab)
+ {
Tabs.Add(tab);
RaiseTabState();
@@ -1594,6 +1867,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// and load-bearing for every one after it.
SelectedTab = tab;
+ // The surface, but deliberately not the screen. A session opened from the files screen shows its
+ // terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
+ // clicking away comes back to the transfer that is presumably still running.
+ Surface = ShellSurface.Terminal;
+
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
@@ -1617,15 +1895,50 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RefreshConnectedHosts();
+ // The snippets screen names the terminal its buttons will type into, and it has no way to learn that
+ // a different tab is selected — the tab list is the shell's, and a subscription the other way would
+ // be a screen keeping the shell alive.
+ SnippetsScreen?.TargetChanged();
+
if (value is not null)
{
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
}
}
- /// Brings one terminal's pane to the front.
+ /// Which terminal a snippet would go into right now.
+ ///
+ /// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
+ /// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
+ /// say, the most recently opened would send a command somewhere the user is not looking.
+ ///
+ /// The connections that are open and therefore have no log entry yet.
+ ///
+ /// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
+ /// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
+ /// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
+ ///
+ private IReadOnlyList LiveConnections() =>
+ [
+ .. connectionLog.Open().Select(open => new LiveConnection(
+ open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
+ ];
+
+ private InsertTarget CurrentInsertTarget() =>
+ SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
+
+ /// Brings one terminal's pane to the front, and shows it.
+ ///
+ /// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
+ /// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
+ /// would answer only one of those.
+ ///
[RelayCommand]
- private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
+ private void SelectTab(TerminalTabViewModel tab)
+ {
+ SelectedTab = tab;
+ Surface = ShellSurface.Terminal;
+ }
///
/// Marks a tab dead when its shell ends on its own.
@@ -1689,10 +2002,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
- // The hosts screen is what this application is for.
+ // The hosts screen is what this application is for. The surface as well as the screen: shells outlive
+ // a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
+ // the application would not be what "unlocked" looks like.
if (value is ShellState.Unlocked)
{
Screen = ShellScreen.Hosts;
+ Surface = ShellSurface.Page;
}
}
@@ -1702,12 +2018,57 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// directions, and raising only the one that became true leaves the old button lit.
///
partial void OnScreenChanged(ShellScreen value)
+ {
+ RaiseSurfaceState();
+
+ // Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
+ // thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
+ // appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
+ if (value is ShellScreen.Logs && LogsScreen is { } logs)
+ {
+ _ = logs.RefreshCommand.ExecuteAsync(null);
+ }
+
+ // Teams are read from the server rather than from the vault, so there is nothing to show until
+ // somebody asks for it — and asking for it on every unlock would be a request per launch for a
+ // screen most people never open. Fire-and-forget because a property change cannot await, and
+ // because the view model turns every failure into its own status line rather than throwing.
+ if (value is ShellScreen.Team)
+ {
+ _ = teams.LoadAsync(CancellationToken.None);
+ }
+ }
+
+ ///
+ partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
+
+ ///
+ /// Both changes raise the same set, and they have to: and its four siblings
+ /// read and together, so which of the two moved does not
+ /// narrow what became stale.
+ ///
+ private void RaiseSurfaceState()
{
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsVaultScreen));
OnPropertyChanged(nameof(IsTeamScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
+ OnPropertyChanged(nameof(IsKnownHostsScreen));
+ OnPropertyChanged(nameof(IsImportScreen));
+ OnPropertyChanged(nameof(IsSnippetsScreen));
+ OnPropertyChanged(nameof(IsLogsScreen));
+
+ OnPropertyChanged(nameof(IsShowingPages));
+ OnPropertyChanged(nameof(IsHostsShowing));
+ OnPropertyChanged(nameof(IsTransfersShowing));
+ OnPropertyChanged(nameof(IsVaultShowing));
+ OnPropertyChanged(nameof(IsTeamShowing));
+ OnPropertyChanged(nameof(IsPreferencesShowing));
+ OnPropertyChanged(nameof(IsKnownHostsShowing));
+ OnPropertyChanged(nameof(IsSnippetsShowing));
+ OnPropertyChanged(nameof(IsLogsShowing));
+
OnPropertyChanged(nameof(IsTerminalShowing));
}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
new file mode 100644
index 0000000..77621ad
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
@@ -0,0 +1,338 @@
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DodoSSH.Client.Domain;
+
+namespace DodoSSH.Client.Shell.ViewModels;
+
+/// Where a snippet is about to be inserted, and whether it can be.
+/// The terminal, or null when there is none open.
+/// What that terminal is called, for the button.
+internal sealed record InsertTarget(uint? SessionId, string Label)
+{
+ /// The answer when no tab is open.
+ internal static InsertTarget None { get; } = new(null, string.Empty);
+
+ /// Whether there is somewhere to insert into.
+ internal bool IsAvailable => SessionId is not null;
+}
+
+///
+/// The saved commands in this keychain, and how to get one into a terminal.
+///
+///
+///
+/// A wrapper over the vault, as KnownHostsViewModel is , and for the same reason: reading
+/// snippets, storing one and pushing the change already live on , wired into its
+/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
+/// the vault has any use for.
+///
+///
+/// The safety story is the copy, not the code. A terminal is one input stream with no notion of being
+/// at a prompt: the remote may be in vi , or at a sudo password prompt with echo off, and
+/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
+/// there", which is what says, and the Enter is the user's unless the snippet was
+/// deliberately marked as one that runs — see .
+///
+///
+internal sealed partial class SnippetsViewModel : ObservableObject
+{
+ private readonly VaultViewModel vault;
+ private readonly Func target;
+ private readonly Func> insert;
+
+ /// The open keychain, which owns the list and the writing.
+ ///
+ /// Which terminal is selected right now. A function rather than a value, because the answer changes every
+ /// time the user clicks a tab and this screen is not told about that.
+ ///
+ ///
+ /// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
+ /// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
+ /// neither of those is a property of the transport.
+ ///
+ internal SnippetsViewModel(
+ VaultViewModel vault,
+ Func target,
+ Func> insert)
+ {
+ this.vault = vault;
+ this.target = target;
+ this.insert = insert;
+
+ vault.Snippets.CollectionChanged += OnSnippetsChanged;
+
+ Rebuild();
+ }
+
+ /// The snippets this filter admits, in the order the vault produced them.
+ internal ObservableCollection Visible { get; } = [];
+
+ [ObservableProperty]
+ private string filter = string.Empty;
+
+ [ObservableProperty]
+ private SnippetRowViewModel? selected;
+
+ [ObservableProperty]
+ private bool isEditing;
+
+ [ObservableProperty]
+ private string editorLabel = string.Empty;
+
+ [ObservableProperty]
+ private string editorCommand = string.Empty;
+
+ [ObservableProperty]
+ private string editorNotes = string.Empty;
+
+ /// Whether the snippet being edited is one that presses Enter for you.
+ ///
+ /// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
+ /// per snippet rather than a preference, because ls -la and rm -rf /var/lib/postgresql do
+ /// not want the same answer and one switch would end up left on by whoever needed it for the first.
+ ///
+ [ObservableProperty]
+ private bool editorRunsOnInsert;
+
+ /// The snippet being edited, or null when the editor would create one.
+ [ObservableProperty]
+ private Guid? editingId;
+
+ [ObservableProperty]
+ private string status = string.Empty;
+
+ internal bool HasSnippets => vault.Snippets.Count > 0;
+
+ internal bool HasVisible => Visible.Count > 0;
+
+ internal bool HasSelection => Selected is not null;
+
+ /// Whether there is a terminal to insert into at all.
+ internal bool CanInsert => HasSelection && target().IsAvailable;
+
+ ///
+ /// What the insert button says, naming the terminal it will type into.
+ ///
+ ///
+ /// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
+ /// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
+ /// command, at the moment that is least convenient to be wrong about.
+ ///
+ internal string InsertLabel => target() is { IsAvailable: true } open
+ ? $"TYPE INTO {open.Label}"
+ : "NO TERMINAL OPEN";
+
+ /// What the run button says, or empty when the selected snippet does not run.
+ internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;
+
+ /// Whether the selected snippet is one marked as running on its own.
+ internal bool SelectionRuns => Selected?.RunsOnInsert is true;
+
+ internal string EmptyMessage => HasSnippets
+ ? "No snippet matches that."
+ : "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
+ + "typing it again.";
+
+ /// Starts a new snippet.
+ [RelayCommand]
+ private void New()
+ {
+ EditingId = null;
+ EditorLabel = string.Empty;
+ EditorCommand = string.Empty;
+ EditorNotes = string.Empty;
+ EditorRunsOnInsert = false;
+ IsEditing = true;
+ Status = "Adding a snippet.";
+ }
+
+ /// Opens the selected snippet for editing.
+ [RelayCommand]
+ private void Edit()
+ {
+ if (Selected is not { } row)
+ {
+ return;
+ }
+
+ if (row.IsReadOnly)
+ {
+ Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
+ return;
+ }
+
+ EditingId = row.EntityId;
+ EditorLabel = row.Snippet.Label;
+ EditorCommand = row.Snippet.Command;
+ EditorNotes = row.Snippet.Notes ?? string.Empty;
+ EditorRunsOnInsert = row.Snippet.RunsOnInsert;
+ IsEditing = true;
+ Status = $"Editing {row.Label}.";
+ }
+
+ /// Abandons the editor.
+ [RelayCommand]
+ private void Cancel()
+ {
+ IsEditing = false;
+ EditingId = null;
+ Status = string.Empty;
+ }
+
+ /// Stores the editor's contents.
+ [RelayCommand]
+ private async Task SaveAsync(CancellationToken cancellationToken)
+ {
+ var snippet = new SnippetSecret
+ {
+ Label = EditorLabel.Trim(),
+
+ // Not trimmed, and this is the field where that matters most. A here-document's terminator has
+ // to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
+ // for one that never comes, which reads as the snippet having hung the terminal.
+ Command = EditorCommand,
+ Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
+ RunsOnInsert = EditorRunsOnInsert,
+ };
+
+ var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
+
+ if (!saved)
+ {
+ Status = vault.Status;
+ return;
+ }
+
+ IsEditing = false;
+ EditingId = null;
+ Status = vault.Status;
+ }
+
+ /// Deletes the selected snippet.
+ [RelayCommand]
+ private async Task DeleteAsync(CancellationToken cancellationToken)
+ {
+ if (Selected is not { } row)
+ {
+ return;
+ }
+
+ await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
+
+ Status = vault.Status;
+ }
+
+ ///
+ /// Types the selected snippet into the selected terminal, without pressing Enter.
+ ///
+ ///
+ /// The button that does not run anything, and it is the one a user should reach for. What it inserts
+ /// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
+ /// at the prompt as text and waits for a person to look at it.
+ ///
+ [RelayCommand]
+ private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);
+
+ ///
+ /// Types the selected snippet into the selected terminal and presses Enter.
+ ///
+ ///
+ /// Only offered for a snippet whose own is set, so that "this
+ /// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
+ /// every one of them.
+ ///
+ [RelayCommand]
+ private Task RunAsync(CancellationToken cancellationToken) =>
+ SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;
+
+ internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;
+
+ /// Re-reads which terminal is selected, after the shell says one has changed.
+ ///
+ /// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
+ /// this screen — a session survives locking the keychain — so a subscription in this direction would be
+ /// a screen holding the shell alive.
+ ///
+ internal void TargetChanged()
+ {
+ OnPropertyChanged(nameof(CanInsert));
+ OnPropertyChanged(nameof(InsertLabel));
+ OnPropertyChanged(nameof(RunLabel));
+ }
+
+ private async Task SendAsync(bool execute, CancellationToken cancellationToken)
+ {
+ if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
+ {
+ Status = "Open a terminal first — a snippet has to go somewhere.";
+ return;
+ }
+
+ var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
+ .ConfigureAwait(true);
+
+ Status = delivered
+ ? execute
+ ? $"Ran '{row.Label}' in {open.Label}."
+ : $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
+ : $"{open.Label} is no longer connected, so nothing was sent.";
+ }
+
+ partial void OnFilterChanged(string value) => Rebuild();
+
+ partial void OnSelectedChanged(SnippetRowViewModel? value)
+ {
+ OnPropertyChanged(nameof(HasSelection));
+ OnPropertyChanged(nameof(CanInsert));
+ OnPropertyChanged(nameof(SelectionRuns));
+ }
+
+ partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
+
+ /// Whether the editor would create a snippet rather than replace one.
+ internal bool IsCreating => EditingId is null;
+
+ private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
+
+ private void Rebuild()
+ {
+ // Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
+ // way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
+ // writes that null straight back before the refill can matter.
+ var selectedId = Selected?.EntityId;
+
+ Visible.Clear();
+
+ foreach (var snippet in vault.Snippets.Where(Matches))
+ {
+ Visible.Add(snippet);
+ }
+
+ Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);
+
+ OnPropertyChanged(nameof(HasSnippets));
+ OnPropertyChanged(nameof(HasVisible));
+ OnPropertyChanged(nameof(EmptyMessage));
+ }
+
+ ///
+ /// The command is searched as well as the name and the notes, because half of what somebody remembers
+ /// about a saved command is a word that was in it.
+ ///
+ private bool Matches(SnippetRowViewModel row)
+ {
+ var needle = Filter.Trim();
+
+ if (needle.Length == 0)
+ {
+ return true;
+ }
+
+ return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);
+
+ bool Contains(string? value) =>
+ value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TeamsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TeamsViewModel.cs
new file mode 100644
index 0000000..ef13b61
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/ViewModels/TeamsViewModel.cs
@@ -0,0 +1,523 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using DodoSSH.Client.Api;
+using DodoSSH.Client.Session;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Shell.ViewModels;
+
+/// One team, as a row in the list.
+internal sealed record TeamRowViewModel(TeamSummary Team)
+{
+ internal Guid TeamId => Team.TeamId;
+
+ internal string Name => Team.Name;
+
+ internal string Slug => Team.Slug;
+
+ /// The caller's own role, as the chip the list shows.
+ internal string Role => Team.Role.ToString().ToUpperInvariant();
+
+ internal string Detail => string.Create(
+ CultureInfo.CurrentCulture,
+ $"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");
+
+ /// Whether this account may add members and create vaults here.
+ internal bool CanAdminister =>
+ Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
+}
+
+/// One member, as a row in the members table.
+internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
+{
+ internal Guid UserId => Member.UserId;
+
+ /// What to call them. The address, or the id when the account has neither.
+ ///
+ /// Falling through to the id rather than to "Unknown": an account with no display name and no email is
+ /// rare and is exactly the row somebody needs to be able to identify in order to remove it.
+ ///
+ internal string Name =>
+ Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();
+
+ internal string Email => Member.Email ?? "—";
+
+ internal string Role => Member.Role.ToString().ToUpperInvariant();
+
+ ///
+ /// What the account can be given, in one phrase.
+ ///
+ ///
+ /// Not a two-factor column, not a last-active column. The server records neither: there is no
+ /// second-factor concept anywhere in it, and LastSeenAtUtc is written at provisioning and at
+ /// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
+ /// What is true and worth a column is whether a vault key can be wrapped to them at all.
+ ///
+ internal string KeyState => Member.IsEnrolled
+ ? "key published"
+ : "no key yet — cannot be given a vault";
+
+ internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
+}
+
+/// One vault of the selected team, with what this account can do to it.
+internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
+{
+ /// What the row says about itself.
+ ///
+ /// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
+ /// somebody has been added to a team and nobody has wrapped the vault key to them yet.
+ ///
+ internal string State => (IsReadable, RekeyRequired) switch
+ {
+ (false, _) => "waiting for a key — ask a member who has one to share it",
+ (true, true) => "readable · a rekey is owed after a membership change",
+ _ => "readable",
+ };
+}
+
+///
+/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
+///
+///
+///
+/// Two separate acts, and the screen is built around saying so. Adding somebody to a team is a
+/// server-side authorization change and takes effect immediately. Giving them a vault key is a
+/// cryptographic act only a machine with that key can perform, and until somebody does it their vault
+/// list shows an entry they cannot open. Every product that hides this ends up implying the server can
+/// hand out access on its own — which, here, it cannot. See TeamService and ADR 0001.
+///
+///
+/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
+/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
+/// would be a second copy of something the server is authoritative for.
+///
+///
+internal sealed partial class TeamsViewModel(
+ Func connection,
+ Func session) : ObservableObject
+{
+ /// Teams this account belongs to.
+ internal ObservableCollection Teams { get; } = [];
+
+ /// Members of the selected team.
+ internal ObservableCollection Members { get; } = [];
+
+ /// Vaults the selected team owns, as far as this account can see them.
+ internal ObservableCollection Vaults { get; } = [];
+
+ [ObservableProperty]
+ private TeamRowViewModel? selectedTeam;
+
+ [ObservableProperty]
+ private TeamMemberRowViewModel? selectedMember;
+
+ [ObservableProperty]
+ private TeamVaultRowViewModel? selectedVault;
+
+ [ObservableProperty]
+ private string status = string.Empty;
+
+ [ObservableProperty]
+ private bool isBusy;
+
+ // ---- Creating a team ----
+
+ [ObservableProperty]
+ private bool isCreatingTeam;
+
+ [ObservableProperty]
+ private string newTeamName = string.Empty;
+
+ [ObservableProperty]
+ private string newTeamSlug = string.Empty;
+
+ // ---- Adding a member ----
+
+ [ObservableProperty]
+ private string inviteEmail = string.Empty;
+
+ /// Whether there is a server to talk to at all.
+ internal bool IsOnline => connection() is not null;
+
+ /// Whether the selected team can be administered by this account.
+ internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
+
+ /// Whether there is anything to show below the team list.
+ internal bool HasSelection => SelectedTeam is not null;
+
+ internal bool HasTeams => Teams.Count > 0;
+
+ /// Reads the teams this account belongs to, and the selected one's detail.
+ internal Task LoadAsync(CancellationToken cancellationToken) =>
+ RunAsync(() => ReloadAsync(cancellationToken));
+
+ ///
+ /// The reload itself, without the busy gate.
+ ///
+ ///
+ /// Separate from because every command ends by reloading, and a command that
+ /// called the gated version would find the gate held by itself and skip the reload silently — leaving
+ /// a team that was created moments ago missing from the list it was just added to.
+ ///
+ private async Task ReloadAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server)
+ {
+ Teams.Clear();
+ Members.Clear();
+ Vaults.Clear();
+ RaiseState();
+
+ Status = "Offline. Teams are read from the server, so this screen needs a connection.";
+ return;
+ }
+
+ var selectedId = SelectedTeam?.TeamId;
+
+ var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
+
+ Teams.Clear();
+
+ foreach (var team in teams)
+ {
+ Teams.Add(new TeamRowViewModel(team));
+ }
+
+ SelectedTeam =
+ Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
+
+ RaiseState();
+
+ await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = Teams.Count == 0
+ ? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
+ : string.Empty;
+ }
+
+ /// Opens the create-a-team form.
+ [RelayCommand]
+ private void NewTeam()
+ {
+ NewTeamName = string.Empty;
+ NewTeamSlug = string.Empty;
+ IsCreatingTeam = true;
+ Status = string.Empty;
+ }
+
+ /// Abandons the create-a-team form.
+ [RelayCommand]
+ private void CancelNewTeam()
+ {
+ IsCreatingTeam = false;
+ Status = string.Empty;
+ }
+
+ /// Creates a team, with this account as its owner.
+ ///
+ /// The id is generated here, which is what makes a create whose response was lost safe to send again —
+ /// the server treats an identical repeat as the same team rather than a second one.
+ ///
+ [RelayCommand]
+ private async Task CreateTeamAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server)
+ {
+ Status = "Offline. Creating a team needs a connection.";
+ return;
+ }
+
+ var name = NewTeamName.Trim();
+ var slug = NewTeamSlug.Trim().ToLowerInvariant();
+
+ if (name.Length == 0 || slug.Length == 0)
+ {
+ Status = "A team needs a name and a slug.";
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var created = await server.Teams
+ .CreateTeamAsync(
+ new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
+ .ConfigureAwait(true);
+
+ IsCreatingTeam = false;
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
+
+ Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
+ + "whoever needs it.";
+ }).ConfigureAwait(true);
+ }
+
+ ///
+ /// Adds a member, by looking their address up in the directory first.
+ ///
+ ///
+ /// Two calls rather than one, and the order is the point: the directory is what turns an address into
+ /// an account and a public key, and the key that gets verified before any sharing is the one that
+ /// lookup returned. Letting the server resolve an address to an account inside the add would put an
+ /// unwitnessed step between the two.
+ ///
+ [RelayCommand]
+ private async Task AddMemberAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server || SelectedTeam is not { } team)
+ {
+ return;
+ }
+
+ var email = InviteEmail.Trim();
+
+ if (email.Length == 0)
+ {
+ Status = "Type the email address of somebody who has signed in to this server.";
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
+ .ConfigureAwait(true);
+
+ if (found.Count == 0)
+ {
+ Status = $"No account here has the address '{email}'. They have to sign in to this "
+ + "server once before they can be added — that is what publishes the key a vault "
+ + "would be shared with.";
+ return;
+ }
+
+ var member = await server.Teams
+ .AddTeamMemberAsync(
+ team.TeamId,
+ new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
+ cancellationToken)
+ .ConfigureAwait(true);
+
+ InviteEmail = string.Empty;
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ // Said out loud, every time. The single most common misunderstanding this design invites is
+ // that adding somebody gave them the vault.
+ Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
+ + "cannot read anything yet — select a vault below and share its key.";
+ }).ConfigureAwait(true);
+ }
+
+ /// Removes a member, revoking every vault key grant they hold from this team.
+ [RelayCommand]
+ private async Task RemoveMemberAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server
+ || SelectedTeam is not { } team
+ || SelectedMember is not { } member)
+ {
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ await server.Teams
+ .RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ // The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
+ // and a message implying otherwise is the one thing this screen must not say.
+ Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything "
+ + "they had already downloaded is still on their machine — rotate the credentials that "
+ + "matter.";
+ }).ConfigureAwait(true);
+ }
+
+ /// Creates a vault owned by the selected team.
+ [RelayCommand]
+ private async Task CreateVaultAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server
+ || session() is not { } open
+ || SelectedTeam is not { } team)
+ {
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var vault = await open
+ .CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
+ + "hosts and credentials can be filed into it from the Vault screen.";
+ }).ConfigureAwait(true);
+ }
+
+ ///
+ /// Wraps the selected vault's key to the selected member.
+ ///
+ ///
+ /// Everything that makes this safe happens inside : the key
+ /// log is read and its chain verified, and the directory's answer has to appear in it unchanged before
+ /// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because
+ /// the reasons are not interchangeable — one of them means somebody is substituting keys.
+ ///
+ [RelayCommand]
+ private async Task ShareVaultAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server
+ || session() is not { } open
+ || SelectedVault is not { } vault
+ || SelectedMember is not { } member)
+ {
+ return;
+ }
+
+ if (member.IsSelf)
+ {
+ Status = "You already hold this vault's key.";
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var outcome = await open
+ .ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
+ .ConfigureAwait(true);
+
+ Status = outcome.Shared
+ ? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
+ : $"Did not share '{vault.Name}': {outcome.Message}";
+ }).ConfigureAwait(true);
+ }
+
+ /// Withdraws the selected member's key to the selected vault.
+ [RelayCommand]
+ private async Task RevokeVaultAsync(CancellationToken cancellationToken)
+ {
+ if (connection() is not { } server
+ || SelectedVault is not { } vault
+ || SelectedMember is not { } member)
+ {
+ return;
+ }
+
+ await RunAsync(async () =>
+ {
+ var revoked = await server.Grants
+ .RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = revoked
+ ? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they "
+ + "already have is unaffected."
+ : $"{member.Name} held no key to '{vault.Name}'.";
+ }).ConfigureAwait(true);
+ }
+
+ partial void OnSelectedTeamChanged(TeamRowViewModel? value)
+ {
+ RaiseState();
+
+ // Fire-and-forget on purpose, and the only place in this class that is: selection changes come
+ // from a list box, which has no cancellation token and no way to await. Failures land in Status
+ // through RunAsync exactly as a command's would.
+ _ = LoadSelectedAsync(CancellationToken.None);
+ }
+
+ /// Reads the selected team's members and vaults.
+ private async Task LoadSelectedAsync(CancellationToken cancellationToken)
+ {
+ Members.Clear();
+ Vaults.Clear();
+
+ if (connection() is not { } server || SelectedTeam is not { } team)
+ {
+ return;
+ }
+
+ var open = session();
+ var selfId = open?.Profile.UserId;
+
+ var members = await server.Teams
+ .ListTeamMembersAsync(team.TeamId, cancellationToken)
+ .ConfigureAwait(true);
+
+ foreach (var member in members)
+ {
+ Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
+ }
+
+ if (open is null)
+ {
+ return;
+ }
+
+ // Read from the session rather than from a team-vaults endpoint, because the interesting fact
+ // about a team vault here is whether *this* machine can open it — which is a property of the
+ // keyring and not something the server can answer.
+ var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
+
+ foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
+ {
+ Vaults.Add(new TeamVaultRowViewModel(
+ vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
+ }
+
+ SelectedVault = Vaults.FirstOrDefault();
+ }
+
+ private void RaiseState()
+ {
+ OnPropertyChanged(nameof(HasTeams));
+ OnPropertyChanged(nameof(HasSelection));
+ OnPropertyChanged(nameof(CanAdministerSelected));
+ OnPropertyChanged(nameof(IsOnline));
+ }
+
+ ///
+ /// One place that raises the busy flag and turns a failure into a sentence. An API exception's message
+ /// is the server's problem detail, which is written for a person to read — see Problems — so it
+ /// is shown rather than replaced with something vaguer.
+ ///
+ private async Task RunAsync(Func work)
+ {
+ if (IsBusy)
+ {
+ return;
+ }
+
+ IsBusy = true;
+
+ try
+ {
+ await work().ConfigureAwait(true);
+ }
+ catch (DodoSshApiException exception)
+ {
+ Status = exception.Message;
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException
+ and not OperationCanceledException)
+ {
+ Status = exception.Message;
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
index 1ef7af6..2ff410f 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
@@ -3,12 +3,24 @@ using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.Shell.ViewModels;
+/// What sort of remote the file browser's right-hand pane is showing.
+internal enum RemoteKind
+{
+ /// A host, over SFTP.
+ Host,
+
+ /// An S3-compatible bucket.
+ Bucket,
+}
+
/// One segment of a path, as a button in a breadcrumb trail.
/// What the segment is called.
/// The absolute path that reaches it.
@@ -240,7 +252,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
private VaultViewModel? vault;
private VaultKnownHostStore? knownHosts;
- private ISftpSession? session;
+ private IRemoteFileStore? session;
+ private ConnectionRecorder? connectionLog;
+
+ /// How a bucket is opened, or null in a build that was not given one.
+ private IObjectStoreFactory? objectStores;
+
+ /// The open SFTP connection, as the log will record it, or null when there is none.
+ ///
+ /// Held rather than rebuilt at close time, because by then the session is being disposed and the host
+ /// row it came from may have been replaced by a background sync. The address is the one that was
+ /// actually dialled, which is the whole point of capturing it at connect.
+ ///
+ private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
+
private bool disposed;
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
@@ -249,7 +274,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// The supplier answers with whatever session is current at the moment a transfer starts, which is
// what lets a queue survive a disconnect and reconnect without every queued row failing.
- queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
+ queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
queue.Changed += OnTransferChanged;
// The three "is there anything in it" flags follow their collections rather than being raised by
@@ -271,6 +296,49 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private HostRowViewModel? selectedHost;
+ /// The buckets that can be browsed, which is the vault's list.
+ ///
+ internal ObservableCollection Buckets { get; } = [];
+
+ [ObservableProperty]
+ private ObjectStoreRowViewModel? selectedBucket;
+
+ ///
+ /// Which sort of remote the right-hand pane is about to open.
+ ///
+ ///
+ ///
+ /// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
+ /// the host editor's authentication picker does — and the reason is that these two are not
+ /// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
+ /// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
+ /// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
+ /// to look at than two clearly separate choices.
+ ///
+ ///
+ /// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
+ /// keychain's categories do: a selection binding moves before a command could refuse it.
+ ///
+ ///
+ [ObservableProperty]
+ private RemoteKind remote;
+
+ /// Whether the picker is showing hosts.
+ internal bool ShowsHostPicker => Remote is RemoteKind.Host;
+
+ /// Whether the picker is showing buckets.
+ internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
+
+ ///
+ /// What the button that opens the remote says.
+ ///
+ ///
+ /// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
+ /// connected and nothing stays open. A word that implied otherwise would make the absence of a
+ /// DISCONNECT step look like a bug rather than the shape of the protocol.
+ ///
+ internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
+
///
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
@@ -288,6 +356,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty]
private bool isConnected;
+ ///
+ /// Whether something is being dragged over the local pane, and whether it would be accepted.
+ ///
+ ///
+ /// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
+ /// three-way binding — and because the refusing state is worth showing rather than merely not showing
+ /// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
+ /// window that has stopped responding.
+ ///
+ [ObservableProperty]
+ private bool isLocalDropTarget;
+
+ ///
+ [ObservableProperty]
+ private bool isRemoteDropTarget;
+
+ ///
+ [ObservableProperty]
+ private bool isRemoteDropRefused;
+
/// The account and endpoint actually dialled, once connected.
[ObservableProperty]
private string? connectedTo;
@@ -304,7 +392,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// Whether the chosen host will want something typed into the password box.
internal bool SelectedHostAsksForAPassword =>
- SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
+ ShowsHostPicker && SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
// ---- The remote pane ----
@@ -376,10 +464,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
/// Takes an unlocked vault, so the host list has something in it.
- internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys)
+ /// The open keychain.
+ /// The pins this screen's own trust decisions are written to.
+ ///
+ /// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
+ /// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
+ /// when the keychain locks still ends somewhere.
+ ///
+ internal void Attach(
+ VaultViewModel openVault,
+ VaultKnownHostStore hostKeys,
+ ConnectionRecorder? log = null,
+ IObjectStoreFactory? buckets = null)
{
vault = openVault;
knownHosts = hostKeys;
+ connectionLog = log;
+ objectStores = buckets;
RefreshHosts();
@@ -408,13 +509,78 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
knownHosts = null;
Hosts.Clear();
+ Buckets.Clear();
SelectedHost = null;
+ SelectedBucket = null;
TypedPassword = string.Empty;
}
- /// Opens a file-transfer session on the chosen host.
+ /// Shows one of the two kinds of remote in the picker.
[RelayCommand]
- private async Task ConnectAsync(CancellationToken cancellationToken)
+ private void ShowRemote(RemoteKind kind) => Remote = kind;
+
+ /// Opens the chosen remote, whichever kind it is.
+ [RelayCommand]
+ private Task ConnectAsync(CancellationToken cancellationToken) =>
+ Remote is RemoteKind.Bucket
+ ? OpenBucketAsync(cancellationToken)
+ : ConnectToHostAsync(cancellationToken);
+
+ ///
+ /// Opens the chosen bucket.
+ ///
+ ///
+ ///
+ /// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
+ /// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
+ /// why the failure this reports is a listing failure rather than a connection one — there is no
+ /// connection to fail.
+ ///
+ ///
+ /// It goes through the same session field, the same queue and the same panes as a host, because by this
+ /// point it is an IRemoteFileStore like any other. Everything below this method was written for
+ /// SFTP and needed no change.
+ ///
+ ///
+ private async Task OpenBucketAsync(CancellationToken cancellationToken)
+ {
+ if (objectStores is not { } factory)
+ {
+ Status = "This build cannot open buckets.";
+ return;
+ }
+
+ if (SelectedBucket is not { } row)
+ {
+ Status = "Choose a bucket first.";
+ return;
+ }
+
+ PendingHostKey = null;
+ HostKeyMismatch = null;
+
+ await RunAsync(
+ $"Opening {row.Label}…",
+ async () =>
+ {
+ await CloseSessionAsync().ConfigureAwait(true);
+
+ session = factory.Open(row.Store);
+
+ IsConnected = true;
+ ConnectedTo = string.Create(
+ CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
+
+ connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
+
+ await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
+
+ Status = $"Opened {row.Label}.";
+ }).ConfigureAwait(true);
+ }
+
+ /// Opens a file-transfer session on the chosen host.
+ private async Task ConnectToHostAsync(CancellationToken cancellationToken)
{
if (vault is not { } open || SelectedHost is not { } row)
{
@@ -463,6 +629,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
CultureInfo.InvariantCulture,
$"{request.Username}@{request.Host}:{request.Port}");
+ // Recorded, and not hidden because it is "only" the file browser. Opening this is a second
+ // login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
+ // would disagree with the host's — and anybody comparing the two would be right to believe
+ // the host.
+ connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
+
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
@@ -624,34 +796,147 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[RelayCommand]
private void Download()
{
- if (SelectedRemoteEntry is not { IsFile: true } row)
+ if (SelectedRemoteEntry is not { } row)
{
Status = "Choose a file on the host to download.";
return;
}
- var destination = Path.Combine(LocalPath, row.Name);
-
- queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length);
-
- Status = $"Queued {row.Name} for download into {LocalPath}.";
+ QueueDownloads([row]);
}
/// Queues the chosen local file for upload into the remote directory showing.
[RelayCommand]
private void Upload()
{
- if (SelectedLocalEntry is not { IsFile: true } row)
+ if (SelectedLocalEntry is not { } row)
{
Status = "Choose a file on this machine to upload.";
return;
}
- var destination = SftpPath.Combine(RemotePath, row.Name);
+ QueueUploads([row.FullPath]);
+ }
- queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
+ ///
+ /// Queues every one of these local paths for upload into the remote directory showing.
+ ///
+ ///
+ ///
+ /// The one path both the button and a drop go through, so there is one set of rules about what can be
+ /// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
+ /// were dragged, from this window's own pane or from the file manager.
+ ///
+ ///
+ /// Directories are skipped and counted. The queue moves files: there is no recursive upload, and
+ /// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
+ ///
+ ///
+ /// Reported per item, not per drop. The queue refuses to overwrite, so a drop of five files where
+ /// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
+ /// about all five.
+ ///
+ ///
+ internal void QueueUploads(IReadOnlyList paths)
+ {
+ ArgumentNullException.ThrowIfNull(paths);
- Status = $"Queued {row.Name} for upload into {RemotePath}.";
+ if (!IsConnected)
+ {
+ Status = "Connect to a host first.";
+ return;
+ }
+
+ var queued = 0;
+ var directories = 0;
+ var missing = 0;
+
+ foreach (var path in paths)
+ {
+ if (Directory.Exists(path))
+ {
+ directories++;
+ continue;
+ }
+
+ // Between the drag starting and the drop landing, a file can be moved or deleted — and the
+ // paths in an OS drop come from another process, which is not obliged to be right about them.
+ if (!File.Exists(path))
+ {
+ missing++;
+ continue;
+ }
+
+ var length = new FileInfo(path).Length;
+ var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));
+
+ queue.Enqueue(TransferDirection.Upload, path, destination, length);
+ queued++;
+ }
+
+ Status = Describe(queued, "upload into", RemotePath, directories, missing);
+ }
+
+ /// Queues every one of these remote entries for download into the local directory showing.
+ ///
+ internal void QueueDownloads(IReadOnlyList rows)
+ {
+ ArgumentNullException.ThrowIfNull(rows);
+
+ if (!IsConnected)
+ {
+ Status = "Connect to a host first.";
+ return;
+ }
+
+ var queued = 0;
+ var directories = 0;
+
+ foreach (var row in rows)
+ {
+ if (!row.IsFile)
+ {
+ directories++;
+ continue;
+ }
+
+ queue.Enqueue(
+ TransferDirection.Download,
+ Path.Combine(LocalPath, row.Name),
+ row.FullPath,
+ row.Entry.Length);
+
+ queued++;
+ }
+
+ Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
+ }
+
+ ///
+ /// One sentence for both directions and every shape of partial success. What it must never do is stay
+ /// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
+ /// looking for the other two in a queue they are not in.
+ ///
+ private static string Describe(int queued, string verb, string destination, int directories, int missing)
+ {
+ var files = queued == 1 ? "1 file" : $"{queued} files";
+ var said = queued == 0
+ ? "Nothing was queued."
+ : $"Queued {files} for {verb} {destination}.";
+
+ if (directories > 0)
+ {
+ var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
+ said += $" {folders} skipped — only files can be transferred.";
+ }
+
+ if (missing > 0)
+ {
+ var gone = missing == 1 ? "1 item was" : $"{missing} items were";
+ said += $" {gone} no longer there.";
+ }
+
+ return said;
}
/// Stops one transfer.
@@ -919,10 +1204,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
SelectedHost ??= Hosts.FirstOrDefault();
+
+ Buckets.Clear();
+
+ foreach (var bucket in open.ObjectStores)
+ {
+ Buckets.Add(bucket);
+ }
+
+ SelectedBucket ??= Buckets.FirstOrDefault();
}
/// The session, or a failure a queue row can carry.
- private ISftpSession RequireSession() =>
+ private IRemoteFileStore RequireSession() =>
session ?? throw new InvalidOperationException(
"This screen is not connected to a host, so there is nowhere to move the file.");
@@ -934,6 +1228,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await open.DisposeAsync().ConfigureAwait(true);
}
+ // Written whole here rather than through an open/close ticket, because this connection is not one
+ // the terminal workspace ever knew about — it has no session id, and borrowing one would collide
+ // with a real terminal's.
+ if (connected is { } record)
+ {
+ connected = null;
+
+ connectionLog?.Record(
+ record.Address,
+ record.HostLabel,
+ record.HostId,
+ ConnectionKind.Sftp,
+ record.StartedAt,
+ TimeProvider.System.GetUtcNow(),
+ ConnectionOutcome.Closed);
+ }
+
IsConnected = false;
ConnectedTo = null;
RemotePath = string.Empty;
@@ -996,6 +1307,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
+ ///
+ /// The password box follows this as well as the host, because it is shown only for a host that asks for
+ /// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
+ /// picker that has nothing to do with passwords.
+ ///
+ partial void OnRemoteChanged(RemoteKind value)
+ {
+ OnPropertyChanged(nameof(ShowsHostPicker));
+ OnPropertyChanged(nameof(ShowsBucketPicker));
+ OnPropertyChanged(nameof(ConnectLabel));
+ OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
+ }
+
partial void OnIsConnectedChanged(bool value)
{
OnPropertyChanged(nameof(CanDownload));
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index c2bc573..ecd9205 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -13,16 +13,151 @@ using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Shell.ViewModels;
+///
+/// Anything the host sidebar's one list can hold.
+///
+///
+///
+/// A marker, because the list is one ListBox and has to stay one — it owns the selection, and it is
+/// where keyboard focus lands when the terminal gives it back. Neither survives being split into a list per
+/// group, which is why headings are rows rather than containers.
+///
+///
+/// The cost is that a heading is selectable as far as the ListBox is concerned, and it must not be as
+/// far as anything else is: Connect , Edit and Delete all read the host selection. See
+/// VaultViewModel.SelectedSidebarRow , which is where a heading click is turned back into whatever was
+/// selected before it.
+///
+///
+internal interface ISidebarRow;
+
+/// One group heading, as a row in the host list.
+/// The group, or null for the heading ungrouped hosts fall under.
+/// What the heading says.
+/// How many hosts are under it, after the filter.
+/// Whether its hosts are showing.
+internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded)
+ : ISidebarRow
+{
+ /// The chevron, as text, because the heading is drawn in the list's own item template.
+ internal string Chevron => IsExpanded ? "▾" : "▸";
+}
+
+/// One group, as a row in the group list.
+///
+/// Thinner than the other row types because a group is thinner: a name, and how many hosts name it. The
+/// count is computed from the host list rather than stored on the group — see
+/// for why membership lives on the host — so it is passed in rather than read off the item.
+///
+internal sealed class HostGroupRowViewModel(VaultItem group, int hostCount)
+{
+ internal Guid EntityId => group.EntityId;
+
+ internal HostGroupSecret Group => group.Secret;
+
+ internal string Label => group.Secret.Label;
+
+ internal int HostCount => hostCount;
+
+ internal bool IsReadOnly => group.IsReadOnly;
+
+ internal string Badge => ItemBadge.For(group.IsBlocked, group.IsReadOnly, group.HasUnsyncedChanges);
+
+ /// What the row says under the name.
+ internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
+}
+
+/// An entry in the host editor's group picker.
+/// The group, or null for "no group".
+/// What to show.
+///
+/// A sentinel entry rather than a nullable selection, for the reason gives:
+/// a ComboBox with nothing selected and a ComboBox meaning "no group" look identical and are not the same
+/// thing. As there, a group the vault no longer has keeps a placeholder entry, so that editing a host's port
+/// cannot quietly unfile it.
+///
+internal sealed record GroupChoice(Guid? EntityId, string Label)
+{
+ /// The "not in a group" entry, always first.
+ internal static GroupChoice None { get; } = new(null, "No group");
+}
+
+/// One snippet, as a row in the list.
+///
+/// Carries the decrypted so opening the editor needs no second decryption, in
+/// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
+/// per click.
+///
+internal sealed class SnippetRowViewModel(VaultItem snippet)
+{
+ internal Guid EntityId => snippet.EntityId;
+
+ internal SnippetSecret Snippet => snippet.Secret;
+
+ internal string Label => snippet.Secret.Label;
+
+ internal bool RunsOnInsert => snippet.Secret.RunsOnInsert;
+
+ internal bool IsReadOnly => snippet.IsReadOnly;
+
+ internal bool HasUnsyncedChanges => snippet.HasUnsyncedChanges;
+
+ internal string Badge => ItemBadge.For(snippet.IsBlocked, snippet.IsReadOnly, snippet.HasUnsyncedChanges);
+
+ ///
+ /// The command, on one line, for the list.
+ ///
+ ///
+ /// Newlines become ⏎ rather than being dropped or wrapped. A three-line snippet shown as one run
+ /// of text would read as a single command, which is exactly the thing the user is deciding about when
+ /// they look at this row.
+ ///
+ internal string Preview => snippet.Secret.Command
+ .ReplaceLineEndings("\n")
+ .Replace("\n", " ⏎ ", StringComparison.Ordinal)
+ .Trim();
+}
+
/// One host, as a row in the list.
///
/// Carries the decrypted so opening the editor needs no second decryption, and
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
/// an item a newer client wrote that must not be re-encoded here.
///
-internal sealed partial class HostRowViewModel(VaultItem host) : ObservableObject
+internal sealed partial class HostRowViewModel(
+ VaultItem host,
+ Guid vaultId,
+ string vaultName) : ObservableObject, ISidebarRow
{
internal Guid EntityId => host.EntityId;
+ ///
+ /// Which vault this host lives in.
+ ///
+ ///
+ /// Carried on the row rather than read from the session, because a session now holds several and an
+ /// edit has to return to the vault the item came from. Writing it to the active vault instead would
+ /// create a second copy in the personal vault and leave the team's original untouched — a silent fork
+ /// that only shows up when somebody else wonders why their change never arrived.
+ ///
+ internal Guid VaultId => vaultId;
+
+ /// The vault's display name, for the heading the sidebar groups under.
+ internal string VaultName => vaultName;
+
+ ///
+ /// The vault name to print on this row, or empty when there is only one vault to be in.
+ ///
+ ///
+ /// Decided by the list rather than by the row, because "is there more than one vault" is not
+ /// something a row can see — and the alternative, a binding that reaches out to the parent view
+ /// model from inside an item template, is the kind of thing that silently resolves to nothing.
+ ///
+ internal string VaultBadge { get; init; } = string.Empty;
+
+ /// Whether this row has a vault to name.
+ internal bool HasVaultBadge => VaultBadge.Length > 0;
+
internal HostSecret Host => host.Secret;
internal string Label => host.Secret.Label;
@@ -165,10 +300,16 @@ internal sealed record AuthenticationChoice(
/// property.
///
///
-internal sealed class SshKeyRowViewModel(VaultItem key)
+internal sealed class SshKeyRowViewModel(VaultItem key, Guid vaultId, string vaultName)
{
internal Guid EntityId => key.EntityId;
+ /// Which vault this key lives in. See .
+ internal Guid VaultId => vaultId;
+
+ /// The vault's display name.
+ internal string VaultName => vaultName;
+
internal SshKeySecret Key => key.Secret;
internal string Label => key.Secret.Label;
@@ -202,10 +343,47 @@ internal sealed class SshKeyRowViewModel(VaultItem key)
/// can render a password by being pointed at the obvious property.
///
///
-internal sealed class CredentialRowViewModel(VaultItem credential)
+/// One bucket, as a row in the list.
+internal sealed class ObjectStoreRowViewModel(VaultItem store)
+{
+ internal Guid EntityId => store.EntityId;
+
+ internal ObjectStoreSecret Store => store.Secret;
+
+ internal string Label => store.Secret.Label;
+
+ /// What the list shows under the name: where it is, never the keys.
+ ///
+ /// The bucket and the service, because those are what tell two entries apart — the same bucket name in
+ /// two accounts is the ordinary case. The access key id is an identifier rather than a secret and is
+ /// still not here: it is long, it is noise in a list, and it belongs in the detail pane.
+ ///
+ internal string Description => store.Secret.Endpoint is { } endpoint
+ ? $"{store.Secret.Bucket} at {endpoint}"
+ : $"{store.Secret.Bucket} · {store.Secret.Region}";
+
+ internal bool HasUnsyncedChanges => store.HasUnsyncedChanges;
+
+ internal bool IsBlocked => store.IsBlocked;
+
+ internal bool IsReadOnly => store.IsReadOnly;
+
+ internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges);
+}
+
+internal sealed class CredentialRowViewModel(
+ VaultItem credential,
+ Guid vaultId,
+ string vaultName)
{
internal Guid EntityId => credential.EntityId;
+ /// Which vault this credential lives in. See .
+ internal Guid VaultId => vaultId;
+
+ /// The vault's display name.
+ internal string VaultName => vaultName;
+
internal CredentialSecret Credential => credential.Secret;
internal string Label => credential.Secret.Label;
@@ -230,58 +408,6 @@ internal sealed class CredentialRowViewModel(VaultItem credent
ItemBadge.For(credential.IsBlocked, credential.IsReadOnly, credential.HasUnsyncedChanges);
}
-/// One pinned host key, as a row in the list.
-///
-///
-/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
-/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
-/// leaves its pin, and so does changing a host's address. Both are correct as trust decisions: the
-/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
-/// What was wrong was that nothing ever showed them.
-///
-///
-/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
-/// of pinning one is to compare it with what they published.
-///
-///
-internal sealed class KnownHostRowViewModel(VaultItem pin, bool isDialledByAHost)
-{
- internal Guid EntityId => pin.EntityId;
-
- internal KnownHostSecret Pin => pin.Secret;
-
- internal string Host => pin.Secret.Host;
-
- internal int Port => pin.Secret.Port;
-
- /// The endpoint and algorithm, which is what a pin actually identifies.
- internal string Label => pin.Secret.Label;
-
- /// The fingerprint, in full.
- ///
- /// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
- /// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
- /// this whole mechanism exists to replace.
- ///
- internal string Fingerprint => pin.Secret.Fingerprint;
-
- ///
- /// Whether any host in this vault actually dials the endpoint this pin is for.
- ///
- ///
- /// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
- /// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
- /// worth deleting on the user's behalf.
- ///
- internal bool IsDialledByAHost { get; } = isDialledByAHost;
-
- internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
-
- internal string Badge => IsDialledByAHost
- ? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
- : "no host uses this";
-}
-
/// The one-word marker a row shows for its sync state.
///
/// Shared by both row types rather than written twice, because the three states mean the same thing for
@@ -362,13 +488,27 @@ internal enum VaultSection
Keys,
/// The usernames and passwords they authenticate with instead.
+ ///
+ /// There was a fourth, for the host keys this user has approved. It is a screen of its own now — see
+ /// KnownHostsScreen — and the member is gone rather than left unused, because a value this
+ /// screen's command would still accept and no longer draw anything for is a trap with no upside.
+ ///
Credentials,
- /// The host keys this user has approved.
- KnownHosts,
+ /// S3-compatible buckets, and the keys that reach them.
+ ///
+ /// A category here rather than a screen of its own, unlike the approved host keys: a bucket is something
+ /// somebody creates and edits and whose secret has to be kept, which is exactly what the other two
+ /// categories are. A pin is not.
+ ///
+ Buckets,
}
/// What kind of thing a row in the vault table is.
+///
+/// Two, since the pins left. Both are things somebody created on purpose and can edit, which is what the
+/// table's shared shape now assumes throughout.
+///
internal enum VaultItemKind
{
/// An SSH key.
@@ -377,8 +517,8 @@ internal enum VaultItemKind
/// A stored username and password.
Credential,
- /// A pinned host key.
- KnownHost,
+ /// An S3-compatible bucket.
+ ObjectStore,
}
///
@@ -409,6 +549,23 @@ internal enum VaultItemKind
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
/// by comparing it against the literal "not synced" would break the day that wording improves.
///
+/// One vault, as an option in the "file this into" picker.
+/// The vault.
+/// Its display name, which is plaintext as all vault names are.
+/// Whether this is the caller's own vault rather than a team's.
+internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal)
+{
+ ///
+ /// What the picker shows.
+ ///
+ ///
+ /// A team vault is marked as one. The whole risk this picker introduces is putting a credential
+ /// somewhere more people can read it, so the option that does that must not look like the option
+ /// that does not.
+ ///
+ internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
+}
+
internal sealed record VaultItemRowViewModel(
VaultItemKind Kind,
Guid EntityId,
@@ -433,6 +590,12 @@ internal enum DeletionTarget
/// A stored password, from the vault screen.
Credential,
+
+ /// A group, from the panel beside the host sidebar.
+ Group,
+
+ /// A bucket, from the vault screen.
+ ObjectStore,
}
///
@@ -525,12 +688,23 @@ internal delegate Task ServerReconnectHandler(CancellationToken c
/// HostSecretCodec.CurrentSchemaVersion .
///
///
+///
+/// Puts one line of text on the system clipboard, or null where there is none.
+///
+/// A delegate rather than Avalonia's IClipboard , for the reason SignInHandler is one: the
+/// clipboard is reached through TopLevel.GetTopLevel(control) , so taking it directly would make this
+/// view model need a visual — and every test that drives it need a window. Null is a machine that has no
+/// clipboard rather than one that failed to copy, and the difference is worth saying out loud.
+///
+///
internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
Func connection,
- ServerReconnectHandler? reconnect = null) : ObservableObject, IAsyncDisposable
+ ServerReconnectHandler? reconnect = null,
+ Func? copyToClipboard = null,
+ ConnectionRecorder? connectionLog = null) : ObservableObject, IAsyncDisposable
{
///
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
@@ -540,9 +714,32 @@ internal sealed partial class VaultViewModel(
///
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
+ /// How often the logs are pruned, at most.
+ ///
+ /// Hours rather than minutes, because pruning writes tombstones that sync. Retention is measured in days
+ /// and thousands of entries, so a pass that ran six hours late has nothing to catch up on — and one that
+ /// ran every minute would be a machine talking to a server all day about housekeeping.
+ ///
+ private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(6);
+
+ /// When the logs were last pruned, or null when this session has not pruned yet.
+ private DateTimeOffset? lastPruned;
+
/// Serialises every synchronisation pass, whether a button pressed it or a timer did.
private readonly SemaphoreSlim syncGate = new(1, 1);
+ ///
+ /// The groups as they came out of the vault, before the host counts are attached.
+ ///
+ ///
+ /// Held between the group reload and the host reload, which are two passes because a group row says how
+ /// many hosts name it and the hosts are read second. See .
+ ///
+ private IReadOnlyList> groupItems = [];
+
+ /// The groups whose hosts are folded away, by id, with for ungrouped.
+ private readonly HashSet collapsedGroups = [];
+
private CancellationTokenSource? autoSync;
private Task? autoSyncLoop;
private bool disposed;
@@ -561,7 +758,7 @@ internal sealed partial class VaultViewModel(
///
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
/// host key list checks itself against, so a filter applied here would change what the application can
- /// do rather than what it shows. is the filtered view.
+ /// do rather than what it shows. is the filtered view.
///
internal ObservableCollection Hosts { get; } = [];
@@ -573,16 +770,51 @@ internal sealed partial class VaultViewModel(
///
internal ObservableCollection VisibleHosts { get; } = [];
+ ///
+ /// What the sidebar's list actually holds: the visible hosts, with group headings between them.
+ ///
+ ///
+ ///
+ /// A vault with no groups produces no headings at all , so this is in
+ /// the same order, and the sidebar looks exactly as it did before groups existed. The feature is
+ /// invisible until it is used, which is the point: somebody with eleven machines and no wish to file them
+ /// should not be shown a heading saying so.
+ ///
+ ///
+ /// Kept beside rather than replacing it. The count on the section heading, the
+ /// filter's own arithmetic and every test that asks what the sidebar is showing all mean hosts — a
+ /// collection whose Count silently included headings would be wrong in each of them.
+ ///
+ ///
+ internal ObservableCollection SidebarRows { get; } = [];
+
+ /// The groups in this vault, with the number of hosts filed under each.
+ internal ObservableCollection Groups { get; } = [];
+
+ /// The saved commands in this vault, unpushed local state included.
+ ///
+ /// Held here rather than on the screen that shows them, for the reason every other list is: this is where
+ /// the reload and the automatic push are wired, and a second copy of that wiring is a second place for it
+ /// to be forgotten. SnippetsViewModel is the filter and the editor over the top.
+ ///
+ internal ObservableCollection Snippets { get; } = [];
+
///
/// What the sidebar's one group heading says.
///
///
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
/// folders on HostSecret , and deriving a group from a naming convention would be a guess
- /// presented as structure. One heading, because one vault is reachable: the server denies access to
- /// every vault that is not this user's own. See docs/design-import-gaps.md .
+ /// presented as structure.
+ ///
+ /// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold
+ /// several, and then the heading stops naming one of them and each row names its own — a heading that
+ /// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this
+ /// interface is otherwise careful about.
+ ///
///
- internal string HostsHeading => VaultName.ToUpperInvariant();
+ internal string HostsHeading =>
+ session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
/// Whether the host list under the heading is folded away.
[ObservableProperty]
@@ -594,6 +826,9 @@ internal sealed partial class VaultViewModel(
/// The stored credentials to show, unpushed local state included.
internal ObservableCollection Credentials { get; } = [];
+ /// The buckets to show, unpushed local state included.
+ internal ObservableCollection ObjectStores { get; } = [];
+
/// The host keys this user has approved.
internal ObservableCollection KnownHostPins { get; } = [];
@@ -601,17 +836,81 @@ internal sealed partial class VaultViewModel(
internal ObservableCollection Conflicts { get; } = [];
internal string VaultName =>
- session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault";
+ session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";
+
+ ///
+ /// The vaults a new item may be filed into: readable, and writable by this account.
+ ///
+ ///
+ /// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one
+ /// it can read but not write is a team vault this member is a viewer of — offering either would end
+ /// in a Save that fails, one of them locally and one at the server.
+ ///
+ internal ObservableCollection TargetVaults { get; } = [];
+
+ ///
+ /// Where the next new item goes.
+ ///
+ ///
+ /// Falls back to the session's active vault, which is the personal one wherever there is one. Filing
+ /// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is
+ /// visible to people who should not have it, and moving it afterwards means deleting and retyping.
+ ///
+ internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;
+
+ /// Whether there is more than one vault to choose between.
+ ///
+ /// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is
+ /// a question with no answer, and for most people this stays at one for ever.
+ ///
+ internal bool HasVaultChoice => TargetVaults.Count > 1;
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? selectedTargetVault;
[ObservableProperty]
private HostRowViewModel? selectedHost;
+ ///
+ /// What the sidebar's ListBox has selected, which may be a heading.
+ ///
+ ///
+ ///
+ /// The list holds two kinds of row and only one of them is a host, so the control's selection and the
+ /// application's selection are no longer the same thing. This is the control's;
+ /// stays the application's, and everything that acts on a host — connecting, editing, deleting — goes on
+ /// reading that one.
+ ///
+ ///
+ /// A heading click is bounced back to whatever was selected before it rather than being left highlighted
+ /// or clearing the selection. Clearing would mean the buttons at the foot of the sidebar quietly stopped
+ /// working because somebody folded a group away; leaving it highlighted would mean a selected row that
+ /// none of those buttons act on.
+ ///
+ ///
+ [ObservableProperty]
+ private ISidebarRow? selectedSidebarRow;
+
+ [ObservableProperty]
+ private HostGroupRowViewModel? selectedGroup;
+
+ /// What the group name box holds, for both creating and renaming.
+ [ObservableProperty]
+ private string groupEditorLabel = string.Empty;
+
+ /// The group being renamed, or null when the box would create one.
+ [ObservableProperty]
+ private Guid? editingGroupId;
+
[ObservableProperty]
private SshKeyRowViewModel? selectedKey;
[ObservableProperty]
private CredentialRowViewModel? selectedCredential;
+ [ObservableProperty]
+ private ObjectStoreRowViewModel? selectedObjectStore;
+
[ObservableProperty]
private KnownHostRowViewModel? selectedKnownHost;
@@ -686,7 +985,7 @@ internal sealed partial class VaultViewModel(
internal bool ShowsCredentials => Section is VaultSection.Credentials;
///
- internal bool ShowsKnownHosts => Section is VaultSection.KnownHosts;
+ internal bool ShowsBuckets => Section is VaultSection.Buckets;
///
/// The rows the vault table is showing, for whichever category is selected.
@@ -713,7 +1012,7 @@ internal sealed partial class VaultViewModel(
{
VaultSection.Keys => "SSH KEYS",
VaultSection.Credentials => "PASSWORDS",
- VaultSection.KnownHosts => "HOST KEYS",
+ VaultSection.Buckets => "BUCKETS",
_ => "ALL ITEMS",
};
@@ -741,8 +1040,12 @@ internal sealed partial class VaultViewModel(
}
}
- /// Everything in the vault except the hosts, which have their own screen.
- internal int TotalItemCount => Keys.Count + Credentials.Count + KnownHostPins.Count;
+ /// Everything on this screen, which is the keychain less the hosts and the pins.
+ ///
+ /// Both of those have screens of their own now. Counting a pin here would put a number on the ALL
+ /// category that the ALL category does not list.
+ ///
+ internal int TotalItemCount => Keys.Count + Credentials.Count;
internal bool HasVaultItems => VaultItems.Count > 0;
@@ -750,15 +1053,14 @@ internal sealed partial class VaultViewModel(
/// Whether the selected row is one with an editor behind it.
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
- VaultItemKind.Key or VaultItemKind.Credential;
+ VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore;
- /// Whether the selected row is a pinned host key, which is edited by being withdrawn.
- internal bool SelectedItemIsPin => SelectedVaultItem?.Kind is VaultItemKind.KnownHost;
+ /// Whether the selected row is an SSH key, which is the only kind with a public half to copy.
+ internal bool SelectedItemIsKey => SelectedVaultItem?.Kind is VaultItemKind.Key;
/// What the detail pane calls the block under the chips.
internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch
{
- VaultItemKind.KnownHost => "FINGERPRINT",
VaultItemKind.Credential => "ACCOUNT",
_ => "WHAT IS STORED",
};
@@ -780,19 +1082,11 @@ internal sealed partial class VaultViewModel(
"No SSH keys yet. Paste one in and bind a host to it, and that host stops asking for a password.",
VaultSection.Credentials =>
"No stored passwords yet. Add one to stop typing the same password into every connection.",
- VaultSection.KnownHosts =>
- "No host keys approved yet. One appears here the first time you accept a host's fingerprint.",
- _ => "Nothing in the vault but your hosts. Add an SSH key or a password to stop typing one.",
+ VaultSection.Buckets =>
+ "No buckets yet. Add one to browse S3-compatible storage beside a host on the Files screen.",
+ _ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.",
};
- /// Whether the category showing is one that can have something added to it.
- ///
- /// Pins are the exception and always have been: one appears because somebody approved a fingerprint at
- /// the moment of connecting, which is the only place it can be checked against what the operator
- /// published. A form for typing one in would be a form for pasting whatever a man in the middle offered.
- ///
- internal bool CanAddToSection => Section is not VaultSection.KnownHosts;
-
// ---- The editor ----
[ObservableProperty]
@@ -830,9 +1124,34 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private AuthenticationChoice? editorSelectedAuthentication;
+ /// What the group picker offers: "no group", then every group.
+ ///
+ internal ObservableCollection EditorGroupChoices { get; } = [];
+
+ [ObservableProperty]
+ private GroupChoice? editorSelectedGroup;
+
/// The item being edited, or null when creating.
private Guid? editingEntityId;
+ ///
+ /// Which vault the editor will write to.
+ ///
+ ///
+ /// Captured when the editor opens rather than read at save time, and there are two different reasons
+ /// for that depending on which way the editor was opened. Editing an existing item, it is the vault
+ /// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the
+ /// target picker said at that moment , so changing the picker afterwards cannot silently move
+ /// a half-typed host into a team's vault.
+ ///
+ private Guid editingHostVaultId;
+
+ /// Which vault the key editor will write to. See .
+ private Guid editingKeyVaultId;
+
+ /// Which vault the credential editor will write to. See .
+ private Guid editingCredentialVaultId;
+
///
/// Whether the editor is showing a host that could have a pinned key to forget.
///
@@ -875,6 +1194,40 @@ internal sealed partial class VaultViewModel(
/// The key being edited, or null when creating.
private Guid? editingKeyId;
+ // ---- Generating a key ----
+
+ ///
+ /// Whether the small form in front of generating a key is showing.
+ ///
+ ///
+ /// A step of its own rather than two more boxes in the key editor, because the two are opposite
+ /// directions: the editor is where a key that already exists is pasted in, and this makes one that does
+ /// not exist yet. What it produces lands in that editor unsaved, so there is still exactly one thing in
+ /// this application that writes a key, and it is still SAVE.
+ ///
+ [ObservableProperty]
+ private bool isGeneratingKey;
+
+ ///
+ /// What the generated key is called, and the comment written into it.
+ ///
+ ///
+ /// One field for both. The comment is the only thing in a host's authorized_keys that will ever
+ /// say where a key came from, and a key whose name here and comment there disagree is one nobody can
+ /// match up months later when they are deciding which line to delete.
+ ///
+ [ObservableProperty]
+ private string generateComment = string.Empty;
+
+ [ObservableProperty]
+ private SshKeyAlgorithm generateAlgorithm = SshKeyAlgorithm.Ed25519;
+
+ ///
+ internal bool GeneratesEd25519 => GenerateAlgorithm is SshKeyAlgorithm.Ed25519;
+
+ ///
+ internal bool GeneratesRsa => GenerateAlgorithm is SshKeyAlgorithm.Rsa4096;
+
// ---- The credential editor ----
// A third set, on the same reasoning as the second: three editors holding unrelated fields, and sharing
// them would mean a half-typed key reappearing inside a credential.
@@ -906,6 +1259,53 @@ internal sealed partial class VaultViewModel(
/// The credential being edited, or null when creating.
private Guid? editingCredentialId;
+ // ---- The bucket editor ----
+ // A fourth set, on the same reasoning as the third.
+
+ [ObservableProperty]
+ private bool isEditingObjectStore;
+
+ [ObservableProperty]
+ private string bucketEditorLabel = string.Empty;
+
+ [ObservableProperty]
+ private string bucketEditorBucket = string.Empty;
+
+ [ObservableProperty]
+ private string bucketEditorAccessKeyId = string.Empty;
+
+ ///
+ /// Holds a secret access key for as long as the editor is open, and cancelling clears it — the same
+ /// bargain, and the same limits, as the password box. A secret access key is a password.
+ ///
+ [ObservableProperty]
+ private string bucketEditorSecretAccessKey = string.Empty;
+
+ [ObservableProperty]
+ private string bucketEditorRegion = string.Empty;
+
+ ///
+ /// Blank means Amazon and the region resolves the host. Anything else is a full URL, which is what makes
+ /// this work against a MinIO on somebody's own network.
+ ///
+ [ObservableProperty]
+ private string bucketEditorEndpoint = string.Empty;
+
+ ///
+ /// Defaulted on for a new bucket, which is the opposite of the AWS default and the right guess here:
+ /// somebody adding a bucket with a custom endpoint is nearly always pointing at a self-hosted service,
+ /// and those have no wildcard DNS. Somebody adding an AWS bucket leaves the endpoint blank, and
+ /// turns it off for them.
+ ///
+ [ObservableProperty]
+ private bool bucketEditorUsePathStyle;
+
+ [ObservableProperty]
+ private string bucketEditorNotes = string.Empty;
+
+ /// The bucket being edited, or null when creating.
+ private Guid? editingObjectStoreId;
+
// ---- Deleting ----
/// The deletion that has been asked for, or null when nothing has been.
@@ -926,7 +1326,29 @@ internal sealed partial class VaultViewModel(
/// buttons that asked it, so that DELETE cannot be pressed a second time while its own confirmation is
/// on screen.
///
- internal bool ShowsHostActions => !IsEditing && !IsConfirmingDeletion;
+ internal bool ShowsHostActions => !IsEditing && !IsConfirmingHostDeletion;
+
+ ///
+ /// Whether the question on screen is the one about deleting a host.
+ ///
+ ///
+ /// The sidebar and the group panel are on the same screen, and there is one pending deletion between
+ /// them, so each has to ask whether the question is its own — otherwise deleting a group draws
+ /// the group's question inside the host sidebar as well, in a place its buttons never were.
+ ///
+ internal bool IsConfirmingHostDeletion => PendingDeletion?.Target is DeletionTarget.Host;
+
+ ///
+ internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group;
+
+ /// Whether the group panel's buttons are showing.
+ internal bool ShowsGroupActions => !IsConfirmingGroupDeletion;
+
+ /// Whether this vault has any groups, which is what makes the sidebar draw headings.
+ internal bool HasGroups => Groups.Count > 0;
+
+ /// What the group panel's save button says.
+ internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "RENAME";
/// Whether the vault screen's Edit and Delete are showing.
///
@@ -965,7 +1387,7 @@ internal sealed partial class VaultViewModel(
///
internal string SelectedHostAuthenticationNote => SelectedHost?.Host switch
{
- { CredentialId: not null } => "This host uses a password stored in your vault.",
+ { CredentialId: not null } => "This host uses a password stored in your keychain.",
{ SshKeyId: not null } => "This host authenticates with its SSH key.",
_ => string.Empty,
};
@@ -1050,10 +1472,20 @@ internal sealed partial class VaultViewModel(
///
private async Task ReloadAsync(CancellationToken cancellationToken)
{
- var unreadable = await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
+ // First, because the four lists below are read across the same set and a vault admitted by the
+ // last refresh should appear in the picker on the same pass its items do.
+ RebuildTargetVaults();
+
+ // Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what
+ // gets counted under them — so the host reload is the pass that can put both together.
+ var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);
+
+ unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
+ unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
+ unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
// Last, because it reads the host list to work out which pins nothing dials any more.
unreadable += await ReloadKnownHostsAsync(cancellationToken).ConfigureAwait(true);
@@ -1067,31 +1499,263 @@ internal sealed partial class VaultViewModel(
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
+ /// Refills the "file this into" picker from the vaults this session can read and write.
+ ///
+ /// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
+ /// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
+ /// vault rather than to nothing, so the next Save still has somewhere to go.
+ ///
+ private void RebuildTargetVaults()
+ {
+ var selectedId = TargetVaultId;
+
+ TargetVaults.Clear();
+
+ foreach (var vault in session.ReadableVaults
+ .Where(vault => vault.CanWrite)
+ .OrderByDescending(vault => vault.IsPersonal)
+ .ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
+ {
+ TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
+ }
+
+ SelectedTargetVault =
+ TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId)
+ ?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId)
+ ?? TargetVaults.FirstOrDefault();
+
+ OnPropertyChanged(nameof(HasVaultChoice));
+ }
+
/// How many hosts would not decrypt.
private async Task ReloadHostsAsync(CancellationToken cancellationToken)
{
- var listing = await session.Hosts
- .ListAsync(session.ActiveVaultId, cancellationToken)
- .ConfigureAwait(true);
-
var selectedId = SelectedHost?.EntityId;
+ var unreadable = 0;
+ var rows = new List();
+
+ // Every vault this session holds a key for, not only the one new items are filed into. A team
+ // vault whose hosts never reached this list would make sharing look as though it had not worked.
+ var readable = session.ReadableVaults.ToList();
+ var several = readable.Count > 1;
+
+ foreach (var vault in readable)
+ {
+ var listing = await session.Hosts
+ .ListAsync(vault.VaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ unreadable += listing.Unreadable;
+
+ rows.AddRange(listing.Items.Select(
+ item => new HostRowViewModel(item, vault.VaultId, vault.Name)
+ {
+ // Only when there is something to tell apart. A badge on every row of a
+ // single-vault list is noise that says the same thing on all of them.
+ VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
+ }));
+ }
Hosts.Clear();
- foreach (var host in listing.Items.OrderBy(host => host.Secret.Label, StringComparer.CurrentCulture))
+ // Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
+ // hold a host with the same label and both are shown: which vault it is in is what tells them
+ // apart, which is why the row carries the name rather than the list deduplicating.
+ foreach (var host in rows
+ .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
+ .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
+ .ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
- Hosts.Add(new HostRowViewModel(host));
+ Hosts.Add(host);
}
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
// under the user.
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
+ // Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
+ // the group rows.
+ RebuildGroups();
RebuildVisibleHosts();
+ return unreadable;
+ }
+
+ /// How many buckets would not decrypt.
+ ///
+ /// The selection survives a reload and a reload never invents one, as the key and credential lists do and
+ /// for the same reason: it is what the delete button aims at.
+ ///
+ private async Task ReloadObjectStoresAsync(CancellationToken cancellationToken)
+ {
+ var listing = await session.ObjectStores
+ .ListAsync(session.ActiveVaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ var selectedId = SelectedObjectStore?.EntityId;
+
+ ObjectStores.Clear();
+
+ foreach (var store in listing.Items
+ .OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
+ {
+ ObjectStores.Add(new ObjectStoreRowViewModel(store));
+ }
+
+ SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
+
return listing.Unreadable;
}
+ /// How many snippets would not decrypt.
+ ///
+ /// No selection to preserve: what a snippet screen selects is its own, and it restores it around this
+ /// list changing the way every other screen does.
+ ///
+ private async Task ReloadSnippetsAsync(CancellationToken cancellationToken)
+ {
+ var listing = await session.Snippets
+ .ListAsync(session.ActiveVaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ Snippets.Clear();
+
+ foreach (var snippet in listing.Items
+ .OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture))
+ {
+ Snippets.Add(new SnippetRowViewModel(snippet));
+ }
+
+ return listing.Unreadable;
+ }
+
+ /// Stores one snippet, encrypted, and queues it for the server.
+ /// The snippet to replace, or null to create one.
+ /// What to store.
+ /// Cancellation.
+ /// Whether it was stored; means the reason is in .
+ ///
+ /// Here rather than on the screen, so the write goes through the same repository, the same outbox and the
+ /// same immediate push as every other save. The screen decides what a snippet is and nothing
+ /// else.
+ ///
+ internal async Task SaveSnippetAsync(
+ Guid? entityId,
+ SnippetSecret snippet,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(snippet);
+
+ if (!snippet.TryValidate(out var reason))
+ {
+ Status = reason;
+ return false;
+ }
+
+ await RunAsync(
+ "Saving…",
+ async () =>
+ {
+ if (entityId is { } existing)
+ {
+ await session.Snippets
+ .UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken)
+ .ConfigureAwait(true);
+ }
+ else
+ {
+ await session.Snippets
+ .CreateAsync(session.ActiveVaultId, snippet, cancellationToken)
+ .ConfigureAwait(true);
+ }
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = connection() is null
+ ? $"Saved '{snippet.Label}'. It will sync when you are online."
+ : $"Saved '{snippet.Label}'.";
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+
+ return true;
+ }
+
+ /// Queues a tombstone for one snippet.
+ internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
+ {
+ if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
+ {
+ Status = "That snippet is no longer here, so nothing was deleted.";
+ return;
+ }
+
+ await RunAsync(
+ "Deleting…",
+ async () =>
+ {
+ await session.Snippets
+ .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ Status = $"Deleted '{row.Label}'.";
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ /// How many groups would not decrypt.
+ ///
+ ///
+ /// The listing is kept rather than projected straight into , because a group row
+ /// carries how many hosts name it and the hosts have not been read yet when this runs. See
+ /// , which is where the two meet.
+ ///
+ ///
+ /// The active vault only, unlike every other list on this screen. Hosts, keys, credentials and
+ /// pins are read across every vault this session holds a key for; groups are not, so a host in a team's
+ /// vault that a teammate filed appears under UNGROUPED. That is the same thing the sidebar already shows
+ /// for a group that has been deleted, and it is deliberate here rather than an oversight: reading them
+ /// across vaults means a group row has to carry the vault it lives in — rename and delete both need it —
+ /// and two vaults may hold groups with the same name, which the one-heading-per-group layout cannot tell
+ /// apart. Both are worth doing and neither is a merge's business. Recorded in
+ /// docs/design-import-gaps.md .
+ ///
+ ///
+ private async Task ReloadGroupsAsync(CancellationToken cancellationToken)
+ {
+ var listing = await session.HostGroups
+ .ListAsync(session.ActiveVaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
+
+ return listing.Unreadable;
+ }
+
+ /// Refills , counting the hosts filed under each.
+ private void RebuildGroups()
+ {
+ var selectedId = SelectedGroup?.EntityId;
+
+ Groups.Clear();
+
+ foreach (var group in groupItems)
+ {
+ var count = Hosts.Count(row => row.Host.GroupId == group.EntityId);
+
+ Groups.Add(new HostGroupRowViewModel(group, count));
+ }
+
+ // Never defaulted to the first row, as the key and credential lists are not: this selection is what
+ // RENAME and DELETE aim at, and a background sync that picked a group would point them at one nobody
+ // chose.
+ SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
+
+ OnPropertyChanged(nameof(HasGroups));
+ }
+
/// Refills the sidebar's list from and the filter.
///
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
@@ -1113,11 +1777,120 @@ internal sealed partial class VaultViewModel(
VisibleHosts.Add(host);
}
+ RebuildSidebarRows();
+
// Restored when it still matches, and explicitly cleared when it does not — rather than left alone
// and trusted to whatever a live SelectedItem binding happens to do about it. A filter that hides
// the selected host has to mean nothing is selected: Connect, Edit and Delete all read this
// property directly, and a host that is not on screen is not one any of them should act on.
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
+
+ // After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
+ // above is a null that has to be overwritten rather than read.
+ SelectedSidebarRow = SelectedHost;
+ }
+
+ ///
+ /// Lays the visible hosts out under their group headings.
+ ///
+ ///
+ ///
+ /// No groups means no headings. The sidebar of a vault nobody has filed anything in is the list it
+ /// always was, which is what makes this feature cost nothing to ignore.
+ ///
+ ///
+ /// A host whose group has been deleted falls under the ungrouped heading rather than disappearing
+ /// or keeping an empty heading of its own. The reference is allowed to dangle — see
+ /// for why deleting a group deliberately does not rewrite the hosts in
+ /// it — so "the group this names is not here" and "this names no group" have to look the same, because to
+ /// the user they are the same thing.
+ ///
+ ///
+ /// An empty group still gets its heading, and a group emptied by the filter does not. The first
+ /// is a thing the user made and can file hosts into; the second is an absence of search results, and a
+ /// heading with nothing under it would read as a group that had lost its contents.
+ ///
+ ///
+ private void RebuildSidebarRows()
+ {
+ SidebarRows.Clear();
+
+ if (Groups.Count == 0)
+ {
+ foreach (var host in VisibleHosts)
+ {
+ SidebarRows.Add(host);
+ }
+
+ return;
+ }
+
+ var known = Groups.Select(group => group.EntityId).ToHashSet();
+
+ foreach (var group in Groups)
+ {
+ AddSection(group.EntityId, group.Label, host => host.Host.GroupId == group.EntityId);
+ }
+
+ AddSection(
+ null,
+ "UNGROUPED",
+ host => host.Host.GroupId is not { } id || !known.Contains(id),
+ onlyWhenOccupied: true);
+
+ void AddSection(
+ Guid? groupId,
+ string label,
+ Func belongs,
+ bool onlyWhenOccupied = false)
+ {
+ var members = VisibleHosts.Where(belongs).ToArray();
+
+ if (onlyWhenOccupied && members.Length == 0)
+ {
+ return;
+ }
+
+ var expanded = !collapsedGroups.Contains(groupId ?? Guid.Empty);
+
+ SidebarRows.Add(new SidebarGroupHeader(groupId, label, members.Length, expanded));
+
+ if (!expanded)
+ {
+ return;
+ }
+
+ foreach (var member in members)
+ {
+ SidebarRows.Add(member);
+ }
+ }
+ }
+
+ /// Folds one group's hosts away, or brings them back.
+ ///
+ /// Keyed on the group id in a set of the folded ones rather than on a flag on the row, because the rows
+ /// are rebuilt from scratch on every filter keystroke and every background sync — a flag would be
+ /// forgotten a minute after it was set. The ungrouped heading uses , which is not
+ /// a legal group id: HostSecret.TryValidate refuses one.
+ ///
+ [RelayCommand]
+ private void ToggleGroup(SidebarGroupHeader? header)
+ {
+ if (header is null)
+ {
+ return;
+ }
+
+ var key = header.GroupId ?? Guid.Empty;
+
+ if (!collapsedGroups.Remove(key))
+ {
+ collapsedGroups.Add(key);
+ }
+
+ RebuildSidebarRows();
+ SelectedSidebarRow = SelectedHost;
}
///
@@ -1148,22 +1921,35 @@ internal sealed partial class VaultViewModel(
///
private async Task ReloadKeysAsync(CancellationToken cancellationToken)
{
- var listing = await session.SshKeys
- .ListAsync(session.ActiveVaultId, cancellationToken)
- .ConfigureAwait(true);
-
var selectedId = SelectedKey?.EntityId;
+ var unreadable = 0;
+ var rows = new List();
+
+ foreach (var vault in session.ReadableVaults)
+ {
+ var listing = await session.SshKeys
+ .ListAsync(vault.VaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ unreadable += listing.Unreadable;
+
+ rows.AddRange(listing.Items.Select(
+ item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
+ }
Keys.Clear();
- foreach (var key in listing.Items.OrderBy(key => key.Secret.Label, StringComparer.CurrentCulture))
+ foreach (var key in rows
+ .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
+ .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
+ .ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
- Keys.Add(new SshKeyRowViewModel(key));
+ Keys.Add(key);
}
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);
- return listing.Unreadable;
+ return unreadable;
}
/// How many credentials would not decrypt.
@@ -1175,23 +1961,35 @@ internal sealed partial class VaultViewModel(
///
private async Task ReloadCredentialsAsync(CancellationToken cancellationToken)
{
- var listing = await session.Credentials
- .ListAsync(session.ActiveVaultId, cancellationToken)
- .ConfigureAwait(true);
-
var selectedId = SelectedCredential?.EntityId;
+ var unreadable = 0;
+ var rows = new List();
+
+ foreach (var vault in session.ReadableVaults)
+ {
+ var listing = await session.Credentials
+ .ListAsync(vault.VaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ unreadable += listing.Unreadable;
+
+ rows.AddRange(listing.Items.Select(
+ item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
+ }
Credentials.Clear();
- foreach (var credential in listing.Items
- .OrderBy(credential => credential.Secret.Label, StringComparer.CurrentCulture))
+ foreach (var credential in rows
+ .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
+ .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
+ .ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
- Credentials.Add(new CredentialRowViewModel(credential));
+ Credentials.Add(credential);
}
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId);
- return listing.Unreadable;
+ return unreadable;
}
/// How many pins would not decrypt.
@@ -1202,11 +2000,9 @@ internal sealed partial class VaultViewModel(
///
private async Task ReloadKnownHostsAsync(CancellationToken cancellationToken)
{
- var listing = await session.KnownHosts
- .ListAsync(session.ActiveVaultId, cancellationToken)
- .ConfigureAwait(true);
-
var selectedId = SelectedKnownHost?.EntityId;
+ var unreadable = 0;
+ var rows = new List();
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
// hundred scans of the host list on every background sync.
@@ -1214,20 +2010,39 @@ internal sealed partial class VaultViewModel(
.Select(host => Endpoint(host.Host.Hostname, host.Host.Port))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ // Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
+ // the active vault alone. The difference is deliberate and is stated in the README: a pin in a
+ // team vault is something a teammate can write, and letting it answer for a host in somebody's
+ // personal vault would let one member suppress another's first-contact prompt. Showing them is
+ // safe and is the only way somebody can see what their team has trusted.
+ foreach (var vault in session.ReadableVaults)
+ {
+ var listing = await session.KnownHosts
+ .ListAsync(vault.VaultId, cancellationToken)
+ .ConfigureAwait(true);
+
+ unreadable += listing.Unreadable;
+
+ rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel(
+ item,
+ dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)),
+ vault.VaultId,
+ vault.Name)));
+ }
+
KnownHostPins.Clear();
- foreach (var pin in listing.Items
- .OrderBy(pin => pin.Secret.Host, StringComparer.CurrentCulture)
- .ThenBy(pin => pin.Secret.Port)
- .ThenBy(pin => pin.Secret.Algorithm, StringComparer.Ordinal))
+ foreach (var pin in rows
+ .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
+ .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
+ .ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
- KnownHostPins.Add(new KnownHostRowViewModel(
- pin, dialled.Contains(Endpoint(pin.Secret.Host, pin.Secret.Port))));
+ KnownHostPins.Add(pin);
}
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId);
- return listing.Unreadable;
+ return unreadable;
}
///
@@ -1351,15 +2166,22 @@ internal sealed partial class VaultViewModel(
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
- // A pass that had to start over says so even when it pulled nothing, which is the one place
- // this loop breaks its own rule about staying quiet. A machine that silently re-read the whole
- // vault has had something happen to it, and the alternative is that nobody ever finds out.
- if (report is not null
- && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention
- || report.ResyncedFromStart))
+ if (report is null)
+ {
+ return;
+ }
+
+ // A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it
+ // gets the treatment the catch below gives a total failure, the fact kept and the message
+ // swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was
+ // reading, once a minute, with the name of a vault it could not reach. Pressing Sync still
+ // names the vault and the reason, because somebody who pressed it is waiting for an answer.
+ if (IsWorthReporting(report))
{
Status = Describe(report);
}
+
+ await PruneLogsIfDueAsync(cancellationToken).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
@@ -1382,7 +2204,9 @@ internal sealed partial class VaultViewModel(
/// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
/// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
///
- private async Task SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken)
+ private async Task?> SyncOnceAsync(
+ ISyncApi api,
+ CancellationToken cancellationToken)
{
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
{
@@ -1391,9 +2215,16 @@ internal sealed partial class VaultViewModel(
try
{
- var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true);
+ // Every vault this session can read, not only the one new items are filed into. A team's
+ // vault that never synced would show its hosts exactly once — at the unlock that first
+ // pulled it — and then quietly stop, which reads as the feature not working.
+ var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true);
- LastSyncFailed = false;
+ // Not unconditionally false, which it was while a pass was one vault and a failure was an
+ // exception. A failure is now a report — one unreachable team vault must not stop the others
+ // syncing — so clearing the flag here regardless would light the titlebar green over a vault
+ // that had just failed to sync, which is exactly the lie that flag exists to prevent.
+ LastSyncFailed = report.Any(vault => !vault.Succeeded);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -1483,6 +2314,7 @@ internal sealed partial class VaultViewModel(
}
editingEntityId = null;
+ editingHostVaultId = TargetVaultId;
EditorLabel = string.Empty;
EditorHostname = string.Empty;
EditorPort = HostSecret.DefaultPort;
@@ -1490,6 +2322,10 @@ internal sealed partial class VaultViewModel(
EditorNotes = string.Empty;
EditorRelayEnabled = false;
BuildAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
+
+ // A new host opens in whichever group is selected beside the list, if one is, because adding three
+ // machines to the group somebody has just made is the ordinary case.
+ BuildGroupChoices(SelectedGroup?.EntityId);
IsEditing = true;
Status = "Adding a host.";
}
@@ -1512,6 +2348,7 @@ internal sealed partial class VaultViewModel(
}
editingEntityId = row.EntityId;
+ editingHostVaultId = row.VaultId;
EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname;
EditorPort = row.Host.Port;
@@ -1519,6 +2356,7 @@ internal sealed partial class VaultViewModel(
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
BuildAuthenticationChoices(row.Host.SshKeyId, row.Host.CredentialId);
+ BuildGroupChoices(row.Host.GroupId);
IsEditing = true;
Status = $"Editing {row.Label}.";
}
@@ -1544,6 +2382,10 @@ internal sealed partial class VaultViewModel(
EditSelectedCredentialCommand.Execute(null);
break;
+ case VaultItemKind.ObjectStore:
+ EditObjectStoreCommand.Execute(null);
+ break;
+
default:
// A pin has no editor. Its button is Forget, and it is elsewhere on the pane.
break;
@@ -1569,6 +2411,10 @@ internal sealed partial class VaultViewModel(
DeleteCredentialCommand.Execute(null);
break;
+ case VaultItemKind.ObjectStore:
+ DeleteObjectStoreCommand.Execute(null);
+ break;
+
default:
break;
}
@@ -1578,6 +2424,148 @@ internal sealed partial class VaultViewModel(
[RelayCommand]
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
+ /// Stores whatever the group name box holds, as a new group or as a rename.
+ ///
+ ///
+ /// One box and one button for both, because a group is one field: a separate "rename" form would be the
+ /// same text box with a different title. is what decides which of the two
+ /// this is, and it is set by and cleared by everything else.
+ ///
+ ///
+ /// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
+ /// wrong — hosts point at ids, so the two are genuinely separate folders — and refusing the
+ /// second one would mean a name somebody chose on another machine could block one they choose here, at
+ /// the next sync, with the rename already saved.
+ ///
+ ///
+ [RelayCommand]
+ private async Task SaveGroupAsync(CancellationToken cancellationToken)
+ {
+ var group = new HostGroupSecret { Label = GroupEditorLabel.Trim() };
+
+ if (!group.TryValidate(out var reason))
+ {
+ Status = reason;
+ return;
+ }
+
+ var renaming = EditingGroupId;
+
+ await RunAsync(
+ "Saving…",
+ async () =>
+ {
+ if (renaming is { } entityId)
+ {
+ await session.HostGroups
+ .UpdateAsync(session.ActiveVaultId, entityId, group, cancellationToken)
+ .ConfigureAwait(true);
+ }
+ else
+ {
+ await session.HostGroups
+ .CreateAsync(session.ActiveVaultId, group, cancellationToken)
+ .ConfigureAwait(true);
+ }
+
+ GroupEditorLabel = string.Empty;
+ EditingGroupId = null;
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ Status = renaming is null ? $"Added the group '{group.Label}'." : $"Renamed to '{group.Label}'.";
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ /// Loads the selected group's name into the box, so saving renames it.
+ [RelayCommand]
+ private void EditGroup()
+ {
+ if (SelectedGroup is not { } row)
+ {
+ return;
+ }
+
+ if (row.IsReadOnly)
+ {
+ Status = "This group was written by a newer version of DodoSSH. Update before editing it.";
+ return;
+ }
+
+ EditingGroupId = row.EntityId;
+ GroupEditorLabel = row.Label;
+ Status = $"Renaming {row.Label}.";
+ }
+
+ /// Abandons a rename, leaving the box ready to create one instead.
+ [RelayCommand]
+ private void CancelGroupEdit()
+ {
+ EditingGroupId = null;
+ GroupEditorLabel = string.Empty;
+ Status = string.Empty;
+ }
+
+ /// Asks whether the selected group should go.
+ ///
+ /// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
+ /// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
+ /// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
+ /// to move, and that none of them are going anywhere else.
+ ///
+ [RelayCommand]
+ private void DeleteGroup()
+ {
+ if (SelectedGroup is not { } row)
+ {
+ return;
+ }
+
+ PendingDeletion = new DeletionRequest(
+ DeletionTarget.Group,
+ row.EntityId,
+ $"Delete the group '{row.Label}'?",
+ HowFarADeletionGoes("The group"),
+ row.HostCount switch
+ {
+ 0 => string.Empty,
+ 1 => "1 host is filed under it. The host stays; it moves to UNGROUPED.",
+ _ => $"{row.HostCount} hosts are filed under it. They stay; they move to UNGROUPED.",
+ });
+ }
+
+ /// Queues a tombstone for the group that was agreed to.
+ private async Task DeleteGroupNowAsync(Guid entityId, CancellationToken cancellationToken)
+ {
+ if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
+ {
+ Status = "That group is no longer here, so nothing was deleted.";
+ return;
+ }
+
+ await RunAsync(
+ "Deleting…",
+ async () =>
+ {
+ await session.HostGroups
+ .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
+ .ConfigureAwait(true);
+
+ if (EditingGroupId == entityId)
+ {
+ EditingGroupId = null;
+ GroupEditorLabel = string.Empty;
+ }
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ Status = $"Deleted the group '{row.Label}'.";
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
/// Abandons the editor.
[RelayCommand]
private void CancelEdit()
@@ -1606,13 +2594,13 @@ internal sealed partial class VaultViewModel(
if (editingEntityId is { } entityId)
{
await session.Hosts
- .UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken)
+ .UpdateAsync(editingHostVaultId, entityId, host, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingEntityId = await session.Hosts
- .CreateAsync(session.ActiveVaultId, host, cancellationToken)
+ .CreateAsync(editingHostVaultId, host, cancellationToken)
.ConfigureAwait(true);
}
@@ -1632,6 +2620,68 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
+ ///
+ /// Stores several hosts at once, the way one save does.
+ ///
+ ///
+ ///
+ /// For the ssh_config import, which is the only thing that produces hosts in bulk. It goes
+ /// through the same repository, the same outbox and the same automatic push as saving one — the import
+ /// screen decides which hosts and nothing else, so there is no second way for a host to be
+ /// written and no second place for the sync wiring to be forgotten.
+ ///
+ ///
+ /// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
+ /// thirty rebuilds of the host list and thirty sync passes, which on a slow link is minutes of the
+ /// window doing nothing visible.
+ ///
+ ///
+ /// A host that fails validation is skipped and counted rather than aborting the batch. Twenty-nine good
+ /// hosts thrown away because the thirtieth had no hostname is not what anybody wants from an import.
+ ///
+ ///
+ internal async Task ImportHostsAsync(
+ IReadOnlyList hosts,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(hosts);
+
+ var imported = 0;
+ var refused = 0;
+
+ await RunAsync(
+ hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…",
+ async () =>
+ {
+ foreach (var host in hosts)
+ {
+ if (!host.TryValidate(out _))
+ {
+ refused++;
+ continue;
+ }
+
+ await session.Hosts
+ .CreateAsync(session.ActiveVaultId, host, cancellationToken)
+ .ConfigureAwait(true);
+
+ imported++;
+ }
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
+
+ Status = connection() is null
+ ? $"Imported {imported} host(s). They will sync when you are online.{refusals}"
+ : $"Imported {imported} host(s).{refusals}";
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+
+ return imported;
+ }
+
/// Asks whether the selected host should go.
///
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
@@ -1673,7 +2723,7 @@ internal sealed partial class VaultViewModel(
async () =>
{
await session.Hosts
- .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
+ .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -1696,6 +2746,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Keys;
editingKeyId = null;
+ editingKeyVaultId = TargetVaultId;
ClearKeyEditor();
IsEditingKey = true;
Status = "Adding an SSH key.";
@@ -1722,6 +2773,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Keys;
editingKeyId = row.EntityId;
+ editingKeyVaultId = row.VaultId;
KeyEditorLabel = row.Key.Label;
KeyEditorPrivateKey = row.Key.PrivateKeyPem;
KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
@@ -1731,6 +2783,136 @@ internal sealed partial class VaultViewModel(
Status = $"Editing {row.Label}.";
}
+ /// Opens the form in front of generating a key.
+ [RelayCommand]
+ private void NewGeneratedKey()
+ {
+ if (AVaultEditorIsInTheWay())
+ {
+ return;
+ }
+
+ Section = VaultSection.Keys;
+ GenerateComment = $"{Environment.UserName}@{Environment.MachineName}";
+ GenerateAlgorithm = SshKeyAlgorithm.Ed25519;
+ IsGeneratingKey = true;
+ Status = "Generating a new SSH key.";
+ }
+
+ /// Chooses which kind of key to make.
+ ///
+ /// A command and two buttons rather than a selector bound to , which is
+ /// the idiom the category rail already uses and for the same reason: a selector moves its own highlight
+ /// before anything here can decide, so it can end up showing a choice that was not made.
+ ///
+ [RelayCommand]
+ private void ChooseKeyAlgorithm(SshKeyAlgorithm algorithm) => GenerateAlgorithm = algorithm;
+
+ /// Abandons the generate form without making anything.
+ [RelayCommand]
+ private void CancelGenerateKey()
+ {
+ IsGeneratingKey = false;
+ GenerateComment = string.Empty;
+ Status = string.Empty;
+ }
+
+ ///
+ /// Makes a new key pair and drops it into the key editor, unsaved.
+ ///
+ ///
+ ///
+ /// It does not save. What comes back lands in the editor and waits for SAVE, so the whole
+ /// storage path — validation, encoding, the outbox, the push — is the one that already exists and this
+ /// command has no second version of it. It also means a generated key can be renamed or annotated
+ /// before it is written, and abandoned by pressing CANCEL.
+ ///
+ ///
+ /// Off the UI thread. RSA at 4096 bits is seconds of solid CPU, which on this thread is a frozen
+ /// window at the moment somebody is watching it — the same reason key derivation runs on a worker. The
+ /// generator does not know which algorithm is cheap, and neither should this.
+ ///
+ ///
+ /// The private key exists in memory from here until the editor is cleared, as a pasted one does. See
+ /// SshKeySecret for why a .NET string is the honest choice for that and what it does not buy.
+ ///
+ ///
+ [RelayCommand]
+ private async Task GenerateKeyAsync(CancellationToken cancellationToken)
+ {
+ var algorithm = GenerateAlgorithm;
+ var comment = string.IsNullOrWhiteSpace(GenerateComment)
+ ? $"{Environment.UserName}@{Environment.MachineName}"
+ : GenerateComment.Trim();
+
+ var kind = algorithm is SshKeyAlgorithm.Rsa4096 ? "RSA 4096-bit" : "Ed25519";
+
+ await RunAsync(
+ $"Generating a {kind} key…",
+ async () =>
+ {
+ var generated = await Task
+ .Run(() => SshKeyGenerator.Generate(algorithm, comment), cancellationToken)
+ .ConfigureAwait(true);
+
+ IsGeneratingKey = false;
+ editingKeyId = null;
+
+ // Filed where a pasted key would be, and set here rather than left over from whatever was
+ // edited last: this path opens the same editor without going through NewKey, so without
+ // this a key generated after editing a team's key would be saved into that team's vault.
+ editingKeyVaultId = TargetVaultId;
+
+ ClearKeyEditor();
+
+ KeyEditorLabel = comment;
+ KeyEditorPrivateKey = generated.PrivateKeyArmour;
+ KeyEditorPublicKey = generated.PublicKeyLine;
+ KeyEditorNotes = $"Generated by DodoSSH. {generated.Fingerprint}";
+
+ IsEditingKey = true;
+
+ Status = $"Generated {generated.Fingerprint}. Nothing is stored until you press SAVE.";
+ }).ConfigureAwait(true);
+ }
+
+ ///
+ /// Puts the selected key's public half on the clipboard.
+ ///
+ ///
+ /// The public half only, and there is deliberately no command for the other one. Installing a key means
+ /// pasting this line into a host's authorized_keys ; a private key on a clipboard is a private key
+ /// in every application on the machine and in whatever syncs it between them.
+ ///
+ [RelayCommand]
+ private async Task CopyPublicKeyAsync()
+ {
+ if (SelectedKey is not { } row)
+ {
+ Status = "Choose a key first.";
+ return;
+ }
+
+ if (row.Key.PublicKey is not { Length: > 0 } line)
+ {
+ // Not derivable here: SshKeySecret stores whatever armour it was given and declines to parse
+ // it, so a key imported without its .pub has no public half to offer. Saying so beats copying
+ // an empty string.
+ Status = $"'{row.Label}' has no public half stored. Paste it into the key's editor to keep it.";
+ return;
+ }
+
+ if (copyToClipboard is null)
+ {
+ Status = "This machine has no clipboard.";
+ return;
+ }
+
+ await copyToClipboard(line).ConfigureAwait(true);
+
+ Status = $"Copied the public key for '{row.Label}'. Add it to the host's ~/.ssh/authorized_keys.";
+ }
+
/// Abandons the key editor, clearing the material out of it.
[RelayCommand]
private void CancelKeyEdit()
@@ -1760,13 +2942,13 @@ internal sealed partial class VaultViewModel(
if (editingKeyId is { } entityId)
{
await session.SshKeys
- .UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken)
+ .UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingKeyId = await session.SshKeys
- .CreateAsync(session.ActiveVaultId, key, cancellationToken)
+ .CreateAsync(editingKeyVaultId, key, cancellationToken)
.ConfigureAwait(true);
}
@@ -1824,7 +3006,7 @@ internal sealed partial class VaultViewModel(
async () =>
{
await session.SshKeys
- .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
+ .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -1845,6 +3027,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Credentials;
editingCredentialId = null;
+ editingCredentialVaultId = TargetVaultId;
ClearCredentialEditor();
IsEditingCredential = true;
Status = "Adding a credential.";
@@ -1871,6 +3054,7 @@ internal sealed partial class VaultViewModel(
Section = VaultSection.Credentials;
editingCredentialId = row.EntityId;
+ editingCredentialVaultId = row.VaultId;
CredentialEditorLabel = row.Credential.Label;
CredentialEditorUsername = row.Credential.Username ?? string.Empty;
CredentialEditorPassword = row.Credential.Password;
@@ -1879,6 +3063,192 @@ internal sealed partial class VaultViewModel(
Status = $"Editing {row.Label}.";
}
+ /// Starts a new bucket.
+ ///
+ /// Path-style addressing starts off, which is the AWS default — and loads
+ /// whatever was stored. Somebody adding a self-hosted bucket turns it on, and the field says why.
+ ///
+ [RelayCommand]
+ private void NewObjectStore()
+ {
+ if (AVaultEditorIsInTheWay())
+ {
+ return;
+ }
+
+ Section = VaultSection.Buckets;
+ editingObjectStoreId = null;
+ ClearObjectStoreEditor();
+ IsEditingObjectStore = true;
+ Status = "Adding a bucket.";
+ }
+
+ /// Opens the selected bucket for editing.
+ [RelayCommand]
+ private void EditObjectStore()
+ {
+ if (SelectedObjectStore is not { } row || AVaultEditorIsInTheWay())
+ {
+ return;
+ }
+
+ if (row.IsReadOnly)
+ {
+ Status = "This bucket was written by a newer version of DodoSSH. Update before editing it.";
+ return;
+ }
+
+ Section = VaultSection.Buckets;
+ editingObjectStoreId = row.EntityId;
+ BucketEditorLabel = row.Store.Label;
+ BucketEditorBucket = row.Store.Bucket;
+ BucketEditorAccessKeyId = row.Store.AccessKeyId;
+ BucketEditorSecretAccessKey = row.Store.SecretAccessKey;
+ BucketEditorRegion = row.Store.Region ?? string.Empty;
+ BucketEditorEndpoint = row.Store.Endpoint ?? string.Empty;
+ BucketEditorUsePathStyle = row.Store.UsePathStyle;
+ BucketEditorNotes = row.Store.Notes ?? string.Empty;
+ IsEditingObjectStore = true;
+ Status = $"Editing {row.Label}.";
+ }
+
+ /// Abandons the bucket editor, clearing the secret access key out of it.
+ [RelayCommand]
+ private void CancelObjectStoreEdit()
+ {
+ IsEditingObjectStore = false;
+ editingObjectStoreId = null;
+ ClearObjectStoreEditor();
+ Status = string.Empty;
+ }
+
+ /// Stores the bucket editor's contents, encrypted, and queues it for the server.
+ [RelayCommand]
+ private async Task SaveObjectStoreAsync(CancellationToken cancellationToken)
+ {
+ var store = BuildObjectStore();
+
+ if (!store.TryValidate(out var reason))
+ {
+ Status = reason;
+ return;
+ }
+
+ await RunAsync(
+ "Saving…",
+ async () =>
+ {
+ if (editingObjectStoreId is { } entityId)
+ {
+ await session.ObjectStores
+ .UpdateAsync(session.ActiveVaultId, entityId, store, cancellationToken)
+ .ConfigureAwait(true);
+ }
+ else
+ {
+ editingObjectStoreId = await session.ObjectStores
+ .CreateAsync(session.ActiveVaultId, store, cancellationToken)
+ .ConfigureAwait(true);
+ }
+
+ IsEditingObjectStore = false;
+ ClearObjectStoreEditor();
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+
+ SelectedObjectStore = ObjectStores
+ .FirstOrDefault(row => row.EntityId == editingObjectStoreId);
+ editingObjectStoreId = null;
+
+ Status = connection() is null
+ ? $"Saved '{store.Label}'. It will sync when you are online."
+ : $"Saved '{store.Label}'.";
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ /// Asks whether the selected bucket should go.
+ ///
+ /// The bucket itself is untouched, and the question says so. Removing the entry here removes this
+ /// keychain's way of reaching it — the objects in it are somebody else's to delete, and a confirmation
+ /// that did not distinguish the two would be genuinely frightening.
+ ///
+ [RelayCommand]
+ private void DeleteObjectStore()
+ {
+ if (SelectedObjectStore is not { } row)
+ {
+ return;
+ }
+
+ PendingDeletion = new DeletionRequest(
+ DeletionTarget.ObjectStore,
+ row.EntityId,
+ $"Remove the bucket '{row.Label}'?",
+ HowFarADeletionGoes("The bucket's address and its keys"),
+ "Nothing in the bucket is touched. This removes the way this keychain reaches it, not the "
+ + "objects in it.");
+ }
+
+ /// Queues a tombstone for the bucket that was agreed to.
+ private async Task DeleteObjectStoreNowAsync(Guid entityId, CancellationToken cancellationToken)
+ {
+ if (ObjectStores.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
+ {
+ Status = "That bucket is no longer here, so nothing was removed.";
+ return;
+ }
+
+ await RunAsync(
+ "Removing…",
+ async () =>
+ {
+ await session.ObjectStores
+ .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
+ .ConfigureAwait(true);
+
+ await ReloadAsync(cancellationToken).ConfigureAwait(true);
+ Status = $"Removed '{row.Label}'.";
+ }).ConfigureAwait(true);
+
+ await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ /// Empties the bucket editor, including the secret access key.
+ private void ClearObjectStoreEditor()
+ {
+ BucketEditorLabel = string.Empty;
+ BucketEditorBucket = string.Empty;
+ BucketEditorAccessKeyId = string.Empty;
+ BucketEditorSecretAccessKey = string.Empty;
+ BucketEditorRegion = string.Empty;
+ BucketEditorEndpoint = string.Empty;
+ BucketEditorUsePathStyle = false;
+ BucketEditorNotes = string.Empty;
+ }
+
+ private ObjectStoreSecret BuildObjectStore() =>
+ new()
+ {
+ Label = BucketEditorLabel.Trim(),
+ Bucket = BucketEditorBucket.Trim(),
+
+ // Trimmed, both of them. A pasted access key with a trailing newline signs every request wrongly
+ // and the service answers "SignatureDoesNotMatch", which names neither the field nor the paste.
+ AccessKeyId = BucketEditorAccessKeyId.Trim(),
+ SecretAccessKey = BucketEditorSecretAccessKey.Trim(),
+
+ // Blank is a real answer for both — no region, or no custom endpoint — and is stored as null so
+ // that ObjectStoreSecret can tell "not set" from "set to nothing".
+ Region = string.IsNullOrWhiteSpace(BucketEditorRegion) ? null : BucketEditorRegion.Trim(),
+ Endpoint = string.IsNullOrWhiteSpace(BucketEditorEndpoint)
+ ? null
+ : BucketEditorEndpoint.Trim().TrimEnd('/'),
+ UsePathStyle = BucketEditorUsePathStyle,
+ Notes = string.IsNullOrWhiteSpace(BucketEditorNotes) ? null : BucketEditorNotes,
+ };
+
/// Abandons the credential editor, clearing the password out of it.
[RelayCommand]
private void CancelCredentialEdit()
@@ -1908,13 +3278,13 @@ internal sealed partial class VaultViewModel(
if (editingCredentialId is { } entityId)
{
await session.Credentials
- .UpdateAsync(session.ActiveVaultId, entityId, credential, cancellationToken)
+ .UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingCredentialId = await session.Credentials
- .CreateAsync(session.ActiveVaultId, credential, cancellationToken)
+ .CreateAsync(editingCredentialVaultId, credential, cancellationToken)
.ConfigureAwait(true);
}
@@ -1966,7 +3336,7 @@ internal sealed partial class VaultViewModel(
async () =>
{
await session.Credentials
- .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
+ .DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -2005,6 +3375,14 @@ internal sealed partial class VaultViewModel(
await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
break;
+ case DeletionTarget.Group:
+ await DeleteGroupNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
+ break;
+
+ case DeletionTarget.ObjectStore:
+ await DeleteObjectStoreNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
+ break;
+
default:
break;
}
@@ -2306,36 +3684,7 @@ internal sealed partial class VaultViewModel(
{
try
{
- await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
-
- var request = new SshConnectionRequest(
- row.Host.Hostname,
- row.Host.Port,
- authentication.Username,
- authentication.Credential);
-
- var sessionId = await workspace
- .OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
- .ConfigureAwait(true);
-
- Status = $"Connected to {row.Label}.";
-
- // Only now, and only on success. The page's own term.focus() focuses the textarea inside
- // the document, which does nothing while the window's keyboard focus is still on the
- // Connect button — so without this the first keystrokes of the session go to the shell's
- // UI instead of the remote shell.
- //
- // The address is built from what was actually dialled rather than from the host's own fields,
- // because a bound credential can supply the username — so a host saved with no username of its
- // own still has one here, and it is the one the remote saw.
- SessionOpened?.Invoke(
- this,
- new TerminalSessionEventArgs(
- sessionId,
- row.Label,
- string.Create(
- CultureInfo.InvariantCulture,
- $"{authentication.Username}@{row.Host.Hostname}:{row.Host.Port}")));
+ await ConnectAndAnnounceAsync(row, authentication, cancellationToken).ConfigureAwait(true);
}
catch (TimeoutException)
{
@@ -2348,14 +3697,154 @@ internal sealed partial class VaultViewModel(
catch (SshHostKeyUnknownException exception)
{
// First contact. The user has to decide, and they need the fingerprint to do it.
+ //
+ // Deliberately not logged. Nothing was refused and nothing failed — the connection is paused on a
+ // question, and it becomes a session the moment the user answers it. An entry here would record a
+ // failure that did not happen, once per new host.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
}
catch (SshHostKeyMismatchException exception)
{
+ // Logged, and this is the entry the connection log most exists for. A changed host key is
+ // refused outright with no way past it, so the only trace it would otherwise leave is a status
+ // line the user dismisses — and a run of these against one machine is what somebody reviewing a
+ // log needs to see.
+ RecordFailure(row, authentication, ConnectionOutcome.Refused);
+
HostKeyMismatch = exception.Message;
Status = "The host key has changed. The connection was refused.";
}
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ // Everything else: an unreachable host, a rejected password, a key the remote will not take.
+ // Caught by shape rather than by type because this project's SSH layer defines only the two
+ // host-key exceptions above and everything else arrives from SSH.NET, which the client
+ // deliberately does not reference.
+ //
+ // Recorded and rethrown, so RunAsync goes on reporting it exactly as it did. The log is an
+ // observer here and must never become the thing that swallows an error. Cancellation is excluded
+ // because a user who gave up did not fail to connect.
+ RecordFailure(row, authentication, ConnectionOutcome.Failed);
+ throw;
+ }
+ }
+
+ /// Opens the session and tells the shell about it. Every failure is a throw.
+ ///
+ /// Split from the handlers around it for length, and the split falls where it should: this is the whole
+ /// happy path, and everything above it is one catch per way of not having one.
+ ///
+ private async Task ConnectAndAnnounceAsync(
+ HostRowViewModel row,
+ HostAuthentication authentication,
+ CancellationToken cancellationToken)
+ {
+ await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
+
+ var request = new SshConnectionRequest(
+ row.Host.Hostname,
+ row.Host.Port,
+ authentication.Username,
+ authentication.Credential);
+
+ var sessionId = await workspace
+ .OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
+ .ConfigureAwait(true);
+
+ // The workspace has already opened a ticket for this session, with the address and the moment it
+ // connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
+ // notion of one — so the name is added here rather than the ticket being replaced, which would move
+ // the start time to now.
+ connectionLog?.Identify(sessionId, row.Label, row.EntityId);
+
+ Status = $"Connected to {row.Label}.";
+
+ // Only now, and only on success. The page's own term.focus() focuses the textarea inside the
+ // document, which does nothing while the window's keyboard focus is still on the Connect button — so
+ // without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
+ SessionOpened?.Invoke(
+ this,
+ new TerminalSessionEventArgs(sessionId, row.Label, Dialled(row, authentication)));
+ }
+
+ /// The address as actually dialled.
+ ///
+ /// Built from what was dialled rather than from the host's own fields, because a bound credential can
+ /// supply the username — so a host saved with no username of its own still has one here, and it is the
+ /// one the remote saw.
+ ///
+ private static string Dialled(HostRowViewModel row, HostAuthentication authentication) =>
+ string.Create(
+ CultureInfo.InvariantCulture,
+ $"{authentication.Username}@{row.Host.Hostname}:{row.Host.Port}");
+
+ ///
+ /// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
+ ///
+ ///
+ ///
+ /// Rate-limited, because pruning writes. Log entries are synced items, so removing one is a real
+ /// tombstone that goes to the server — and a prune on every sync tick would be a machine that writes to
+ /// a server once a minute for ever. It rides the existing loop rather than a timer of its own, so a
+ /// laptop that is asleep prunes nothing and one that is awake prunes when it was going to talk to the
+ /// server anyway.
+ ///
+ ///
+ /// The first pass after a vault opens always runs, which is what makes a machine that has been off for a
+ /// month tidy up as soon as it comes back.
+ ///
+ ///
+ /// Failures are swallowed. Retention is housekeeping; a vault that could not prune is not a vault
+ /// somebody needs to be told about mid-sync.
+ ///
+ ///
+ private async Task PruneLogsIfDueAsync(CancellationToken cancellationToken)
+ {
+ var now = TimeProvider.System.GetUtcNow();
+
+ if (lastPruned is { } previous && now - previous < PruneInterval)
+ {
+ return;
+ }
+
+ lastPruned = now;
+
+ try
+ {
+ await LogPruner.PruneAsync(session, LogRetention.Default, now, cancellationToken)
+ .ConfigureAwait(true);
+ }
+ catch (OperationCanceledException)
+ {
+ // Locking, or closing.
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ // Housekeeping. Nothing the user did failed, and there is nothing for them to do about it.
+ }
+ }
+
+ /// Records a connection that never became a session.
+ ///
+ /// Start and end are the same instant, which is what a connection that never opened actually looks like:
+ /// the duration is zero and the outcome carries the meaning.
+ ///
+ private void RecordFailure(
+ HostRowViewModel row,
+ HostAuthentication authentication,
+ ConnectionOutcome outcome)
+ {
+ var at = TimeProvider.System.GetUtcNow();
+
+ connectionLog?.Record(
+ Dialled(row, authentication),
+ row.Label,
+ row.EntityId,
+ ConnectionKind.Terminal,
+ at,
+ at,
+ outcome);
}
///
@@ -2433,7 +3922,7 @@ internal sealed partial class VaultViewModel(
if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential)
{
return Refuse(
- $"'{host.Label}' authenticates with a credential that is not in this vault any more. "
+ $"'{host.Label}' authenticates with a credential that is not in this keychain any more. "
+ "Edit the host to choose another one, or set it back to a typed password.",
out authentication,
out reason);
@@ -2454,7 +3943,7 @@ internal sealed partial class VaultViewModel(
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
{
return Refuse(
- $"'{host.Label}' authenticates with an SSH key that is not in this vault any more. "
+ $"'{host.Label}' authenticates with an SSH key that is not in this keychain any more. "
+ "Edit the host to choose another key, or set it back to a password.",
out authentication,
out reason);
@@ -2527,6 +4016,10 @@ internal sealed partial class VaultViewModel(
// HostSecret.TryValidate catching it after the fact.
SshKeyId = Bound(AuthenticationKind.SshKey),
CredentialId = Bound(AuthenticationKind.Credential),
+
+ // Read off the picker for the same reason, including the id of a group that has gone missing:
+ // an unrelated edit must not unfile a host as a side effect.
+ GroupId = EditorSelectedGroup?.EntityId,
};
/// The picker's selection, if it names something of this kind.
@@ -2594,6 +4087,33 @@ internal sealed partial class VaultViewModel(
.FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId)
?? AuthenticationChoice.Typed;
+ /// Fills the group picker, keeping whatever the host is currently filed under selectable.
+ /// The group the host names, if any.
+ ///
+ /// A group that is no longer in the vault gets a placeholder, for the reason
+ /// gives: without one the picker would open on "No group", and
+ /// somebody editing the host's port would unfile it by saving. It says the group is gone rather than
+ /// naming it, because there is nothing left to read the name off.
+ ///
+ private void BuildGroupChoices(Guid? groupId)
+ {
+ EditorGroupChoices.Clear();
+ EditorGroupChoices.Add(GroupChoice.None);
+
+ foreach (var group in Groups)
+ {
+ EditorGroupChoices.Add(new GroupChoice(group.EntityId, group.Label));
+ }
+
+ if (groupId is { } bound && !EditorGroupChoices.Any(choice => choice.EntityId == bound))
+ {
+ EditorGroupChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
+ }
+
+ EditorSelectedGroup = EditorGroupChoices.FirstOrDefault(choice => choice.EntityId == groupId)
+ ?? GroupChoice.None;
+ }
+
private CredentialSecret BuildCredential() =>
new()
{
@@ -2676,14 +4196,16 @@ internal sealed partial class VaultViewModel(
///
private bool AVaultEditorIsInTheWay()
{
- Status = (IsEditingKey, IsEditingCredential) switch
+ Status = (IsEditingKey, IsEditingCredential, IsGeneratingKey, IsEditingObjectStore) switch
{
- (true, _) => "Finish or cancel the SSH key you are editing first.",
- (_, true) => "Finish or cancel the credential you are editing first.",
+ (true, _, _, _) => "Finish or cancel the SSH key you are editing first.",
+ (_, true, _, _) => "Finish or cancel the credential you are editing first.",
+ (_, _, true, _) => "Finish or cancel the key you are generating first.",
+ (_, _, _, true) => "Finish or cancel the bucket you are editing first.",
_ => Status,
};
- return IsEditingKey || IsEditingCredential;
+ return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore;
}
private void ClearKeyEditor()
@@ -2722,13 +4244,84 @@ internal sealed partial class VaultViewModel(
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
/// of recording those is that somebody sees them.
///
+ ///
+ /// Movement and attention only — deliberately not failure. A background pass that announced every
+ /// unreachable vault would be a socket error on screen once a minute, which is the thing
+ /// 's catch block exists to avoid; the caller records
+ /// instead, and the titlebar stops claiming to be up to date. Pressing
+ /// Sync reports the failure in full, because somebody who pressed it is waiting for an answer.
+ ///
+ ///
+ /// The item counts rather than the raw ones. Every user action queues a log entry a moment after the
+ /// action's own status message, and this machine reads its own entries back on the next pull — so a
+ /// rule written against the raw numbers would overwrite that message after every single save, which is
+ /// exactly what it did until the report learned to tell the two apart.
+ ///
+ private static bool IsWorthReporting(IReadOnlyList reports) =>
+ reports.Any(vault => vault.Succeeded
+ && (vault.Report!.PulledItems > 0
+ || vault.Report.PushedItems > 0
+ || vault.Report.NeedsAttention
+
+ // A pass that had to start over says so even when it pulled nothing, which is the one
+ // place this rule is broken deliberately. A machine that silently re-read a whole vault
+ // has had something happen to it, and the alternative is that nobody ever finds out.
+ || vault.Report.ResyncedFromStart));
+
+ ///
+ /// Counts are summed across vaults, and a failure is named with its reason . Both halves
+ /// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a
+ /// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them
+ /// costs nothing.
+ ///
+ private static string Describe(IReadOnlyList reports)
+ {
+ var failed = reports
+ .Where(vault => !vault.Succeeded)
+ .Select(vault => $"{vault.Name} ({vault.Failure?.Message})")
+ .ToList();
+
+ var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList();
+
+ var line = succeeded.Count switch
+ {
+ 0 => string.Empty,
+ 1 => Describe(succeeded[0]),
+ _ => DescribeMany(succeeded),
+ };
+
+ if (failed.Count == 0)
+ {
+ return line.Length == 0 ? "Nothing to synchronise." : line;
+ }
+
+ var names = string.Join("; ", failed);
+
+ return line.Length == 0
+ ? $"Could not synchronise {names}."
+ : $"{line} Could not synchronise {names}.";
+ }
+
+ private static string DescribeMany(List reports)
+ {
+ var pulled = reports.Sum(report => report.Pulled);
+ var pushed = reports.Sum(report => report.Pushed);
+ var attention = reports.Count(report => report.NeedsAttention);
+
+ var line = pulled == 0 && pushed == 0
+ ? $"Already up to date across {reports.Count} vaults."
+ : $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out.";
+
+ return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list.";
+ }
+
private static string Describe(SyncReport report)
{
// Said first, and in both branches, because it is the explanation for the numbers after it. A pass
// reporting "214 in" on a vault nobody has touched all week reads as something having gone wrong;
// this is what actually happened, and it needs nothing from the reader.
var replayed = report.ResyncedFromStart
- ? "The server no longer recognised this machine's position, so the vault was read again from "
+ ? "The server no longer recognised this machine's position, so the keychain was read again from "
+ "the beginning. "
: string.Empty;
@@ -2765,7 +4358,7 @@ internal sealed partial class VaultViewModel(
if (report.RekeyRequired)
{
- notes.Add("this vault was rekeyed and your access needs re-issuing");
+ notes.Add("this keychain was rekeyed and your access needs re-issuing");
}
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
@@ -2804,16 +4397,63 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
+ // Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
+ // one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
+ // other.
+ SelectedSidebarRow = value;
+
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
}
+ ///
+ ///
+ /// The one direction that needs a decision. A host selection is the application's selection and passes
+ /// straight through; a heading is not, and is turned back into whatever was selected before it, so that
+ /// clicking a group name neither breaks the buttons at the foot of the sidebar nor leaves a row
+ /// highlighted that none of them act on.
+ ///
+ ///
+ /// A null is left alone rather than cleared through. It arrives from the ListBox 's own answer to
+ /// the Reset that rebuilding the list raises — which happens on every filter keystroke and every
+ /// background sync — and treating that as the user deselecting would take the selection away from under
+ /// them once a minute. Deliberate clearing is done by , which sets
+ /// itself.
+ ///
+ ///
+ partial void OnSelectedSidebarRowChanged(ISidebarRow? value)
+ {
+ switch (value)
+ {
+ case HostRowViewModel host:
+ SelectedHost = host;
+ break;
+
+ case SidebarGroupHeader:
+ SelectedSidebarRow = SelectedHost;
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
+ {
+ DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
+ }
+
partial void OnPendingDeletionChanged(DeletionRequest? value)
{
OnPropertyChanged(nameof(IsConfirmingDeletion));
+ OnPropertyChanged(nameof(IsConfirmingHostDeletion));
+ OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
OnPropertyChanged(nameof(ShowsHostActions));
+ OnPropertyChanged(nameof(ShowsGroupActions));
OnPropertyChanged(nameof(ShowsItemActions));
}
+ partial void OnEditingGroupIdChanged(Guid? value) => OnPropertyChanged(nameof(GroupSaveLabel));
+
///
/// Takes the question away when the selection it was asked about has moved on.
///
@@ -2882,18 +4522,18 @@ internal sealed partial class VaultViewModel(
}
}
- if (Section is VaultSection.All or VaultSection.KnownHosts)
+ if (Section is VaultSection.All or VaultSection.Buckets)
{
- foreach (var pin in KnownHostPins)
+ foreach (var store in ObjectStores)
{
VaultItems.Add(new VaultItemRowViewModel(
- VaultItemKind.KnownHost,
- pin.EntityId,
- pin.Label,
- "HOST KEY",
- pin.Fingerprint,
- pin.Badge,
- pin.HasUnsyncedChanges));
+ VaultItemKind.ObjectStore,
+ store.EntityId,
+ store.Label,
+ "BUCKET",
+ store.Description,
+ store.Badge,
+ store.HasUnsyncedChanges));
}
}
@@ -2915,13 +4555,14 @@ internal sealed partial class VaultViewModel(
{
OnPropertyChanged(nameof(HasSelectedVaultItem));
OnPropertyChanged(nameof(SelectedItemIsEditable));
- OnPropertyChanged(nameof(SelectedItemIsPin));
+ OnPropertyChanged(nameof(SelectedItemIsKey));
OnPropertyChanged(nameof(SelectedDetailHeading));
OnPropertyChanged(nameof(ShowsItemActions));
// Both kinds this table can delete, because one selection covers both lists.
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
+ DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, value?.EntityId);
switch (value?.Kind)
{
@@ -2933,8 +4574,8 @@ internal sealed partial class VaultViewModel(
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == value.EntityId);
break;
- case VaultItemKind.KnownHost:
- SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == value.EntityId);
+ case VaultItemKind.ObjectStore:
+ SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == value.EntityId);
break;
default:
@@ -2960,9 +4601,8 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(ShowsAll));
OnPropertyChanged(nameof(ShowsKeys));
OnPropertyChanged(nameof(ShowsCredentials));
- OnPropertyChanged(nameof(ShowsKnownHosts));
+ OnPropertyChanged(nameof(ShowsBuckets));
OnPropertyChanged(nameof(SectionTitle));
- OnPropertyChanged(nameof(CanAddToSection));
RebuildVaultItems();
}
@@ -2982,8 +4622,18 @@ internal sealed partial class VaultViewModel(
partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
+ partial void OnIsGeneratingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
+
+ partial void OnGenerateAlgorithmChanged(SshKeyAlgorithm value)
+ {
+ OnPropertyChanged(nameof(GeneratesEd25519));
+ OnPropertyChanged(nameof(GeneratesRsa));
+ }
+
partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
+ partial void OnIsEditingObjectStoreChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
+
///
/// Takes the question away when an editor opens over the pane it was asked in.
///
diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
index 690f84d..0a6b989 100644
--- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js
@@ -22,6 +22,7 @@ const SERVER_SESSION_OPENED = 2;
const SERVER_SESSION_CLOSED = 3;
const SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
+const SERVER_PASTE = 6;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
@@ -275,6 +276,39 @@ function handleFrame(buffer) {
break;
}
+ case SERVER_PASTE: {
+ const session = sessions.get(sessionId);
+
+ if (!session || payload.length < 1) {
+ break;
+ }
+
+ const execute = payload[0] !== 0;
+ const text = new TextDecoder().decode(payload.subarray(1));
+
+ /*
+ term.paste rather than term.input, and that is the whole reason this frame exists rather than
+ the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
+ when the remote has turned that mode on — xterm tracks \e[?2004h from the output stream, which
+ is something only this page sees — and a shell that receives a multi-line command inside those
+ markers treats every newline as text. Without them it treats each one as "run this", so a
+ three-line snippet runs three commands the moment it is inserted.
+ */
+ session.term.paste(text);
+
+ /*
+ And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
+ pasted text would be bracketed along with it and arrive at the shell as a literal carriage
+ return, so nothing would run — which is the failure that looks like the feature working right
+ up until somebody wonders why RUN does not.
+ */
+ if (execute) {
+ session.term.input('\r');
+ }
+
+ break;
+ }
+
case SERVER_SESSION_CLOSED: {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);
diff --git a/src/DodoSSH.Client.Shell/packages.lock.json b/src/DodoSSH.Client.Shell/packages.lock.json
index fed5920..c3f199a 100644
--- a/src/DodoSSH.Client.Shell/packages.lock.json
+++ b/src/DodoSSH.Client.Shell/packages.lock.json
@@ -170,6 +170,21 @@
"dodossh.client.domain": {
"type": "Project"
},
+ "dodossh.client.import": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Domain": "[1.0.0, )"
+ }
+ },
+ "dodossh.client.objectstore": {
+ "type": "Project",
+ "dependencies": {
+ "AWSSDK.Core": "[4.0.100.9, )",
+ "AWSSDK.S3": "[4.0.101.6, )",
+ "DodoSSH.Client.Domain": "[1.0.0, )",
+ "DodoSSH.Client.Ssh": "[1.0.0, )"
+ }
+ },
"dodossh.client.session": {
"type": "Project",
"dependencies": {
@@ -178,12 +193,14 @@
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
- "DodoSSH.Client.Sync": "[1.0.0, )"
+ "DodoSSH.Client.Sync": "[1.0.0, )",
+ "DodoSSH.Client.Terminal": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
+ "NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -227,6 +244,21 @@
"NSec.Cryptography": "[26.4.0, )"
}
},
+ "AWSSDK.Core": {
+ "type": "CentralTransitive",
+ "requested": "[4.0.100.9, )",
+ "resolved": "4.0.100.9",
+ "contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
+ },
+ "AWSSDK.S3": {
+ "type": "CentralTransitive",
+ "requested": "[4.0.101.6, )",
+ "resolved": "4.0.101.6",
+ "contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
+ "dependencies": {
+ "AWSSDK.Core": "[4.0.100.9, 5.0.0)"
+ }
+ },
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
diff --git a/src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj b/src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj
index 0b3663f..456a368 100644
--- a/src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj
+++ b/src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj
@@ -7,6 +7,12 @@
+
+
diff --git a/src/DodoSSH.Client.Ssh/OpenSshKeyWriter.cs b/src/DodoSSH.Client.Ssh/OpenSshKeyWriter.cs
new file mode 100644
index 0000000..84c3a77
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/OpenSshKeyWriter.cs
@@ -0,0 +1,211 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DodoSSH.Client.Ssh;
+
+///
+/// Writes the two files ssh-keygen writes.
+///
+///
+///
+/// Why this is hand-rolled. Neither the BCL nor NSec can write an OpenSSH private key. .NET has no
+/// Ed25519 at all — that is why NSec is here in the first place — and the openssh-key-v1 container is
+/// an SSH-specific framing that no general-purpose library emits. The one alternative was PKCS#8 with the
+/// Ed25519 OID 1.3.101.112 , which would also have had to be hand-encoded and which SSH.NET 2025.1.0
+/// is not confirmed to parse — its PKCS#8 path historically switches on RSA, DSA and EC OIDs only. This
+/// format is the one SSH.NET definitely reads and the one every other tool reads.
+///
+///
+/// The container is written unencrypted, on purpose. Encrypting one needs bcrypt_pbkdf —
+/// Blowfish with a byte-swizzling quirk — plus AES-256-CTR. .NET has no Blowfish, Rfc2898DeriveBytes
+/// is in BannedSymbols.txt and is the wrong primitive anyway, and the only oracle for a hand-written
+/// implementation is ssh-keygen itself. That is a standing crypto maintenance cost for a defence the
+/// product does not need: a passphrase protects a key file sitting on a disk, and a key generated here goes
+/// straight into an end-to-end encrypted keychain and never touches one. See
+/// SshKeySecret.Passphrase , which makes the same argument at more length.
+///
+///
+/// Everything below is length-prefixed big-endian, which is the whole of the SSH wire format. Getting a
+/// prefix wrong yields a file that parses far enough to look plausible and then fails authentication with
+/// an error that says nothing about the encoding.
+///
+///
+internal static class OpenSshKeyWriter
+{
+ private const string Magic = "openssh-key-v1\0";
+
+ private const string Ed25519Algorithm = "ssh-ed25519";
+
+ private const string RsaAlgorithm = "ssh-rsa";
+
+ ///
+ /// How wide the base64 body is wrapped.
+ ///
+ ///
+ /// OpenSSH writes 70. Nothing parses by line length — but a key that diffs against one ssh-keygen
+ /// produced, in a repository or a paste, should differ in its bytes and not in its wrapping.
+ ///
+ private const int WrapAt = 70;
+
+ ///
+ /// The armoured private key for an Ed25519 pair, in openssh-key-v1 form.
+ ///
+ /// The 32-byte private scalar seed, as NSec exports it.
+ /// The 32-byte public point.
+ /// The trailing comment, which OpenSSH stores inside the private section.
+ internal static string WriteEd25519PrivateKey(
+ ReadOnlySpan seed,
+ ReadOnlySpan publicKey,
+ string comment)
+ {
+ var publicBlob = Ed25519PublicBlob(publicKey);
+
+ using var privateSection = new MemoryStream();
+
+ // Two copies of the same random value. OpenSSH uses them as a decryption check: after decrypting an
+ // encrypted key it compares them, and a mismatch is a wrong passphrase. Nothing here is encrypted,
+ // so nothing checks them — they are written because the format says so, and a parser is entitled to
+ // insist.
+ var check = RandomNumberGenerator.GetBytes(4);
+ privateSection.Write(check);
+ privateSection.Write(check);
+
+ WriteString(privateSection, Ed25519Algorithm);
+ WriteString(privateSection, publicKey);
+
+ // The private field of an Ed25519 OpenSSH key is the seed followed by the public point, 64 bytes,
+ // not the 32-byte seed alone. A file carrying only the seed loads and then signs with a key whose
+ // public half nobody agrees on.
+ Span expanded = stackalloc byte[64];
+ seed.CopyTo(expanded);
+ publicKey.CopyTo(expanded[32..]);
+ WriteString(privateSection, expanded);
+
+ WriteString(privateSection, comment);
+
+ Pad(privateSection);
+
+ using var container = new MemoryStream();
+ container.Write(Encoding.ASCII.GetBytes(Magic));
+ WriteString(container, "none");
+ WriteString(container, "none");
+ WriteString(container, ReadOnlySpan.Empty);
+ WriteUInt32(container, 1);
+ WriteString(container, publicBlob);
+ WriteString(container, privateSection.ToArray());
+
+ return Armour("OPENSSH PRIVATE KEY", container.ToArray());
+ }
+
+ /// The authorized_keys line for an Ed25519 public point.
+ internal static string WriteEd25519PublicKey(ReadOnlySpan publicKey, string comment) =>
+ PublicLine(Ed25519Algorithm, Ed25519PublicBlob(publicKey), comment);
+
+ /// The authorized_keys line for an RSA key.
+ internal static string WriteRsaPublicKey(RSA rsa, string comment) =>
+ PublicLine(RsaAlgorithm, RsaPublicBlob(rsa), comment);
+
+ /// The raw public key blob, which is what a fingerprint is taken over.
+ internal static byte[] Ed25519PublicBlob(ReadOnlySpan publicKey)
+ {
+ using var blob = new MemoryStream();
+ WriteString(blob, Ed25519Algorithm);
+ WriteString(blob, publicKey);
+
+ return blob.ToArray();
+ }
+
+ ///
+ internal static byte[] RsaPublicBlob(RSA rsa)
+ {
+ var parameters = rsa.ExportParameters(includePrivateParameters: false);
+
+ using var blob = new MemoryStream();
+ WriteString(blob, RsaAlgorithm);
+ WriteMpint(blob, parameters.Exponent!);
+ WriteMpint(blob, parameters.Modulus!);
+
+ return blob.ToArray();
+ }
+
+ private static string PublicLine(string algorithm, byte[] blob, string comment)
+ {
+ var line = $"{algorithm} {Convert.ToBase64String(blob)}";
+
+ return string.IsNullOrWhiteSpace(comment) ? line : $"{line} {comment.Trim()}";
+ }
+
+ ///
+ /// To a multiple of eight, with the bytes 1, 2, 3… — the block size of the "none" cipher, which OpenSSH
+ /// applies even though nothing is being blocked. This is the classic place to get an
+ /// openssh-key-v1 writer wrong, because whether it is wrong depends on the length of the comment:
+ /// a name that happens to land on a boundary produces a file that loads everywhere, and one character
+ /// more produces one that does not.
+ ///
+ private static void Pad(Stream destination)
+ {
+ var remainder = (int)(destination.Length % 8);
+
+ if (remainder == 0)
+ {
+ return;
+ }
+
+ for (var i = 1; i <= 8 - remainder; i++)
+ {
+ destination.WriteByte((byte)i);
+ }
+ }
+
+ private static string Armour(string label, byte[] body)
+ {
+ var builder = new StringBuilder();
+ builder.Append("-----BEGIN ").Append(label).Append("-----\n");
+
+ var base64 = Convert.ToBase64String(body);
+
+ for (var offset = 0; offset < base64.Length; offset += WrapAt)
+ {
+ builder.Append(base64.AsSpan(offset, Math.Min(WrapAt, base64.Length - offset))).Append('\n');
+ }
+
+ builder.Append("-----END ").Append(label).Append("-----\n");
+
+ return builder.ToString();
+ }
+
+ private static void WriteString(Stream destination, string value) =>
+ WriteString(destination, Encoding.UTF8.GetBytes(value));
+
+ private static void WriteString(Stream destination, ReadOnlySpan value)
+ {
+ WriteUInt32(destination, (uint)value.Length);
+ destination.Write(value);
+ }
+
+ private static void WriteUInt32(Stream destination, uint value)
+ {
+ Span encoded = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(encoded, value);
+ destination.Write(encoded);
+ }
+
+ ///
+ /// Signed big-endian, so a leading byte with its high bit set needs a zero in front of it or it reads as
+ /// a negative number. An RSA modulus has that bit set roughly half the time, which is what makes this
+ /// the kind of bug that ships.
+ ///
+ private static void WriteMpint(Stream destination, byte[] value)
+ {
+ if (value.Length > 0 && (value[0] & 0x80) != 0)
+ {
+ var padded = new byte[value.Length + 1];
+ value.CopyTo(padded, 1);
+ WriteString(destination, padded);
+ return;
+ }
+
+ WriteString(destination, value);
+ }
+}
diff --git a/src/DodoSSH.Client.Ssh/SftpSession.cs b/src/DodoSSH.Client.Ssh/SftpSession.cs
index 9f802cc..8dd61a5 100644
--- a/src/DodoSSH.Client.Ssh/SftpSession.cs
+++ b/src/DodoSSH.Client.Ssh/SftpSession.cs
@@ -261,14 +261,40 @@ public static class SftpPath
/// transfer runs is fine, and is the point of not opening a session per transfer.
///
///
-public interface ISftpSession : IAsyncDisposable
+public interface ISftpSession : IRemoteFileStore
+{
+ /// The host key that was accepted for this session.
+ HostKeyPresentation HostKey { get; }
+}
+
+///
+/// A remote place with files in it, whatever protocol reaches it.
+///
+///
+///
+/// Extracted from when buckets arrived, and unchanged in shape — the transfer
+/// queue reads, writes, stats and lists, and never once needed anything SSH-specific. What stayed behind on
+/// ISftpSession is the one member that could not be answered by a bucket: a host key.
+///
+///
+/// It lives in a project called .Ssh , which is a naming debt worth writing down rather than
+/// paying. is here too and is the type every listing is made of, so moving the
+/// interface without moving that would split the vocabulary in half — and moving both means renaming a
+/// record that the whole file browser and its tests are written against. The cost of leaving it is a
+/// reference that reads oddly from the object-store project; the cost of moving it is a rename with no
+/// behaviour in it.
+///
+///
+/// Not every implementation can do everything, and the contract says which. An object store has no
+/// directories, no rename and no way to resume a half-finished upload; each of those is documented on the
+/// member and refused with a reason rather than silently approximated. See S3FileStore .
+///
+///
+public interface IRemoteFileStore : IAsyncDisposable
{
/// Whether the transport is still up.
bool IsConnected { get; }
- /// The host key that was accepted for this session.
- HostKeyPresentation HostKey { get; }
-
///
/// Where the session starts, which is the account's home directory.
///
diff --git a/src/DodoSSH.Client.Ssh/SshKeyGenerator.cs b/src/DodoSSH.Client.Ssh/SshKeyGenerator.cs
new file mode 100644
index 0000000..0061c35
--- /dev/null
+++ b/src/DodoSSH.Client.Ssh/SshKeyGenerator.cs
@@ -0,0 +1,122 @@
+using System.Security.Cryptography;
+using NSec.Cryptography;
+
+namespace DodoSSH.Client.Ssh;
+
+/// Which kind of key pair to make.
+public enum SshKeyAlgorithm
+{
+ /// Ed25519. Small, fast, and what every current OpenSSH prefers.
+ Ed25519 = 0,
+
+ /// RSA at 4096 bits, for servers too old to accept the above.
+ Rsa4096 = 1,
+}
+
+///
+/// A freshly generated key pair, in the two forms anybody needs it in.
+///
+///
+/// The private half, in the armoured form ssh-keygen writes. Goes straight into
+/// SshKeySecret.PrivateKeyPem , which stores it verbatim.
+///
+///
+/// The public half, as one authorized_keys line. This is what gets installed on a host.
+///
+///
+/// The SHA256:… fingerprint, in the format ssh-keygen -lf prints, so it can be read out to
+/// somebody or compared against what a host reports.
+///
+public sealed record GeneratedSshKey(string PrivateKeyArmour, string PublicKeyLine, string Fingerprint);
+
+///
+/// Makes a new SSH key pair without shelling out to ssh-keygen .
+///
+///
+///
+/// Why the client can do this at all. Every part is already here: NSec does Ed25519 because .NET
+/// does not, the BCL does RSA, and the SSH wire encoding is a few length-prefixed strings — see
+/// . What it buys is that the private key is never written to a disk. The
+/// alternative flow is "run ssh-keygen, find the file, open it, copy the text, paste it here, remember to
+/// delete the file", and the last step is the one nobody does.
+///
+///
+/// The armour has no passphrase , and that is a deliberate limitation with its reasoning in
+/// . The key is protected by the keychain it lands in.
+///
+///
+/// This lives in the SSH project rather than in DodoSSH.Crypto , which is the normative
+/// implementation of docs/crypto.md and has nothing to say about SSH file formats. It is also where
+/// already lives, and a second SHA256: encoder would be a second
+/// thing to get wrong.
+///
+///
+public static class SshKeyGenerator
+{
+ ///
+ /// Generates a key pair.
+ ///
+ /// Which kind.
+ ///
+ /// The trailing comment, conventionally user@machine . It identifies the key in a host's
+ /// authorized_keys and is the only thing there that will say where it came from.
+ ///
+ ///
+ /// Synchronous and CPU-bound. RSA at 4096 bits is seconds of work on an ordinary machine, so a caller on
+ /// a UI thread has to move this to one of its own — the window would otherwise freeze at exactly the
+ /// moment somebody is watching it. Ed25519 is effectively instant, and the caller should not have to
+ /// know which is which.
+ ///
+ public static GeneratedSshKey Generate(SshKeyAlgorithm algorithm, string comment) => algorithm switch
+ {
+ SshKeyAlgorithm.Ed25519 => Ed25519(comment),
+ SshKeyAlgorithm.Rsa4096 => Rsa4096(comment),
+ _ => throw new ArgumentOutOfRangeException(nameof(algorithm)),
+ };
+
+ private static GeneratedSshKey Ed25519(string comment)
+ {
+ var parameters = new KeyCreationParameters
+ {
+ // The seed has to come back out to be written into the file. NSec holds key material in
+ // libsodium's guarded memory and refuses to export it unless asked at creation time.
+ ExportPolicy = KeyExportPolicies.AllowPlaintextExport,
+ };
+
+ using var key = Key.Create(SignatureAlgorithm.Ed25519, parameters);
+
+ var seed = key.Export(KeyBlobFormat.RawPrivateKey);
+
+ try
+ {
+ var publicKey = key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
+
+ return new GeneratedSshKey(
+ OpenSshKeyWriter.WriteEd25519PrivateKey(seed, publicKey, comment),
+ OpenSshKeyWriter.WriteEd25519PublicKey(publicKey, comment),
+ SshHostKeyFingerprint.Format(OpenSshKeyWriter.Ed25519PublicBlob(publicKey)));
+ }
+ finally
+ {
+ // The one copy of the private scalar this method makes, and it is an ordinary managed array
+ // outside libsodium's guarded memory. Clearing it does not undo anything the garbage collector
+ // may already have moved, which is why the export happens once and is used immediately.
+ CryptographicOperations.ZeroMemory(seed);
+ }
+ }
+
+ ///
+ /// PKCS#1, which is what ExportRSAPrivateKeyPem writes and what SSH.NET's RSA PRIVATE KEY
+ /// branch reads. No hand-encoding is needed on this path at all — only the public line, because there is
+ /// no BCL helper for the SSH wire format.
+ ///
+ private static GeneratedSshKey Rsa4096(string comment)
+ {
+ using var rsa = RSA.Create(4096);
+
+ return new GeneratedSshKey(
+ rsa.ExportRSAPrivateKeyPem() + "\n",
+ OpenSshKeyWriter.WriteRsaPublicKey(rsa, comment),
+ SshHostKeyFingerprint.Format(OpenSshKeyWriter.RsaPublicBlob(rsa)));
+ }
+}
diff --git a/src/DodoSSH.Client.Ssh/packages.lock.json b/src/DodoSSH.Client.Ssh/packages.lock.json
index 2a67be2..fdf5787 100644
--- a/src/DodoSSH.Client.Ssh/packages.lock.json
+++ b/src/DodoSSH.Client.Ssh/packages.lock.json
@@ -14,6 +14,15 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
+ "NSec.Cryptography": {
+ "type": "Direct",
+ "requested": "[26.4.0, )",
+ "resolved": "26.4.0",
+ "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
+ "dependencies": {
+ "libsodium": "[1.0.22, 1.0.23)"
+ }
+ },
"SSH.NET": {
"type": "Direct",
"requested": "[2025.1.0, )",
@@ -42,6 +51,12 @@
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
}
}
}
diff --git a/src/DodoSSH.Client.Storage/StoredTypes.cs b/src/DodoSSH.Client.Storage/StoredTypes.cs
index 7741ac7..75ca8fc 100644
--- a/src/DodoSSH.Client.Storage/StoredTypes.cs
+++ b/src/DodoSSH.Client.Storage/StoredTypes.cs
@@ -79,7 +79,30 @@ public sealed record StoredVault(
uint KeyGeneration,
int Permissions,
byte[]? WrappedVaultKey,
- bool RekeyRequired);
+ bool RekeyRequired)
+{
+ ///
+ /// The Write bit of .
+ ///
+ ///
+ /// A literal rather than a reference to DodoSSH.Domain.PermissionFlags , because that enum is
+ /// the server's and no client project references the domain assembly. The value is part of the wire
+ /// contract — VaultSummary.Permissions is an opaque int by design — and a test pins the two
+ /// together so a renumbering cannot silently make a read-only vault look writable here.
+ ///
+ private const int WriteFlag = 1 << 1;
+
+ ///
+ /// Whether the server would accept a change to this vault.
+ ///
+ ///
+ /// A user-interface answer, not a boundary: the server checks the same bit on every push, and this
+ /// exists so somebody in a team as a viewer is not offered a Save button that ends in a 403. Its
+ /// counterpart — whether the items can be read — is not a permission at all but a question
+ /// of holding the vault key, and is answered by the keyring.
+ ///
+ public bool CanWrite => (Permissions & WriteFlag) == WriteFlag;
+}
/// The last item state the server confirmed.
/// Owning vault.
diff --git a/src/DodoSSH.Client.Storage/VaultStore.cs b/src/DodoSSH.Client.Storage/VaultStore.cs
index d730ec9..c7e7e16 100644
--- a/src/DodoSSH.Client.Storage/VaultStore.cs
+++ b/src/DodoSSH.Client.Storage/VaultStore.cs
@@ -88,6 +88,37 @@ public sealed class VaultStore(IDbContextFactory contexts, T
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// Records one vault, leaving the rest alone.
+ ///
+ ///
+ /// For the vault this machine has just created, which exists here before the server's next
+ /// /me confirms it. would be wrong for that: it treats absence
+ /// as loss of access, and the one list that does not yet mention this vault is the one this client
+ /// last fetched.
+ ///
+ public async Task UpsertAsync(StoredVault vault, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(vault);
+
+ var context = contexts.CreateDbContext();
+ await using var scope = context.ConfigureAwait(false);
+
+ var row = await context.Set()
+ .SingleOrDefaultAsync(r => r.VaultId == vault.VaultId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (row is null)
+ {
+ row = new CachedVaultRow { VaultId = vault.VaultId };
+ context.Add(row);
+ }
+
+ Apply(row, vault, clock.GetUtcNow());
+
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
{
row.Name = vault.Name;
diff --git a/src/DodoSSH.Client.Sync/ActivityLogCipher.cs b/src/DodoSSH.Client.Sync/ActivityLogCipher.cs
new file mode 100644
index 0000000..dbe6054
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/ActivityLogCipher.cs
@@ -0,0 +1,131 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Domain;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// Turns a log entry into an item payload and back.
+///
+///
+///
+/// Mirrors exactly, including the rule that a payload is sealed at the
+/// version the server will assign rather than the one it replaces — see .
+/// In practice a log entry is only ever sealed at version 1, because nothing updates one; the general rule
+/// is used anyway, so that this cipher does not become the one place where a different one applies.
+///
+///
+/// The resource type is the one thing not to copy. SyncEntityType.ActivityLogEntry and
+/// CryptoSpec.AadResourceType.ActivityLogEntry deliberately differ, as every pair in this folder does, and the
+/// two log kinds sit next to each other in both enums — so a copied cipher with one constant left behind
+/// seals a connection record under the resource type for an activity record. That encrypts perfectly,
+/// decrypts perfectly on the machine that wrote it, and violates docs/crypto.md everywhere else.
+///
+///
+public static class ActivityLogCipher
+{
+ private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.ActivityLogEntry;
+
+ /// Encrypts an entry.
+ /// The entry. Must be valid for storage.
+ /// The vault key, which the data key is wrapped under.
+ /// The item id, which the AAD binds.
+ /// The vault's current key generation.
+ /// The version this payload will hold once the server accepts it.
+ public static EncryptedPayload Seal(
+ ActivityLogSecret entry,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
+
+ var plaintext = ActivityLogSecretCodec.Encode(entry);
+ var dataKey = ItemKeys.CreateDataKey();
+
+ try
+ {
+ var dataKeyId = Guid.CreateVersion7();
+
+ var wrappedDataKey = ItemKeys.WrapDataKey(
+ dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
+
+ var envelope = ItemKeys.SealPayload(
+ dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
+
+ return new EncryptedPayload(
+ envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+
+ // Wiped like every other payload here. Nothing in a log entry is a credential, and what it does
+ // hold — which machines this person reaches, and when — is the aggregate the whole item is
+ // encrypted to keep, so leaving it in a pooled buffer would be an odd place to stop caring.
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+
+ /// Decrypts an entry.
+ ///
+ public static ActivityLogSecretDocument? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(payload);
+
+ if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
+ {
+ return null;
+ }
+
+ var dataKey = ItemKeys.TryUnwrapDataKey(
+ vaultKey,
+ payload.WrappedDataKey,
+ Resource,
+ entityId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (dataKey is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var plaintext = ItemKeys.TryOpenPayload(
+ dataKey,
+ payload.Envelope,
+ Resource,
+ entityId,
+ payload.DataKeyId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (plaintext is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return ActivityLogSecretCodec.TryDecode(plaintext, out var document) ? document : null;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Sync/ConnectionLogCipher.cs b/src/DodoSSH.Client.Sync/ConnectionLogCipher.cs
new file mode 100644
index 0000000..ebb8fbb
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/ConnectionLogCipher.cs
@@ -0,0 +1,131 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Domain;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// Turns a log entry into an item payload and back.
+///
+///
+///
+/// Mirrors exactly, including the rule that a payload is sealed at the
+/// version the server will assign rather than the one it replaces — see .
+/// In practice a log entry is only ever sealed at version 1, because nothing updates one; the general rule
+/// is used anyway, so that this cipher does not become the one place where a different one applies.
+///
+///
+/// The resource type is the one thing not to copy. SyncEntityType.ConnectionLogEntry and
+/// CryptoSpec.AadResourceType.ConnectionLogEntry deliberately differ, as every pair in this folder does, and the
+/// two log kinds sit next to each other in both enums — so a copied cipher with one constant left behind
+/// seals a connection record under the resource type for an activity record. That encrypts perfectly,
+/// decrypts perfectly on the machine that wrote it, and violates docs/crypto.md everywhere else.
+///
+///
+public static class ConnectionLogCipher
+{
+ private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.ConnectionLogEntry;
+
+ /// Encrypts an entry.
+ /// The entry. Must be valid for storage.
+ /// The vault key, which the data key is wrapped under.
+ /// The item id, which the AAD binds.
+ /// The vault's current key generation.
+ /// The version this payload will hold once the server accepts it.
+ public static EncryptedPayload Seal(
+ ConnectionLogSecret entry,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
+
+ var plaintext = ConnectionLogSecretCodec.Encode(entry);
+ var dataKey = ItemKeys.CreateDataKey();
+
+ try
+ {
+ var dataKeyId = Guid.CreateVersion7();
+
+ var wrappedDataKey = ItemKeys.WrapDataKey(
+ dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
+
+ var envelope = ItemKeys.SealPayload(
+ dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
+
+ return new EncryptedPayload(
+ envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+
+ // Wiped like every other payload here. Nothing in a log entry is a credential, and what it does
+ // hold — which machines this person reaches, and when — is the aggregate the whole item is
+ // encrypted to keep, so leaving it in a pooled buffer would be an odd place to stop caring.
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+
+ /// Decrypts an entry.
+ ///
+ public static ConnectionLogSecretDocument? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(payload);
+
+ if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
+ {
+ return null;
+ }
+
+ var dataKey = ItemKeys.TryUnwrapDataKey(
+ vaultKey,
+ payload.WrappedDataKey,
+ Resource,
+ entityId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (dataKey is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var plaintext = ItemKeys.TryOpenPayload(
+ dataKey,
+ payload.Envelope,
+ Resource,
+ entityId,
+ payload.DataKeyId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (plaintext is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return ConnectionLogSecretCodec.TryDecode(plaintext, out var document) ? document : null;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Sync/CredentialRepository.cs b/src/DodoSSH.Client.Sync/CredentialRepository.cs
index 837add0..ac864bc 100644
--- a/src/DodoSSH.Client.Sync/CredentialRepository.cs
+++ b/src/DodoSSH.Client.Sync/CredentialRepository.cs
@@ -18,10 +18,14 @@ namespace DodoSSH.Client.Sync;
/// interface reads a listing once per reload rather than holding one open.
///
///
-public sealed class CredentialRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
+public sealed class CredentialRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
{
private readonly VaultItemRepository credentials =
- new(CredentialKind.Instance, items, outbox, keyring);
+ new(CredentialKind.Instance, items, outbox, keyring, activity);
///
public Task> ListAsync(
diff --git a/src/DodoSSH.Client.Sync/HostGroupCipher.cs b/src/DodoSSH.Client.Sync/HostGroupCipher.cs
new file mode 100644
index 0000000..ecb718a
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/HostGroupCipher.cs
@@ -0,0 +1,130 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Domain;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// Turns a host group into an item payload and back.
+///
+///
+///
+/// Mirrors exactly, including the rule that a payload is sealed at the
+/// version the server will assign rather than the one it replaces — see .
+///
+///
+/// The resource type is the one thing not to copy. SyncEntityType.HostGroup is 4 and
+/// CryptoSpec.AadResourceType.HostGroup is 7, because the crypto enum carries None, User, Device and
+/// Vault ahead of the item types. Casting one to the other would seal a group under the resource type for a
+/// host — which encrypts perfectly, decrypts perfectly on the machine that wrote it, and is a
+/// specification violation nothing would notice until an interoperating client refused the item.
+///
+///
+public static class HostGroupCipher
+{
+ private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.HostGroup;
+
+ /// Encrypts a group.
+ /// The group. Must be valid for storage.
+ /// The vault key, which the data key is wrapped under.
+ /// The item id, which the AAD binds.
+ /// The vault's current key generation.
+ /// The version this payload will hold once the server accepts it.
+ public static EncryptedPayload Seal(
+ HostGroupSecret group,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(group);
+ ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
+
+ var plaintext = HostGroupSecretCodec.Encode(group);
+ var dataKey = ItemKeys.CreateDataKey();
+
+ try
+ {
+ var dataKeyId = Guid.CreateVersion7();
+
+ var wrappedDataKey = ItemKeys.WrapDataKey(
+ dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
+
+ var envelope = ItemKeys.SealPayload(
+ dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
+
+ return new EncryptedPayload(
+ envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+
+ // Wiped like every other payload in this folder, and here for the smallest reason of all: the
+ // buffer holds one name somebody chose for a folder. It is wiped anyway, because the rule this
+ // folder follows is that plaintext does not outlive the call that made it, and an exception for
+ // the case that seems harmless is how the rule stops being one.
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+
+ /// Decrypts a group.
+ ///
+ public static HostGroupSecretDocument? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(payload);
+
+ if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
+ {
+ return null;
+ }
+
+ var dataKey = ItemKeys.TryUnwrapDataKey(
+ vaultKey,
+ payload.WrappedDataKey,
+ Resource,
+ entityId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (dataKey is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var plaintext = ItemKeys.TryOpenPayload(
+ dataKey,
+ payload.Envelope,
+ Resource,
+ entityId,
+ payload.DataKeyId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (plaintext is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return HostGroupSecretCodec.TryDecode(plaintext, out var document) ? document : null;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Sync/HostGroupRepository.cs b/src/DodoSSH.Client.Sync/HostGroupRepository.cs
new file mode 100644
index 0000000..8a3b05f
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/HostGroupRepository.cs
@@ -0,0 +1,54 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Storage;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// The groups in this vault, decrypted, with unpushed local changes laid over them.
+///
+///
+///
+/// The fifth facade over the same generic repository, and like the fourth it needed no new sync logic at all.
+///
+///
+/// Deleting a group does not touch the hosts in it. There is deliberately no DeleteAsync
+/// overload that unfiles its members: one user action would become N host writes, N outbox rows and N chances
+/// to merge against an edit nobody made, and the group's own tombstone can still lose a merge — by which time
+/// the membership it was clearing is gone. Hosts left holding a dangling id fall under the ungrouped heading,
+/// which is where the interface handles it. See .
+///
+///
+public sealed class HostGroupRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
+{
+ private readonly VaultItemRepository groups =
+ new(HostGroupKind.Instance, items, outbox, keyring, activity);
+
+ ///
+ public Task> ListAsync(
+ Guid vaultId,
+ CancellationToken cancellationToken) =>
+ groups.ListAsync(vaultId, cancellationToken);
+
+ ///
+ public Task CreateAsync(
+ Guid vaultId,
+ HostGroupSecret group,
+ CancellationToken cancellationToken) =>
+ groups.CreateAsync(vaultId, group, cancellationToken);
+
+ ///
+ public Task UpdateAsync(
+ Guid vaultId,
+ Guid entityId,
+ HostGroupSecret group,
+ CancellationToken cancellationToken) =>
+ groups.UpdateAsync(vaultId, entityId, group, cancellationToken);
+
+ ///
+ public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
+ groups.DeleteAsync(vaultId, entityId, cancellationToken);
+}
diff --git a/src/DodoSSH.Client.Sync/HostRepository.cs b/src/DodoSSH.Client.Sync/HostRepository.cs
index 19c93e4..14044b6 100644
--- a/src/DodoSSH.Client.Sync/HostRepository.cs
+++ b/src/DodoSSH.Client.Sync/HostRepository.cs
@@ -13,10 +13,14 @@ namespace DodoSSH.Client.Sync;
/// generic is internal to this assembly — exposing it would make the encoding and merge of every item
/// type part of the public surface for the sake of a constructor argument.
///
-public sealed class HostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
+public sealed class HostRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
{
private readonly VaultItemRepository hosts =
- new(HostKind.Instance, items, outbox, keyring);
+ new(HostKind.Instance, items, outbox, keyring, activity);
///
public Task> ListAsync(Guid vaultId, CancellationToken cancellationToken) =>
diff --git a/src/DodoSSH.Client.Sync/IActivityLogSink.cs b/src/DodoSSH.Client.Sync/IActivityLogSink.cs
new file mode 100644
index 0000000..d5196d0
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/IActivityLogSink.cs
@@ -0,0 +1,64 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// Somewhere to record that a keychain item was created, changed or deleted.
+///
+///
+///
+/// Hooked into rather than into the view models. That
+/// repository is the single generic funnel every kind's create, update and delete goes through, so one
+/// write site covers all of them and picks up a new kind for free. Hooking the view models instead would
+/// miss VaultKnownHostStore , which writes pins programmatically at connect time and never touches a
+/// screen — and those are exactly the writes an audit trail must not be blind to.
+///
+///
+/// Three rules govern the call, and they are properties of where it sits rather than of what it does:
+///
+///
+/// -
+///
After the outbox queue, never before. A crash between the two loses an advisory line; the reverse
+/// records a change that never happened.
+///
+/// -
+///
Every exception swallowed by the implementation. A failing log write must never fail a save — the
+/// entire point of the outbox is that saving works offline and cannot be refused.
+///
+/// -
+///
Not in a transaction with the outbox , or a log failure rolls back a change the user made.
+///
+///
+///
+/// Note the deliberate asymmetry with the outbox itself, because it reads as a discrepancy otherwise: the
+/// outbox coalesces two edits of one item into a single pending row, and this does not — two edits
+/// are two lines. The outbox describes what still has to be sent; this describes what somebody did.
+///
+///
+public interface IActivityLogSink
+{
+ /// Records one write.
+ /// Which vault it happened in.
+ /// Which sort of item, as the wire contract names it.
+ /// The item.
+ /// What the item was called at the time.
+ /// What was done.
+ ///
+ /// The names of the fields that differ, and never their values. Empty for a create and a delete, and
+ /// also when the previous version could not be read — which is why an empty list must not be taken to
+ /// mean nothing changed.
+ ///
+ ///
+ /// Returns by contract, for the reason IConnectionLogSink does: the caller
+ /// is a save the user is waiting on, and an encrypt-and-write on that path would put the cost of the log
+ /// into every keystroke that reaches a Save button.
+ ///
+ void Record(
+ Guid vaultId,
+ SyncEntityType kind,
+ Guid entityId,
+ string label,
+ ActivityOperation operation,
+ IReadOnlyList changedFields);
+}
diff --git a/src/DodoSSH.Client.Sync/ItemKinds.cs b/src/DodoSSH.Client.Sync/ItemKinds.cs
index b9d480c..797b240 100644
--- a/src/DodoSSH.Client.Sync/ItemKinds.cs
+++ b/src/DodoSSH.Client.Sync/ItemKinds.cs
@@ -1,6 +1,7 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
+using static DodoSSH.Client.Sync.FieldChange;
namespace DodoSSH.Client.Sync;
@@ -85,6 +86,35 @@ internal interface IItemKind
/// Merges two divergent versions against the version they both started from.
MergedItem Merge(TSecret ancestor, TSecret local, TSecret remote);
+ ///
+ /// The names of the fields that differ between two versions of an item.
+ ///
+ ///
+ ///
+ /// For the activity log, which records what changed and never what it changed to. Written per kind
+ /// rather than derived by reflection, for two reasons: this project's serialisation is source-generated
+ /// precisely to keep reflection out of it, and the names here are read by a person — so each kind gets
+ /// to say "Passphrase" rather than whatever a property happens to be called.
+ ///
+ ///
+ /// Never a value. A log that recorded an old password would be a plaintext credential store with
+ /// a vault drawn around it; see ADR 0006, which imposes the same rule on the server's own detail column.
+ ///
+ ///
+ IReadOnlyList Changes(TSecret before, TSecret after);
+
+ ///
+ /// Whether writing one of these is worth a line in the activity log.
+ ///
+ ///
+ /// False for the log kinds themselves, and that is not a preference: the activity hook sits in the one
+ /// generic repository every kind goes through, so a log entry that logged itself would produce an entry
+ /// per entry, for ever. It is stated per kind rather than special-cased at the call site so that a
+ /// future kind with the same shape — anything written by the machine rather than by a person — cannot
+ /// re-enter the loop by being forgotten.
+ ///
+ bool IsAudited => true;
+
/// The same item under a new name, for a resurrection.
TSecret Relabel(TSecret secret, string label);
}
@@ -120,6 +150,23 @@ internal static class ItemKinds
(SyncEntityType.KnownHostKey, static (outbox, conflicts, keyring) =>
new ItemReconciler(KnownHostKeyKind.Instance, outbox, conflicts, keyring)),
+
+ (SyncEntityType.HostGroup, static (outbox, conflicts, keyring) =>
+ new ItemReconciler(HostGroupKind.Instance, outbox, conflicts, keyring)),
+
+ (SyncEntityType.Snippet, static (outbox, conflicts, keyring) =>
+ new ItemReconciler(SnippetKind.Instance, outbox, conflicts, keyring)),
+
+ (SyncEntityType.ConnectionLogEntry, static (outbox, conflicts, keyring) =>
+ new ItemReconciler(
+ ConnectionLogEntryKind.Instance, outbox, conflicts, keyring)),
+
+ (SyncEntityType.ActivityLogEntry, static (outbox, conflicts, keyring) =>
+ new ItemReconciler(
+ ActivityLogEntryKind.Instance, outbox, conflicts, keyring)),
+
+ (SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
+ new ItemReconciler(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
];
/// The types to ask the server for, in a fixed order.
@@ -141,6 +188,24 @@ internal static class ItemKinds
entry => entry.Create(outbox, conflicts, keyring));
}
+/// What the per-kind field comparisons share.
+///
+/// One line per field at each call site, which is the point: the alternative was reflection, and this
+/// project keeps reflection out of its serialisation on purpose. Comparison is
+/// on the values, which is why the collection-shaped fields on a host are types with structural equality —
+/// JumpChain and HostOptions — rather than plain lists.
+///
+internal static class FieldChange
+{
+ internal static void Note(List changed, string name, T before, T after)
+ {
+ if (!EqualityComparer.Default.Equals(before, after))
+ {
+ changed.Add(name);
+ }
+ }
+}
+
/// Hosts.
internal sealed class HostKind : IItemKind
{
@@ -184,6 +249,29 @@ internal sealed class HostKind : IItemKind
return new MergedItem(merged.Merged, merged.Conflicts);
}
+ ///
+ public IReadOnlyList Changes(HostSecret before, HostSecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List();
+
+ Note(changed, "Name", before.Label, after.Label);
+ Note(changed, "Hostname", before.Hostname, after.Hostname);
+ Note(changed, "Port", before.Port, after.Port);
+ Note(changed, "Username", before.Username, after.Username);
+ Note(changed, "Notes", before.Notes, after.Notes);
+ Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
+ Note(changed, "Options", before.Options, after.Options);
+ Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
+ Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
+ Note(changed, "Credential", before.CredentialId, after.CredentialId);
+ Note(changed, "Group", before.GroupId, after.GroupId);
+
+ return changed;
+ }
+
///
public HostSecret Relabel(HostSecret secret, string label)
{
@@ -247,6 +335,27 @@ internal sealed class SshKeyKind : IItemKind
return new MergedItem(merged.Merged, merged.Conflicts);
}
+ ///
+ /// The private key is compared and never reported by value, which is the whole rule. "Private key"
+ /// appearing in a log is the fact somebody needs; the key itself is what nobody does.
+ ///
+ ///
+ public IReadOnlyList Changes(SshKeySecret before, SshKeySecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List();
+
+ Note(changed, "Name", before.Label, after.Label);
+ Note(changed, "Private key", before.PrivateKeyPem, after.PrivateKeyPem);
+ Note(changed, "Passphrase", before.Passphrase, after.Passphrase);
+ Note(changed, "Public key", before.PublicKey, after.PublicKey);
+ Note(changed, "Notes", before.Notes, after.Notes);
+
+ return changed;
+ }
+
///
public SshKeySecret Relabel(SshKeySecret secret, string label)
{
@@ -312,6 +421,22 @@ internal sealed class CredentialKind : IItemKind
return new MergedItem(merged.Merged, merged.Conflicts);
}
+ ///
+ public IReadOnlyList Changes(CredentialSecret before, CredentialSecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List();
+
+ Note(changed, "Name", before.Label, after.Label);
+ Note(changed, "Password", before.Password, after.Password);
+ Note(changed, "Username", before.Username, after.Username);
+ Note(changed, "Notes", before.Notes, after.Notes);
+
+ return changed;
+ }
+
///
public CredentialSecret Relabel(CredentialSecret secret, string label)
{
@@ -397,6 +522,28 @@ internal sealed class KnownHostKeyKind : IItemKind
/// the pin is about a different host. The resurrected item still gets its own id and still produces a
/// conflict notice, so the event is visible — the notice simply names the pin the same way twice.
///
+ ///
+ /// In practice only the fingerprint can change: the store rewrites one or creates a new item, and never
+ /// re-addresses an existing pin. The other three are compared anyway, because a payload from elsewhere
+ /// is untrusted input and a silently unreported change to what a pin is about is the one thing
+ /// an audit trail must not miss.
+ ///
+ ///
+ public IReadOnlyList Changes(KnownHostSecret before, KnownHostSecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List();
+
+ Note(changed, "Host", before.Host, after.Host);
+ Note(changed, "Port", before.Port, after.Port);
+ Note(changed, "Algorithm", before.Algorithm, after.Algorithm);
+ Note(changed, "Fingerprint", before.Fingerprint, after.Fingerprint);
+
+ return changed;
+ }
+
///
public KnownHostSecret Relabel(KnownHostSecret secret, string label)
{
@@ -405,3 +552,413 @@ internal sealed class KnownHostKeyKind : IItemKind
return secret;
}
}
+
+/// Host groups.
+internal sealed class HostGroupKind : IItemKind
+{
+ internal static HostGroupKind Instance { get; } = new();
+
+ ///
+ public SyncEntityType EntityType => SyncEntityType.HostGroup;
+
+ ///
+ public string Noun => "group";
+
+ ///
+ public OpenedItem? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ var document = HostGroupCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
+
+ return document is null
+ ? null
+ : new OpenedItem(document.Group, document.IsReadOnly);
+ }
+
+ ///
+ public EncryptedPayload Seal(
+ HostGroupSecret secret,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion) =>
+ HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
+
+ ///
+ /// Nothing, and the field it declines to send is the one named after this type.
+ ///
+ ///
+ /// SyncPlaintextFields.GroupId exists, the server had a column for it, and no client ever wrote
+ /// one. What it would have handed over is a clustering of the estate — which machines this user files
+ /// together — for a column nothing in the product reads. The server now refuses the field outright, on
+ /// hosts as well as here. See ADR 0004.
+ ///
+ ///
+ public SyncPlaintextFields? Fields(HostGroupSecret secret) => null;
+
+ ///
+ public MergedItem Merge(
+ HostGroupSecret ancestor,
+ HostGroupSecret local,
+ HostGroupSecret remote)
+ {
+ var merged = HostGroupSecretMerge.Merge(ancestor, local, remote);
+
+ return new MergedItem(merged.Merged, merged.Conflicts);
+ }
+
+ ///
+ public IReadOnlyList Changes(HostGroupSecret before, HostGroupSecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List();
+
+ Note(changed, "Name", before.Label, after.Label);
+
+ return changed;
+ }
+
+ ///
+ public HostGroupSecret Relabel(HostGroupSecret secret, string label)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+
+ return secret with { Label = label };
+ }
+}
+
+/// Snippets.
+internal sealed class SnippetKind : IItemKind
+{
+ internal static SnippetKind Instance { get; } = new();
+
+ ///
+ public SyncEntityType EntityType => SyncEntityType.Snippet;
+
+ ///
+ public string Noun => "snippet";
+
+ ///
+ public OpenedItem? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ var document = SnippetCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
+
+ return document is null
+ ? null
+ : new OpenedItem(document.Snippet, document.IsReadOnly);
+ }
+
+ ///
+ public EncryptedPayload Seal(
+ SnippetSecret secret,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion) =>
+ SnippetCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
+
+ ///
+ /// Nothing. A command is a description of the estate as precise as a hostname is.
+ ///
+ ///
+ public SyncPlaintextFields? Fields(SnippetSecret secret) => null;
+
+ ///
+ public MergedItem Merge(
+ SnippetSecret ancestor,
+ SnippetSecret local,
+ SnippetSecret remote)
+ {
+ var merged = SnippetSecretMerge.Merge(ancestor, local, remote);
+
+ return new MergedItem(merged.Merged, merged.Conflicts);
+ }
+
+ ///
+ public IReadOnlyList Changes(SnippetSecret before, SnippetSecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List();
+
+ Note(changed, "Name", before.Label, after.Label);
+ Note(changed, "Command", before.Command, after.Command);
+ Note(changed, "Notes", before.Notes, after.Notes);
+
+ // Named for what it means rather than after the property, because this is the line somebody
+ // reviewing a log actually needs to see: a snippet that has been turned into one that runs.
+ Note(changed, "Runs on insert", before.RunsOnInsert, after.RunsOnInsert);
+
+ return changed;
+ }
+
+ ///
+ public SnippetSecret Relabel(SnippetSecret secret, string label)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+
+ return secret with { Label = label };
+ }
+}
+
+/// Connection log entries.
+internal sealed class ConnectionLogEntryKind : IItemKind
+{
+ internal static ConnectionLogEntryKind Instance { get; } = new();
+
+ ///
+ public SyncEntityType EntityType => SyncEntityType.ConnectionLogEntry;
+
+ /// What to call one of these to a person.
+ ///
+ /// "Log entry" and not "connection". A user told that "this connection could not be decrypted" would go
+ /// looking at a machine they cannot reach; the item is the record of one, and the noun has to
+ /// say so.
+ ///
+ public string Noun => "log entry";
+
+ ///
+ public OpenedItem? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ var document = ConnectionLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
+
+ return document is null
+ ? null
+ : new OpenedItem(document.Entry, document.IsReadOnly);
+ }
+
+ ///
+ public EncryptedPayload Seal(
+ ConnectionLogSecret secret,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion) =>
+ ConnectionLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
+
+ ///
+ /// Nothing, and here the temptation is a timestamp rather than an address.
+ ///
+ ///
+ /// A plaintext startedAt would let the server order and prune a log without a client's help, which
+ /// is genuinely useful and is exactly the wrong trade: a timestamp column on this table is a record of
+ /// when each user works, assembled for free. The client sorts and prunes its own log. See ADR 0004.
+ ///
+ ///
+ public SyncPlaintextFields? Fields(ConnectionLogSecret secret) => null;
+
+ ///
+ public MergedItem Merge(
+ ConnectionLogSecret ancestor,
+ ConnectionLogSecret local,
+ ConnectionLogSecret remote)
+ {
+ var merged = ConnectionLogSecretMerge.Merge(ancestor, local, remote);
+
+ return new MergedItem(merged.Merged, merged.Conflicts);
+ }
+
+ /// Never asked, because a log entry is never audited or updated.
+ ///
+ public IReadOnlyList Changes(ConnectionLogSecret before, ConnectionLogSecret after) => [];
+
+ ///
+ /// False, and this is the guard that stops the log logging itself.
+ ///
+ ///
+ /// The activity hook lives in the one generic repository every kind writes through, so without this a
+ /// connection entry would produce an activity entry, which would produce another, without end. It is a
+ /// property of the kind rather than a check at the call site so that the next machine-written kind
+ /// cannot re-enter the loop by being overlooked.
+ ///
+ public bool IsAudited => false;
+
+ ///
+ /// The entry unchanged, because an entry has no name of its own to change.
+ ///
+ ///
+ /// As for a pinned host key: the label is derived from what the entry records, so renaming it would mean
+ /// claiming the connection was to a different machine. A resurrected entry still gets its own id and
+ /// still produces a conflict notice, so the event stays visible.
+ ///
+ ///
+ public ConnectionLogSecret Relabel(ConnectionLogSecret secret, string label)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+
+ return secret;
+ }
+}
+
+/// Activity log entries.
+internal sealed class ActivityLogEntryKind : IItemKind
+{
+ internal static ActivityLogEntryKind Instance { get; } = new();
+
+ ///
+ public SyncEntityType EntityType => SyncEntityType.ActivityLogEntry;
+
+ ///
+ public string Noun => "log entry";
+
+ ///
+ public OpenedItem? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ var document = ActivityLogCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
+
+ return document is null
+ ? null
+ : new OpenedItem(document.Entry, document.IsReadOnly);
+ }
+
+ ///
+ public EncryptedPayload Seal(
+ ActivityLogSecret secret,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion) =>
+ ActivityLogCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
+
+ ///
+ /// Nothing. SyncPlaintextFields.Kind would fit and is refused for it.
+ ///
+ ///
+ public SyncPlaintextFields? Fields(ActivityLogSecret secret) => null;
+
+ ///
+ public MergedItem Merge(
+ ActivityLogSecret ancestor,
+ ActivityLogSecret local,
+ ActivityLogSecret remote)
+ {
+ var merged = ActivityLogSecretMerge.Merge(ancestor, local, remote);
+
+ return new MergedItem(merged.Merged, merged.Conflicts);
+ }
+
+ ///
+ public IReadOnlyList Changes(ActivityLogSecret before, ActivityLogSecret after) => [];
+
+ ///
+ public bool IsAudited => false;
+
+ ///
+ public ActivityLogSecret Relabel(ActivityLogSecret secret, string label)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+
+ return secret;
+ }
+}
+
+/// Buckets.
+internal sealed class ObjectStoreKind : IItemKind
+{
+ internal static ObjectStoreKind Instance { get; } = new();
+
+ ///
+ public SyncEntityType EntityType => SyncEntityType.ObjectStore;
+
+ /// What to call one of these to a person.
+ ///
+ /// "Bucket" rather than "object store", because that is the word on the screen and in every service's own
+ /// documentation. The type is named for the protocol; the noun is named for what people say.
+ ///
+ public string Noun => "bucket";
+
+ ///
+ public OpenedItem? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ var document = ObjectStoreCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
+
+ return document is null
+ ? null
+ : new OpenedItem(document.Store, document.IsReadOnly);
+ }
+
+ ///
+ public EncryptedPayload Seal(
+ ObjectStoreSecret secret,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion) =>
+ ObjectStoreCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
+
+ ///
+ /// Nothing, and for this type there was never a candidate.
+ ///
+ ///
+ /// The endpoint is the field somebody might reach for, and it is the one that must not go: for everybody
+ /// self-hosting it is an address on their own network, which is exactly what the host table only holds in
+ /// the clear when the relay cannot work without it. Nothing on the server dials a bucket.
+ ///
+ ///
+ public SyncPlaintextFields? Fields(ObjectStoreSecret secret) => null;
+
+ ///
+ public MergedItem Merge(
+ ObjectStoreSecret ancestor,
+ ObjectStoreSecret local,
+ ObjectStoreSecret remote)
+ {
+ var merged = ObjectStoreSecretMerge.Merge(ancestor, local, remote);
+
+ return new MergedItem(merged.Merged, merged.Conflicts);
+ }
+
+ ///
+ /// The secret access key is compared and never reported by value, which is the rule every credential-like
+ /// field in this file follows. The access key id is shown: it is an identifier, not a secret.
+ ///
+ ///
+ public IReadOnlyList Changes(ObjectStoreSecret before, ObjectStoreSecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List