diff --git a/Directory.Packages.props b/Directory.Packages.props
index b279b97..a6cec88 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -100,6 +100,22 @@
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
-->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ ItemsSource="{Binding SidebarRows}"
+ SelectedItem="{Binding SelectedSidebarRow}">
+
+
+
+
+
+
+
+
+
@@ -106,7 +133,8 @@
-
+
+
@@ -148,6 +176,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..d5ba257
--- /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..593d1fb
--- /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..a7e83e9
--- /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.App.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..ae0216a
--- /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..5de5d13
--- /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..1c62d7e
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/LogsScreen.axaml.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+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 ViewModels.LogsViewModel { ShowsActivity: true } ? ActivityList : ConnectionList;
+}
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 2a1be7b..4d8cfef 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 keychain 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 keychain key is sealed to one account, and sharing means re-wrapping it for another.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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 8fa2afa..56d0957 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 0bd35c3..ed380a9 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." />
diff --git a/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs b/src/DodoSSH.Client.App/Views/TerminalTabs.axaml.cs
index ee94c91..a270624 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.App.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 72cd513..27fab76 100644
--- a/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
@@ -36,15 +36,32 @@
-
+
-
+
+
+
+
+
+
@@ -58,26 +75,43 @@
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
@@ -93,7 +127,12 @@
-
+
+
@@ -175,6 +214,17 @@
Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" />
+
+
+
@@ -197,7 +247,8 @@
-
+
@@ -315,6 +366,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 ece7bbd..8cefcb6 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.App.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 714cead..3eb4085 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 2ec0f3f..184f59c 100644
--- a/src/DodoSSH.Client.App/Views/VaultScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/VaultScreen.axaml
@@ -2,25 +2,29 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.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 +35,7 @@
-
+
+
+ CommandParameter="{x:Static vm:VaultSection.Buckets}"
+ Classes.active="{Binding ShowsBuckets}">
-
-
+
@@ -93,7 +102,7 @@
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
+ Text="One keychain, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
+
-
+ ToolTip.Tip="Pastes in a key you already have." />
+
+
@@ -218,7 +235,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 +243,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
@@ -273,7 +332,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 +366,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/WebAssets/terminal.js b/src/DodoSSH.Client.App/WebAssets/terminal.js
index 690f84d..0a6b989 100644
--- a/src/DodoSSH.Client.App/WebAssets/terminal.js
+++ b/src/DodoSSH.Client.App/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.App/packages.lock.json b/src/DodoSSH.Client.App/packages.lock.json
index 2eb2975..63e23e5 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,12 +372,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, )"
}
},
@@ -406,6 +423,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/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs
index 3089c14..df477a0 100644
--- a/src/DodoSSH.Client.Session/VaultSession.cs
+++ b/src/DodoSSH.Client.Session/VaultSession.cs
@@ -48,6 +48,16 @@ public sealed class VaultSession : IAsyncDisposable
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,10 +88,23 @@ 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.
@@ -114,6 +137,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;
@@ -316,7 +369,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 +423,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/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.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 6bff153..eed845d 100644
--- a/src/DodoSSH.Client.Ssh/SftpSession.cs
+++ b/src/DodoSSH.Client.Ssh/SftpSession.cs
@@ -214,14 +214,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.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();
+
+ Note(changed, "Name", before.Label, after.Label);
+ Note(changed, "Bucket", before.Bucket, after.Bucket);
+ Note(changed, "Access key id", before.AccessKeyId, after.AccessKeyId);
+ Note(changed, "Secret access key", before.SecretAccessKey, after.SecretAccessKey);
+ Note(changed, "Region", before.Region, after.Region);
+ Note(changed, "Endpoint", before.Endpoint, after.Endpoint);
+ Note(changed, "Path-style addressing", before.UsePathStyle, after.UsePathStyle);
+ Note(changed, "Notes", before.Notes, after.Notes);
+
+ return changed;
+ }
+
+ ///
+ public ObjectStoreSecret Relabel(ObjectStoreSecret secret, string label)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+
+ return secret with { Label = label };
+ }
+}
diff --git a/src/DodoSSH.Client.Sync/KnownHostRepository.cs b/src/DodoSSH.Client.Sync/KnownHostRepository.cs
index 4e12327..ff7e920 100644
--- a/src/DodoSSH.Client.Sync/KnownHostRepository.cs
+++ b/src/DodoSSH.Client.Sync/KnownHostRepository.cs
@@ -19,10 +19,14 @@ namespace DodoSSH.Client.Sync;
/// the handshake from an in-memory snapshot.
///
///
-public sealed class KnownHostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
+public sealed class KnownHostRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
{
private readonly VaultItemRepository knownHosts =
- new(KnownHostKeyKind.Instance, items, outbox, keyring);
+ new(KnownHostKeyKind.Instance, items, outbox, keyring, activity);
///
public Task> ListAsync(
diff --git a/src/DodoSSH.Client.Sync/LogRepositories.cs b/src/DodoSSH.Client.Sync/LogRepositories.cs
new file mode 100644
index 0000000..29f47cd
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/LogRepositories.cs
@@ -0,0 +1,68 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Storage;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// The connections this vault has recorded, decrypted, with unpushed local entries laid over them.
+///
+///
+///
+/// The seventh facade over the same generic repository, and the first whose UpdateAsync is missing on
+/// purpose. A connection log entry is written once, at close, and never edited — see
+/// VaultConnectionLogEntry for why that is what makes a synced log tractable at all — so an update
+/// method here would be an invitation to break the property the whole design rests on.
+///
+///
+/// stays, because retention needs it. It is the only thing that deletes an entry.
+///
+///
+public sealed class ConnectionLogRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
+{
+ private readonly VaultItemRepository entries =
+ new(ConnectionLogEntryKind.Instance, items, outbox, keyring);
+
+ ///
+ public Task> ListAsync(
+ Guid vaultId,
+ CancellationToken cancellationToken) =>
+ entries.ListAsync(vaultId, cancellationToken);
+
+ ///
+ public Task CreateAsync(
+ Guid vaultId,
+ ConnectionLogSecret entry,
+ CancellationToken cancellationToken) =>
+ entries.CreateAsync(vaultId, entry, cancellationToken);
+
+ ///
+ public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
+ entries.DeleteAsync(vaultId, entityId, cancellationToken);
+}
+
+///
+/// The keychain changes this vault has recorded, decrypted, with unpushed local entries laid over them.
+///
+///
+public sealed class ActivityLogRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
+{
+ private readonly VaultItemRepository entries =
+ new(ActivityLogEntryKind.Instance, items, outbox, keyring);
+
+ ///
+ public Task> ListAsync(
+ Guid vaultId,
+ CancellationToken cancellationToken) =>
+ entries.ListAsync(vaultId, cancellationToken);
+
+ ///
+ public Task CreateAsync(
+ Guid vaultId,
+ ActivityLogSecret entry,
+ CancellationToken cancellationToken) =>
+ entries.CreateAsync(vaultId, entry, cancellationToken);
+
+ ///
+ public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
+ entries.DeleteAsync(vaultId, entityId, cancellationToken);
+}
diff --git a/src/DodoSSH.Client.Sync/ObjectStoreCipher.cs b/src/DodoSSH.Client.Sync/ObjectStoreCipher.cs
new file mode 100644
index 0000000..ffb339f
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/ObjectStoreCipher.cs
@@ -0,0 +1,129 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Domain;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// Turns a bucket 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.ObjectStore is 13 and
+/// CryptoSpec.AadResourceType.ObjectStore is 16, because the crypto enum carries None, User, Device
+/// and Vault ahead of the item types and then closed a hole at 12 and 13. Casting one to the other would
+/// seal a bucket's keys under the resource type for a host-to-credential association — which
+/// encrypts perfectly, decrypts perfectly on the machine that wrote it, and violates docs/crypto.md
+/// everywhere else.
+///
+///
+public static class ObjectStoreCipher
+{
+ private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.ObjectStore;
+
+ /// Encrypts a bucket.
+ /// The bucket. 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(
+ ObjectStoreSecret store,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
+
+ var plaintext = ObjectStoreSecretCodec.Encode(store);
+ 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);
+
+ // The same reason a credential's buffer is wiped: this one holds a secret access key, which is a
+ // password by another name and is live on the service it belongs to.
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+
+ /// Decrypts a bucket.
+ ///
+ public static ObjectStoreSecretDocument? 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 ObjectStoreSecretCodec.TryDecode(plaintext, out var document) ? document : null;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Sync/ObjectStoreRepository.cs b/src/DodoSSH.Client.Sync/ObjectStoreRepository.cs
new file mode 100644
index 0000000..dd7a074
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/ObjectStoreRepository.cs
@@ -0,0 +1,47 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Storage;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// The buckets in this vault, decrypted, with unpushed local changes laid over them.
+///
+///
+/// The eighth facade over the same generic repository, and the pattern has not needed a change since the
+/// fourth — which is the point of the item-kind seam. Nothing here is on a transfer's data path: a bucket is
+/// read when the file screen's picker is built, and the object-store client is constructed from the result.
+///
+public sealed class ObjectStoreRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
+{
+ private readonly VaultItemRepository stores =
+ new(ObjectStoreKind.Instance, items, outbox, keyring, activity);
+
+ ///
+ public Task> ListAsync(
+ Guid vaultId,
+ CancellationToken cancellationToken) =>
+ stores.ListAsync(vaultId, cancellationToken);
+
+ ///
+ public Task CreateAsync(
+ Guid vaultId,
+ ObjectStoreSecret store,
+ CancellationToken cancellationToken) =>
+ stores.CreateAsync(vaultId, store, cancellationToken);
+
+ ///
+ public Task UpdateAsync(
+ Guid vaultId,
+ Guid entityId,
+ ObjectStoreSecret store,
+ CancellationToken cancellationToken) =>
+ stores.UpdateAsync(vaultId, entityId, store, cancellationToken);
+
+ ///
+ public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
+ stores.DeleteAsync(vaultId, entityId, cancellationToken);
+}
diff --git a/src/DodoSSH.Client.Sync/SnippetCipher.cs b/src/DodoSSH.Client.Sync/SnippetCipher.cs
new file mode 100644
index 0000000..bf54c9c
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/SnippetCipher.cs
@@ -0,0 +1,128 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Domain;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// Turns a snippet 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.Snippet is 8 and
+/// CryptoSpec.AadResourceType.Snippet is 9 — a difference of one, which is the most dangerous kind,
+/// because a cast that is wrong by one still produces a defined value and seals the item under the resource
+/// type for a tag. That round-trips on the machine that wrote it and violates docs/crypto.md
+/// everywhere else.
+///
+///
+public static class SnippetCipher
+{
+ private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Snippet;
+
+ /// Encrypts a snippet.
+ /// The snippet. 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(
+ SnippetSecret snippet,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(snippet);
+ ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
+
+ var plaintext = SnippetSecretCodec.Encode(snippet);
+ 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);
+
+ // A snippet is not a credential, and it is closer to one than it looks: people paste tokens into
+ // commands, and the command that unlocks a service is worth as much as the password it carries.
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+
+ /// Decrypts a snippet.
+ ///
+ public static SnippetSecretDocument? 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 SnippetSecretCodec.TryDecode(plaintext, out var document) ? document : null;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Sync/SnippetRepository.cs b/src/DodoSSH.Client.Sync/SnippetRepository.cs
new file mode 100644
index 0000000..1f8a2c9
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/SnippetRepository.cs
@@ -0,0 +1,47 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Storage;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// The snippets in this vault, decrypted, with unpushed local changes laid over them.
+///
+///
+/// The sixth facade over the same generic repository. Nothing here is on the terminal's write path: a snippet
+/// is read when the screen that lists them opens, and inserting one hands text to the renderer rather than
+/// coming back through the vault.
+///
+public sealed class SnippetRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
+{
+ private readonly VaultItemRepository snippets =
+ new(SnippetKind.Instance, items, outbox, keyring, activity);
+
+ ///
+ public Task> ListAsync(
+ Guid vaultId,
+ CancellationToken cancellationToken) =>
+ snippets.ListAsync(vaultId, cancellationToken);
+
+ ///
+ public Task CreateAsync(
+ Guid vaultId,
+ SnippetSecret snippet,
+ CancellationToken cancellationToken) =>
+ snippets.CreateAsync(vaultId, snippet, cancellationToken);
+
+ ///
+ public Task UpdateAsync(
+ Guid vaultId,
+ Guid entityId,
+ SnippetSecret snippet,
+ CancellationToken cancellationToken) =>
+ snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
+
+ ///
+ public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
+ snippets.DeleteAsync(vaultId, entityId, cancellationToken);
+}
diff --git a/src/DodoSSH.Client.Sync/SshKeyRepository.cs b/src/DodoSSH.Client.Sync/SshKeyRepository.cs
index 0ff69c4..bcc5ccc 100644
--- a/src/DodoSSH.Client.Sync/SshKeyRepository.cs
+++ b/src/DodoSSH.Client.Sync/SshKeyRepository.cs
@@ -20,10 +20,14 @@ namespace DodoSSH.Client.Sync;
/// where the decryption actually happens, which is here.
///
///
-public sealed class SshKeyRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
+public sealed class SshKeyRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
{
private readonly VaultItemRepository keys =
- new(SshKeyKind.Instance, items, outbox, keyring);
+ new(SshKeyKind.Instance, items, outbox, keyring, activity);
///
public Task> ListAsync(
diff --git a/src/DodoSSH.Client.Sync/SyncEngine.cs b/src/DodoSSH.Client.Sync/SyncEngine.cs
index b29c1aa..261a5e8 100644
--- a/src/DodoSSH.Client.Sync/SyncEngine.cs
+++ b/src/DodoSSH.Client.Sync/SyncEngine.cs
@@ -158,6 +158,14 @@ public sealed class SyncEngine
{
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
report.Pulled++;
+
+ // Counted apart for the same reason the pushed ones are: a machine reads back the log
+ // entries it just wrote, so a pass that "pulled five changes" may have carried nothing
+ // anybody did. See SyncReport.PulledItems.
+ if (IsLog(change.EntityType))
+ {
+ report.PulledLogEntries++;
+ }
}
var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal);
@@ -188,6 +196,15 @@ public sealed class SyncEngine
/// for, so a rejection of that is a server this code cannot reason about and has to surface.
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
///
+ /// Whether a change is one the machine wrote about itself rather than one somebody made.
+ ///
+ /// Used only for reporting. Log entries sync exactly like every other item — they are pulled, pushed,
+ /// merged and stored by the same code — and this distinguishes them nowhere except in the two numbers a
+ /// person reads.
+ ///
+ private static bool IsLog(SyncEntityType type) =>
+ type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
+
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
!string.IsNullOrEmpty(cursor)
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
@@ -393,6 +410,16 @@ public sealed class SyncEngine
// what makes a retry after a timeout exactly-once rather than merely at-least-once.
await AcceptAsync(vaultId, operation, result, cancellationToken).ConfigureAwait(false);
report.Pushed++;
+
+ // Counted separately so the interface can stay quiet about a pass that carried nothing but
+ // log entries. Every user action now queues one, so without this the background pass would
+ // have something to announce after literally every save — and would overwrite the message
+ // the save itself had just put on the status line.
+ if (IsLog(operation.EntityType))
+ {
+ report.PushedLogEntries++;
+ }
+
return false;
case SyncOperationStatus.Conflict:
diff --git a/src/DodoSSH.Client.Sync/SyncReport.cs b/src/DodoSSH.Client.Sync/SyncReport.cs
index 8c5bf9b..644d36a 100644
--- a/src/DodoSSH.Client.Sync/SyncReport.cs
+++ b/src/DodoSSH.Client.Sync/SyncReport.cs
@@ -97,11 +97,34 @@ public sealed record SyncReport(
bool RekeyRequired,
long ServerTimeSkewMs,
bool RoundsExhausted,
- bool ResyncedFromStart)
+ bool ResyncedFromStart,
+ int PushedLogEntries = 0,
+ int PulledLogEntries = 0)
{
/// Whether anything happened that a user should be told about.
public bool NeedsAttention =>
Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
+
+ ///
+ /// Operations the server accepted that were somebody's own work rather than a log entry.
+ ///
+ ///
+ /// The number a background pass decides whether to speak about. Every user action queues a log entry a
+ /// moment later, so a pass that reported on alone would have something to announce
+ /// after every save — and would overwrite the message the save had just written. Defaulted so that the
+ /// hundreds of existing constructions of this record go on meaning what they meant.
+ ///
+ public int PushedItems => Pushed - PushedLogEntries;
+
+ ///
+ /// Changes received that were somebody's own work rather than a log entry.
+ ///
+ ///
+ /// The other half of the same rule, and it needs saying because the case is not obvious: a machine pulls
+ /// back the log entries it has just pushed, so a pass immediately after a save reports a pull as well as
+ /// a push, and both are the machine talking about itself.
+ ///
+ public int PulledItems => Pulled - PulledLogEntries;
}
/// Accumulates a while a pass runs.
@@ -111,6 +134,12 @@ internal sealed class SyncReportBuilder(Guid vaultId)
internal int Pushed { get; set; }
+ /// How many of those were log entries rather than the user's own items.
+ internal int PushedLogEntries { get; set; }
+
+ ///
+ internal int PulledLogEntries { get; set; }
+
internal int Merged { get; set; }
internal int Resurrected { get; set; }
@@ -145,7 +174,9 @@ internal sealed class SyncReportBuilder(Guid vaultId)
RekeyRequired,
ServerTimeSkewMs,
RoundsExhausted,
- ResyncedFromStart);
+ ResyncedFromStart,
+ PushedLogEntries,
+ PulledLogEntries);
}
/// The record written to the conflict log when a merge had to override something.
diff --git a/src/DodoSSH.Client.Sync/VaultItemRepository.cs b/src/DodoSSH.Client.Sync/VaultItemRepository.cs
index e64cc42..7d46ef7 100644
--- a/src/DodoSSH.Client.Sync/VaultItemRepository.cs
+++ b/src/DodoSSH.Client.Sync/VaultItemRepository.cs
@@ -63,9 +63,20 @@ internal sealed class VaultItemRepository(
IItemKind kind,
ItemStore items,
OutboxStore outbox,
- VaultKeyring keyring)
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
where TSecret : class, IVaultSecret
{
+ ///
+ /// Whether writes through this repository are worth recording.
+ ///
+ ///
+ /// Asked once rather than at each call site, and false for the log kinds themselves — which is the guard
+ /// that stops the activity log producing an entry for every entry it writes, without end. See
+ /// .
+ ///
+ private bool IsAudited => activity is not null && kind.IsAudited;
+
/// Reads every item of this kind the user should see in a vault.
internal async Task> ListAsync(
Guid vaultId,
@@ -154,6 +165,14 @@ internal sealed class VaultItemRepository(
Ancestor: null),
cancellationToken).ConfigureAwait(false);
+ // After the queue, deliberately. A crash between the two loses one advisory line; the reverse order
+ // records an item that was never created.
+ if (IsAudited)
+ {
+ activity!.Record(
+ vaultId, kind.EntityType, entityId, secret.Label, ActivityOperation.Created, []);
+ }
+
return entityId;
}
@@ -188,6 +207,13 @@ internal sealed class VaultItemRepository(
var ancestor = pending?.Ancestor
?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
+ // Read before the queue overwrites it, and compared after. The version this decrypts at is the one
+ // the payload was sealed at, which is why the pending and mirror cases differ: a pending payload
+ // holds the version the server will assign, and a mirror row holds the one it has.
+ var before = IsAudited
+ ? Open(vaultKey, entityId, pending, ancestor)
+ : null;
+
await outbox.QueueAsync(
new QueuedChange(
vaultId,
@@ -204,6 +230,19 @@ internal sealed class VaultItemRepository(
kind.Fields(secret),
ancestor),
cancellationToken).ConfigureAwait(false);
+
+ if (IsAudited)
+ {
+ // An empty list when the previous version could not be read, which is why nothing may take empty
+ // to mean "nothing changed" — it also means "we could not tell".
+ activity!.Record(
+ vaultId,
+ kind.EntityType,
+ entityId,
+ secret.Label,
+ ActivityOperation.Updated,
+ before is null ? [] : kind.Changes(before, secret));
+ }
}
///
@@ -225,9 +264,20 @@ internal sealed class VaultItemRepository(
.FindAsync(vaultId, kind.EntityType, entityId, cancellationToken)
.ConfigureAwait(false);
+ // Read before either branch, because both of them destroy it — and a delete's line is the one that
+ // most needs a name, since the item it refers to is about to stop existing.
+ var label = IsAudited
+ ? await LabelAsync(vaultId, entityId, pending, cancellationToken).ConfigureAwait(false)
+ : null;
+
if (pending is not null && NeverReachedTheServer(pending))
{
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
+
+ // Recorded even though nothing goes to the server. Somebody created an item and then removed it,
+ // which is two things they did — and a log that showed only the create would describe a keychain
+ // that does not exist.
+ Audit(vaultId, entityId, label);
return;
}
@@ -249,6 +299,72 @@ internal sealed class VaultItemRepository(
Fields: null,
ancestor),
cancellationToken).ConfigureAwait(false);
+
+ Audit(vaultId, entityId, label);
+ }
+
+ /// Records a delete, if this kind is audited at all.
+ private void Audit(Guid vaultId, Guid entityId, string? label)
+ {
+ if (IsAudited)
+ {
+ // The label may be null when the item could not be decrypted, which is a state worth recording
+ // rather than skipping: an item nobody can read is still one somebody deleted.
+ activity!.Record(
+ vaultId,
+ kind.EntityType,
+ entityId,
+ label ?? "(an item that could not be read)",
+ ActivityOperation.Deleted,
+ []);
+ }
+ }
+
+ /// What an item is currently called, for a log line written as it goes away.
+ private async Task LabelAsync(
+ Guid vaultId,
+ Guid entityId,
+ PendingOperation? pending,
+ CancellationToken cancellationToken)
+ {
+ if (!keyring.TryGet(vaultId, out var vaultKey, out _))
+ {
+ return null;
+ }
+
+ var ancestor = await MirrorAncestorAsync(vaultId, entityId, cancellationToken)
+ .ConfigureAwait(false);
+
+ return Open(vaultKey, entityId, pending, ancestor)?.Label;
+ }
+
+ ///
+ /// Decrypts whichever version of an item this machine currently shows.
+ ///
+ ///
+ /// The pending payload first, because that is what the user is looking at — an item edited offline twice
+ /// should report the second edit against the first, not against what the server last accepted. The
+ /// version each is opened at differs for the reason the sealing side differs: a queued payload is sealed
+ /// at the version the server will assign, and a mirror row holds the one it has.
+ ///
+ private TSecret? Open(
+ ReadOnlyMemory vaultKey,
+ Guid entityId,
+ PendingOperation? pending,
+ StoredAncestor? ancestor)
+ {
+ if (pending is { Operation: SyncOperation.Upsert, Payload: { } queued })
+ {
+ return kind.TryOpen(
+ queued,
+ vaultKey.Span,
+ entityId,
+ SyncVersions.NextVersion(pending.ExpectedVersion))?.Secret;
+ }
+
+ return ancestor is null
+ ? null
+ : kind.TryOpen(ancestor.Payload, vaultKey.Span, entityId, ancestor.Version)?.Secret;
}
///
diff --git a/src/DodoSSH.Client.Terminal/IConnectionLogSink.cs b/src/DodoSSH.Client.Terminal/IConnectionLogSink.cs
new file mode 100644
index 0000000..fa55c10
--- /dev/null
+++ b/src/DodoSSH.Client.Terminal/IConnectionLogSink.cs
@@ -0,0 +1,50 @@
+namespace DodoSSH.Client.Terminal;
+
+///
+/// Somewhere to record that a connection happened, and how long it lasted.
+///
+///
+///
+/// Declared here and implemented two layers up.DodoSSH.Client.Terminal references only
+/// DodoSSH.Client.Ssh and cannot see the sync layer at all — which is deliberate, because the
+/// workspace has to keep running with the vault locked and a direct dependency would invite the opposite.
+/// So this is the shape of the hole, and ConnectionRecorder in DodoSSH.Client.Session is what
+/// fills it.
+///
+///
+/// Both methods return by contract.'s only caller is a
+/// finally unwinding on a thread-pool thread as a session tears down — including inside the loop that
+/// runs when the application is closing — and an encrypt-and-write there is how shutting down comes to take
+/// four seconds per open tab. An implementation must hand the work to something else and return.
+///
+///
+/// A null sink is a workspace that records nothing, which is what every test and every not-yet-unlocked
+/// process has.
+///
+///
+public interface IConnectionLogSink
+{
+ ///
+ /// Notes that a connection has opened.
+ ///
+ /// The session, as the workspace knows it.
+ /// The address as dialled.
+ /// When it opened.
+ ///
+ /// Called only after the connection has genuinely been made, so a host-key refusal never arrives here as
+ /// a session that started. Those are recorded separately, by the code that holds the typed exception.
+ ///
+ void Opened(uint sessionId, string address, DateTimeOffset startedAt);
+
+ ///
+ /// Notes that the connection has ended, whatever ended it.
+ ///
+ /// The session that has ended.
+ /// When it ended.
+ ///
+ /// One method for a tab close, a remote hangup and a process shutdown, because all three funnel through
+ /// the same finally — and because the difference between them is not something this client can
+ /// establish honestly. A session id that was never opened, or has already been closed out, is ignored.
+ ///
+ void Closed(uint sessionId, DateTimeOffset endedAt);
+}
diff --git a/src/DodoSSH.Client.Terminal/TerminalFrame.cs b/src/DodoSSH.Client.Terminal/TerminalFrame.cs
index 858cad1..11c4105 100644
--- a/src/DodoSSH.Client.Terminal/TerminalFrame.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalFrame.cs
@@ -53,6 +53,29 @@ public enum TerminalServerOpcode : byte
///
///
SessionRemoved = 5,
+
+ ///
+ /// Text to insert into this session, as if it had been pasted.
+ ///
+ ///
+ ///
+ /// Payload is one flag byte — non-zero to press Enter after the text — followed by UTF-8.
+ ///
+ ///
+ /// Through the renderer rather than straight into the input stream, and that is the whole reason this
+ /// opcode exists. Writing the bytes to the pump would have been fewer lines and is wrong: xterm.js
+ /// watches the remote for \e[?2004h and wraps pasted text in bracketed-paste markers when the mode
+ /// is on, which is what makes a shell treat embedded newlines as text instead of as "run this". The host
+ /// process cannot do that — TerminalDataPlane moves opaque bytes and never parses output — so it
+ /// would have to guess, and guessing wrong executes every line of a multi-line snippet.
+ ///
+ ///
+ /// The flag is separate from the text for the same reason. A trailing newline inside the payload would be
+ /// wrapped along with everything else and arrive at the shell as a literal character; the Enter has to go
+ /// through term.input, outside the wrapper, or nothing runs.
+ ///
+ ///
+ Paste = 6,
}
/// Frames the renderer sends to the host.
diff --git a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
index 074373c..c62c42a 100644
--- a/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
+++ b/src/DodoSSH.Client.Terminal/TerminalWorkspace.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+using System.Text;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal;
@@ -96,6 +98,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
dataPlane = new TerminalDataPlane(assets);
}
+ ///
+ /// Where connections are recorded, or null to record none.
+ ///
+ ///
+ ///
+ /// Settable rather than a constructor parameter, because the two objects have different lifetimes and
+ /// the workspace's is the longer one: it is composed at startup and outlives every lock, while anything
+ /// that can write to a vault exists only while one is open. Locking sets this back to null, and the
+ /// sessions that were already running go on running with nothing recording them.
+ ///
+ ///
+ /// Which means an entry can be missed — a shell open across a lock and closed after it. The recorder
+ /// holds its store for close-out past Close() precisely so that the common case does not, and the
+ /// residue is stated here rather than papered over.
+ ///
+ ///
+ public IConnectionLogSink? ConnectionLog { get; set; }
+
/// Where the WebView should navigate.
public Uri PageUrl => dataPlane.PageUrl;
@@ -224,6 +244,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
dataPlane.Register(sessionId, pump);
+ // After the connection succeeded and before the run begins. Ordered that way for two reasons: a
+ // host-key refusal throws out of ConnectAsync above and must never be recorded as a session that
+ // started, and a session whose shell ends immediately must already have a ticket open for the
+ // finally below to close.
+ ConnectionLog?.Opened(sessionId, Describe(request), clock.GetUtcNow());
+
// Registered before running, so an acknowledgement that arrives with the very first output
// frame has somewhere to go.
var run = RunSessionAsync(sessionId, pump);
@@ -257,6 +283,58 @@ public sealed class TerminalWorkspace : IAsyncDisposable
TerminalFrame.Create((byte)TerminalServerOpcode.SessionActivated, sessionId, []),
cancellationToken);
+ ///
+ /// Inserts text into one terminal, as if it had been pasted there.
+ ///
+ /// The terminal to insert into.
+ /// What to insert. Sent verbatim.
+ /// Whether to press Enter afterwards.
+ /// Cancellation.
+ ///
+ /// when that session's shell is not running, which is an ordinary answer rather
+ /// than an error: a tab whose remote hung up an hour ago is still on screen and still selectable, and
+ /// somebody clicking a snippet at it has made a mistake worth a sentence, not an exception.
+ ///
+ ///
+ ///
+ /// Refused for a dead session rather than sent and dropped. The transport discards frames for a
+ /// pane the page no longer has, so sending regardless would look exactly like success — and the one thing
+ /// somebody inserting a command needs to know is whether it arrived.
+ ///
+ ///
+ /// The liveness check and the send are deliberately not atomic. A shell that ends between the two is a
+ /// race no lock can close — the remote could hang up while the frame is in the socket — so the check is
+ /// there to catch the ordinary case honestly, not to make a guarantee it cannot keep.
+ ///
+ ///
+ public async Task PasteAsync(
+ uint sessionId,
+ string text,
+ bool execute,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+
+ if (!IsSessionLive(sessionId))
+ {
+ return false;
+ }
+
+ var utf8 = Encoding.UTF8.GetBytes(text);
+ var payload = new byte[1 + utf8.Length];
+
+ payload[0] = execute ? (byte)1 : (byte)0;
+ utf8.CopyTo(payload, 1);
+
+ await dataPlane
+ .SendAsync(
+ TerminalFrame.Create((byte)TerminalServerOpcode.Paste, sessionId, payload),
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ return true;
+ }
+
///
/// Closes one terminal.
///
@@ -363,6 +441,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
{
dataPlane.Unregister(sessionId);
+ // Unconditional, and this one hook covers all three ways a session ends: the user closing the
+ // tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
+ // the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
+ // must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
+ // the application is closing.
+ ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
+
// Only when the session is still one this workspace knows about. CloseSessionAsync removes the
// entry before it disposes the pump, so a tab the user closed does not come back as news.
bool announce;
@@ -379,5 +464,17 @@ public sealed class TerminalWorkspace : IAsyncDisposable
}
}
+ /// The address as dialled, for the log.
+ ///
+ /// The username is in here because it is part of the address that was dialled, and an address without one
+ /// does not identify the connection — two people reaching one machine as different accounts is the
+ /// ordinary case. This is not the same as recording which account authenticated: nothing here
+ /// reads the credential, and the payload has no field for one.
+ ///
+ private static string Describe(SshConnectionRequest request) =>
+ string.Create(
+ CultureInfo.InvariantCulture,
+ $"{request.Username}@{request.Host}:{request.Port}");
+
private sealed record LiveSession(ISshConnection Connection, TerminalSessionPump Pump, Task Run);
}
diff --git a/src/DodoSSH.Client.Terminal/packages.lock.json b/src/DodoSSH.Client.Terminal/packages.lock.json
index c9a7349..6e0493f 100644
--- a/src/DodoSSH.Client.Terminal/packages.lock.json
+++ b/src/DodoSSH.Client.Terminal/packages.lock.json
@@ -30,6 +30,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
+ "NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -39,6 +40,21 @@
"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, )",
diff --git a/src/DodoSSH.Client.Transfer/FileTransferQueue.cs b/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
index 4e78dea..c5ae6d8 100644
--- a/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
+++ b/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
@@ -152,7 +152,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
///
private static readonly TimeSpan ProgressInterval = TimeSpan.FromMilliseconds(100);
- private readonly Func> sessions;
+ private readonly Func> sessions;
private readonly TimeProvider clock;
private readonly List transfers = [];
private readonly Lock gate = new();
@@ -167,7 +167,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
/// queue holding a stale session would fail every row with a socket error instead of reconnecting.
///
/// Time source, so throughput is measurable without waiting for real seconds.
- public FileTransferQueue(Func> sessions, TimeProvider clock)
+ public FileTransferQueue(Func> sessions, TimeProvider clock)
{
this.sessions = sessions;
this.clock = clock;
@@ -427,7 +427,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
continue;
}
- ISftpSession session;
+ IRemoteFileStore session;
try
{
@@ -482,7 +482,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
return next;
}
- private async Task RunAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
+ private async Task RunAsync(Entry entry, IRemoteFileStore session, CancellationToken cancellationToken)
{
var token = entry.Cancellation?.Token ?? cancellationToken;
@@ -521,7 +521,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
}
}
- private async Task DownloadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
+ private async Task DownloadAsync(Entry entry, IRemoteFileStore session, CancellationToken cancellationToken)
{
var destination = entry.LocalPath;
@@ -553,7 +553,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
private async Task ReadIntoPartAsync(
Entry entry,
- ISftpSession session,
+ IRemoteFileStore session,
string part,
long offset,
CancellationToken cancellationToken)
@@ -579,7 +579,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
await CopyAsync(remote, local, entry, offset, cancellationToken).ConfigureAwait(false);
}
- private async Task UploadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
+ private async Task UploadAsync(Entry entry, IRemoteFileStore session, CancellationToken cancellationToken)
{
var destination = entry.RemotePath;
@@ -606,7 +606,7 @@ public sealed class FileTransferQueue : IAsyncDisposable
private async Task WriteIntoPartAsync(
Entry entry,
- ISftpSession session,
+ IRemoteFileStore session,
string part,
long offset,
CancellationToken cancellationToken)
diff --git a/src/DodoSSH.Client.Transfer/packages.lock.json b/src/DodoSSH.Client.Transfer/packages.lock.json
index c9a7349..6e0493f 100644
--- a/src/DodoSSH.Client.Transfer/packages.lock.json
+++ b/src/DodoSSH.Client.Transfer/packages.lock.json
@@ -30,6 +30,7 @@
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
+ "NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
}
},
@@ -39,6 +40,21 @@
"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, )",
diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
index 3ff7037..980a7d2 100644
--- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
+++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
@@ -327,7 +327,10 @@ DodoSSH.Contracts.SyncChange.UpdatedAt.init -> void
DodoSSH.Contracts.SyncChange.Version.get -> int
DodoSSH.Contracts.SyncChange.Version.init -> void
DodoSSH.Contracts.SyncEntityType
+DodoSSH.Contracts.SyncEntityType.ActivityLogEntry = 12 -> DodoSSH.Contracts.SyncEntityType
+DodoSSH.Contracts.SyncEntityType.ConnectionLogEntry = 11 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Credential = 2 -> DodoSSH.Contracts.SyncEntityType
+DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Host = 1 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostCredential = 7 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.HostGroup = 4 -> DodoSSH.Contracts.SyncEntityType
diff --git a/src/DodoSSH.Contracts/SyncEntityType.cs b/src/DodoSSH.Contracts/SyncEntityType.cs
index adb5bf8..a1e4e3f 100644
--- a/src/DodoSSH.Contracts/SyncEntityType.cs
+++ b/src/DodoSSH.Contracts/SyncEntityType.cs
@@ -42,4 +42,28 @@ public enum SyncEntityType
/// A known SSH host key.
KnownHostKey = 10,
+
+ /// One connection that was made, and how long it lasted.
+ ///
+ /// The first member added here since the contract was frozen; every one before it was reserved in
+ /// advance. Logs are synced items rather than local files because they are audit records — an
+ /// administrator has to be able to read a shared vault's history once teams land, and a log kept only on
+ /// the machine that produced it can be neither read nor trusted by anybody else. See ADR 0001 for what
+ /// that costs in metadata.
+ ///
+ ConnectionLogEntry = 11,
+
+ /// One create, edit or delete of a vault item.
+ ///
+ ActivityLogEntry = 12,
+
+ ///
+ /// An S3-compatible bucket, and the credentials that reach it.
+ ///
+ ///
+ /// Named for the protocol's shape rather than for Amazon, because what it addresses is any service
+ /// speaking S3 — MinIO, R2, Backblaze, Ceph — and a member called S3 would read as a claim about
+ /// the vendor. The interface says S3, which is what people call the protocol.
+ ///
+ ObjectStore = 13,
}
diff --git a/src/DodoSSH.Crypto/CryptoSpec.cs b/src/DodoSSH.Crypto/CryptoSpec.cs
index f7de2a1..42e3472 100644
--- a/src/DodoSSH.Crypto/CryptoSpec.cs
+++ b/src/DodoSSH.Crypto/CryptoSpec.cs
@@ -158,6 +158,21 @@ public static class CryptoSpec
/// A host-to-credential association.
HostCredential = 13,
+
+ /// One connection that was made, and how long it lasted.
+ ///
+ /// 14 and 15 are the first members here that are not one-behind their SyncEntityType
+ /// counterparts by the same constant offset — the two enums drifted apart when 12 and 13 closed a
+ /// hole. That is why nothing casts between them and why AadResourceTypeTests pins each pairing
+ /// by name.
+ ///
+ ConnectionLogEntry = 14,
+
+ /// One create, edit or delete of a vault item.
+ ActivityLogEntry = 15,
+
+ /// An S3-compatible bucket, and the credentials that reach it.
+ ObjectStore = 16,
}
/// HKDF info labels. Domain-separated so one subkey cannot stand in for another.
diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs
index d29898b..6760e15 100644
--- a/src/DodoSSH.Domain/Hosts.cs
+++ b/src/DodoSSH.Domain/Hosts.cs
@@ -16,6 +16,15 @@ namespace DodoSSH.Domain;
/// it becomes an authenticated open TCP proxy into the operator's own network. A database CHECK
/// constraint enforces the pairing so it cannot drift. See ADR 0004.
///
+///
+/// There is no group column, and there used to be one. A group_id was reserved here from the
+/// first migration and never written by any shipped client; it was dropped when groups actually landed,
+/// because membership belongs inside . The test ADR 0004 sets for a plaintext
+/// concession is that the server cannot function without the value, and nothing on the server reads
+/// a group — the client decrypts its own vault to draw the sidebar. What the column would have handed over
+/// is a clustering of the estate: which machines this user files together, for free, in the clear. See
+/// .
+///
///
public sealed class SshHost : IVaultItem
{
@@ -56,9 +65,6 @@ public sealed class SshHost : IVaultItem
/// Target port. Permitted only when is set.
public int? Port { get; set; }
- /// Owning group, for tree placement. Groups arrive in M2.
- public Guid? GroupId { get; set; }
-
///
/// Client-visible, monotonic item version. Used for optimistic concurrency on push, and
/// deliberately distinct from the internal xmin guard, which is never exposed because
@@ -315,3 +321,368 @@ public sealed class VaultKnownHostKey : IVaultItem
/// Who last modified it.
public Guid UpdatedByUserId { get; set; }
}
+
+///
+/// A folder hosts can be filed under, as ciphertext.
+///
+///
+///
+/// A group is a name and nothing else, so this row is the narrowest one in the schema: an envelope and its
+/// bookkeeping. There is no parent_id and no name column, and both absences are deliberate.
+///
+///
+/// No parent, because groups are flat. A nesting pointer merged by a scalar three-way merge lets two
+/// offline clients each re-parent A under B and B under A, and the result is a cycle the server cannot see —
+/// the pointer would be inside the payload, which the server cannot read — and which every client would then
+/// have to detect on every read, forever. Flat costs one level of organisation and removes a whole class of
+/// unrepairable state.
+///
+///
+/// No name, for the reason has no host column. A group name is not
+/// confidential the way a password is, but the set of names one person files their machines under is a
+/// description of the estate — "customer-a", "pci", "on-call" — and the server has no use for any of it. It
+/// sorts nothing; the client decrypts its own vault to draw a list.
+///
+///
+/// Membership lives on the host, not here. The alternative — a member id list in this payload — would
+/// make adding one host to a group a write to the group, so two clients adding two different hosts at once
+/// would collide on one item. ThreeWayMerge has no set merge, so that collision would resolve by one
+/// side winning and the other host quietly leaving the group. One pointer per host makes each of those two
+/// operations a write to a different item, which cannot collide at all.
+///
+///
+public sealed class VaultHostGroup : IVaultItem
+{
+ /// Primary key. UUIDv7, generated by the client so a group can be created offline.
+ public Guid Id { get; set; }
+
+ /// Owning vault.
+ public Guid VaultId { get; set; }
+
+ /// Owning vault.
+ public Vault? Vault { get; set; }
+
+ /// The encrypted group: a DSH1 envelope. Opaque to the server.
+ public byte[] Payload { get; set; } = [];
+
+ /// The item's data key, wrapped under the vault key. Opaque.
+ public byte[]? DataKeyWrap { get; set; }
+
+ /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.
+ public Guid? ContentKeyId { get; set; }
+
+ /// Vault key generation this payload was encrypted under.
+ public int KeyGeneration { get; set; }
+
+ /// AAD rule version, enabling a lazy re-encrypt-on-write migration later.
+ public short PayloadAadVersion { get; set; }
+
+ /// Client-visible, monotonic item version, used for expectedVersion checks.
+ public int Version { get; set; }
+
+ /// Latest change-log sequence touching this row, so a delta pull can join directly.
+ public long ChangeSequence { get; set; }
+
+ /// Creation timestamp.
+ public DateTimeOffset CreatedAtUtc { get; set; }
+
+ /// Last modification timestamp.
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+
+ ///
+ /// Soft-delete marker; a tombstone, so an offline client learns the group went away.
+ ///
+ ///
+ /// Deleting a group leaves every host that named it holding an id that resolves to nothing, and that is
+ /// the intended outcome rather than an oversight: those hosts fall back to the ungrouped heading. The
+ /// alternative is rewriting N host payloads inside one delete, which turns a single user action into N
+ /// pushes, N outbox rows and N chances to merge against a change nobody made.
+ ///
+ public DateTimeOffset? DeletedAtUtc { get; set; }
+
+ /// Who created it.
+ public Guid CreatedByUserId { get; set; }
+
+ /// Who last modified it.
+ public Guid UpdatedByUserId { get; set; }
+}
+
+///
+/// An S3-compatible bucket and the credentials that reach it, as ciphertext.
+///
+///
+///
+/// 's shape, and it holds the same class of thing: a secret access key is a
+/// password by another name, and everything around it — the endpoint, the region, the bucket — describes
+/// somewhere the user keeps data. There is no plaintext column and there is no argument for one; the relay
+/// does not dial a bucket, so ADR 0004's single concession has no analogue here.
+///
+///
+/// The endpoint is inside the payload even though it is often s3.amazonaws.com. For everybody
+/// self-hosting MinIO or Ceph it is an address on their own network, which is precisely the thing the host
+/// table only stores in the clear when the relay cannot work without it. Nothing on the server dials this.
+///
+///
+public sealed class VaultObjectStore : IVaultItem
+{
+ /// Primary key. UUIDv7, generated by the client so a bucket can be added offline.
+ public Guid Id { get; set; }
+
+ /// Owning vault.
+ public Guid VaultId { get; set; }
+
+ /// Owning vault.
+ public Vault? Vault { get; set; }
+
+ /// The encrypted bucket and its keys: a DSH1 envelope. Opaque to the server.
+ public byte[] Payload { get; set; } = [];
+
+ /// The item's data key, wrapped under the vault key. Opaque.
+ public byte[]? DataKeyWrap { get; set; }
+
+ /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.
+ public Guid? ContentKeyId { get; set; }
+
+ /// Vault key generation this payload was encrypted under.
+ public int KeyGeneration { get; set; }
+
+ /// AAD rule version, enabling a lazy re-encrypt-on-write migration later.
+ public short PayloadAadVersion { get; set; }
+
+ /// Client-visible, monotonic item version, used for expectedVersion checks.
+ public int Version { get; set; }
+
+ /// Latest change-log sequence touching this row, so a delta pull can join directly.
+ public long ChangeSequence { get; set; }
+
+ /// Creation timestamp.
+ public DateTimeOffset CreatedAtUtc { get; set; }
+
+ /// Last modification timestamp.
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+
+ /// Soft-delete marker; a tombstone, so an offline client learns the bucket went away.
+ public DateTimeOffset? DeletedAtUtc { get; set; }
+
+ /// Who created it.
+ public Guid CreatedByUserId { get; set; }
+
+ /// Who last modified it.
+ public Guid UpdatedByUserId { get; set; }
+}
+
+///
+/// One connection that was made, as ciphertext.
+///
+///
+///
+/// Written once, at close, and never updated. That single decision is what makes a synced log
+/// tractable: an entry that is created and then left alone needs no three-way merge, produces exactly one
+/// outbox row, and cannot collide with the unique index that allows one outbox row per item. A connection
+/// that is still open is shown from the workspace's own in-memory state instead, because that is where the
+/// truth about a running shell actually lives.
+///
+///
+/// Synced, and that is a deliberate trade rather than the obvious choice. A log kept on the machine
+/// that produced it cannot be read by an administrator, cannot survive a reinstall, and cannot be checked
+/// against anything. Keeping it here buys team auditing; what it costs is that the operator learns a user's
+/// connection rate and timing from row counts and updated_at, even though every field inside
+/// is sealed. ADR 0001 already concedes it cannot hide that class of metadata; this widens it, and says so.
+///
+///
+/// The shape is 's: an envelope and its bookkeeping, no plaintext column of any
+/// kind. Which machines somebody reaches and when is the single most revealing thing this schema could hold
+/// in the clear, which is exactly why it holds none of it.
+///
+///
+public sealed class VaultConnectionLogEntry : IVaultItem
+{
+ /// Primary key. UUIDv7, generated by the client, so an entry can be written offline.
+ public Guid Id { get; set; }
+
+ /// Owning vault.
+ public Guid VaultId { get; set; }
+
+ /// Owning vault.
+ public Vault? Vault { get; set; }
+
+ /// The encrypted entry: a DSH1 envelope. Opaque to the server.
+ public byte[] Payload { get; set; } = [];
+
+ /// The item's data key, wrapped under the vault key. Opaque.
+ public byte[]? DataKeyWrap { get; set; }
+
+ /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.
+ public Guid? ContentKeyId { get; set; }
+
+ /// Vault key generation this payload was encrypted under.
+ public int KeyGeneration { get; set; }
+
+ /// AAD rule version, enabling a lazy re-encrypt-on-write migration later.
+ public short PayloadAadVersion { get; set; }
+
+ ///
+ /// Client-visible, monotonic item version.
+ ///
+ ///
+ /// Always 1 in practice, because nothing updates a log entry. The column stays because the shared write
+ /// path needs it, and a kind that opted out of the version check would be a second write path.
+ ///
+ public int Version { get; set; }
+
+ /// Latest change-log sequence touching this row, so a delta pull can join directly.
+ public long ChangeSequence { get; set; }
+
+ /// Creation timestamp.
+ public DateTimeOffset CreatedAtUtc { get; set; }
+
+ /// Last modification timestamp.
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+
+ ///
+ /// Soft-delete marker; a tombstone, so an offline client learns the entry was pruned.
+ ///
+ ///
+ /// Retention is the only thing that deletes one of these, and it is a real tombstone that pushes — which
+ /// is why it is rate-limited rather than run on a timer. A prune that did not sync would delete the same
+ /// thousand entries again on every machine, for ever.
+ ///
+ public DateTimeOffset? DeletedAtUtc { get; set; }
+
+ /// Who created it.
+ public Guid CreatedByUserId { get; set; }
+
+ /// Who last modified it.
+ public Guid UpdatedByUserId { get; set; }
+}
+
+///
+/// One create, edit or delete of a vault item, as ciphertext.
+///
+///
+///
+/// 's shape and its write-once rule, for a different history: what
+/// changed in the keychain rather than what was connected to. The payload records the names of the
+/// fields that changed and never their values, which is the same rule ADR 0006 imposes on the server's own
+/// detail column. A log that recorded an old password would be a plaintext credential store with no
+/// vault around it.
+///
+///
+/// Note the deliberate asymmetry with the outbox, because it reads as a discrepancy otherwise: the outbox
+/// coalesces two edits of one item into a single pending row, and this log does not — two edits are
+/// two lines. The outbox describes what still has to be sent; this describes what somebody did.
+///
+///
+public sealed class VaultActivityLogEntry : IVaultItem
+{
+ /// Primary key. UUIDv7, generated by the client, so an entry can be written offline.
+ public Guid Id { get; set; }
+
+ /// Owning vault.
+ public Guid VaultId { get; set; }
+
+ /// Owning vault.
+ public Vault? Vault { get; set; }
+
+ /// The encrypted entry: a DSH1 envelope. Opaque to the server.
+ public byte[] Payload { get; set; } = [];
+
+ /// The item's data key, wrapped under the vault key. Opaque.
+ public byte[]? DataKeyWrap { get; set; }
+
+ /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.
+ public Guid? ContentKeyId { get; set; }
+
+ /// Vault key generation this payload was encrypted under.
+ public int KeyGeneration { get; set; }
+
+ /// AAD rule version, enabling a lazy re-encrypt-on-write migration later.
+ public short PayloadAadVersion { get; set; }
+
+ ///
+ public int Version { get; set; }
+
+ /// Latest change-log sequence touching this row, so a delta pull can join directly.
+ public long ChangeSequence { get; set; }
+
+ /// Creation timestamp.
+ public DateTimeOffset CreatedAtUtc { get; set; }
+
+ /// Last modification timestamp.
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+
+ ///
+ public DateTimeOffset? DeletedAtUtc { get; set; }
+
+ /// Who created it.
+ public Guid CreatedByUserId { get; set; }
+
+ /// Who last modified it.
+ public Guid UpdatedByUserId { get; set; }
+}
+
+///
+/// A saved command, as ciphertext.
+///
+///
+///
+/// 's shape exactly, and for a stronger reason than symmetry. A snippet is a
+/// command somebody runs on their infrastructure — systemctl restart against a named unit, a
+/// psql line naming a database — and read as a set it describes what the estate is made of at least
+/// as precisely as a list of hostnames does. There is nothing here the server could sort by that is worth
+/// what holding it would cost, so there is no plaintext column to put it in even by mistake.
+///
+///
+/// Whether the snippet runs on its own or merely gets typed into the terminal is inside the payload too, and
+/// that is worth stating: it is the field the whole feature's safety rests on, so it must be one that
+/// travels sealed and merges with everything else it belongs to, rather than a flag the server could see or
+/// change.
+///
+///
+public sealed class VaultSnippet : IVaultItem
+{
+ /// Primary key. UUIDv7, generated by the client so a snippet can be written offline.
+ public Guid Id { get; set; }
+
+ /// Owning vault.
+ public Guid VaultId { get; set; }
+
+ /// Owning vault.
+ public Vault? Vault { get; set; }
+
+ /// The encrypted snippet: a DSH1 envelope. Opaque to the server.
+ public byte[] Payload { get; set; } = [];
+
+ /// The item's data key, wrapped under the vault key. Opaque.
+ public byte[]? DataKeyWrap { get; set; }
+
+ /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.
+ public Guid? ContentKeyId { get; set; }
+
+ /// Vault key generation this payload was encrypted under.
+ public int KeyGeneration { get; set; }
+
+ /// AAD rule version, enabling a lazy re-encrypt-on-write migration later.
+ public short PayloadAadVersion { get; set; }
+
+ /// Client-visible, monotonic item version, used for expectedVersion checks.
+ public int Version { get; set; }
+
+ /// Latest change-log sequence touching this row, so a delta pull can join directly.
+ public long ChangeSequence { get; set; }
+
+ /// Creation timestamp.
+ public DateTimeOffset CreatedAtUtc { get; set; }
+
+ /// Last modification timestamp.
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+
+ /// Soft-delete marker; a tombstone, so an offline client learns the snippet went away.
+ public DateTimeOffset? DeletedAtUtc { get; set; }
+
+ /// Who created it.
+ public Guid CreatedByUserId { get; set; }
+
+ /// Who last modified it.
+ public Guid UpdatedByUserId { get; set; }
+}
diff --git a/src/DodoSSH.Domain/SyncLog.cs b/src/DodoSSH.Domain/SyncLog.cs
index 10c08a7..4bb9ef5 100644
--- a/src/DodoSSH.Domain/SyncLog.cs
+++ b/src/DodoSSH.Domain/SyncLog.cs
@@ -48,6 +48,15 @@ public enum ChangeEntityType
/// A known SSH host key. M2.
KnownHostKey = 10,
+
+ /// One connection that was made, and how long it lasted. M2.
+ ConnectionLogEntry = 11,
+
+ /// One create, edit or delete of a vault item. M2.
+ ActivityLogEntry = 12,
+
+ /// An S3-compatible bucket, and the credentials that reach it. M2.
+ ObjectStore = 13,
}
///
diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
index ee41456..857b9bc 100644
--- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
+++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
@@ -160,6 +160,192 @@ public sealed class KnownHostKeyConfiguration : IEntityTypeConfiguration
+/// Maps .
+///
+///
+/// The same shape again, and by now the sameness is the design rather than a coincidence: four item types
+/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is the
+/// type where a plaintext column would have been most tempting and least defensible — a name here
+/// would let the server order a list it never draws, in exchange for telling the operator how every user
+/// files their machines.
+///
+public sealed class HostGroupConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("host_group");
+ builder.HasKey(g => g.Id);
+
+ // Client-generated UUIDv7: a group must be creatable offline, with its id, because the host that
+ // names it is created offline too and needs something to point at.
+ builder.Property(g => g.Id).ValueGeneratedNever();
+ builder.UseXminConcurrencyToken();
+
+ builder.Property(g => g.Payload).IsRequired();
+
+ builder.HasIndex(g => new { g.VaultId, g.ChangeSequence });
+
+ builder.HasIndex(g => g.VaultId)
+ .HasFilter("deleted_at_utc IS NULL")
+ .HasDatabaseName("ix_host_group_vault_live");
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_host_group_version",
+ "version >= 1"));
+ }
+}
+
+///
+/// Maps .
+///
+///
+/// Identical to . A snippet's label would be harmless on its own and the
+/// commands beside it would not be, and splitting one out would give the server half a map for no feature.
+///
+public sealed class SnippetConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("snippet");
+ builder.HasKey(s => s.Id);
+
+ // Client-generated UUIDv7: a snippet must be writable offline, with its id.
+ builder.Property(s => s.Id).ValueGeneratedNever();
+ builder.UseXminConcurrencyToken();
+
+ builder.Property(s => s.Payload).IsRequired();
+
+ builder.HasIndex(s => new { s.VaultId, s.ChangeSequence });
+
+ builder.HasIndex(s => s.VaultId)
+ .HasFilter("deleted_at_utc IS NULL")
+ .HasDatabaseName("ix_snippet_vault_live");
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_snippet_version",
+ "version >= 1"));
+ }
+}
+
+///
+/// Maps .
+///
+///
+///
+/// The same envelope-and-bookkeeping shape as every other item kind, and this is the one where holding
+/// something in the clear would have been most useful and least defensible: a started_at column would
+/// let the server order a log without decrypting it, and would hand the operator every user's working hours.
+/// The client sorts its own log.
+///
+///
+/// The live index is not decoration here. This is the only table that grows without a person adding
+/// anything to it — a busy user produces entries all day — so the query retention runs, "everything in this
+/// vault that is not already a tombstone", is the one that has to stay cheap.
+///
+///
+public sealed class ConnectionLogEntryConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("connection_log_entry");
+ builder.HasKey(e => e.Id);
+
+ // Client-generated UUIDv7, and here the ordering it carries is load-bearing rather than incidental:
+ // a log is read newest-first, and the id is the only creation time an item has.
+ builder.Property(e => e.Id).ValueGeneratedNever();
+ builder.UseXminConcurrencyToken();
+
+ builder.Property(e => e.Payload).IsRequired();
+
+ builder.HasIndex(e => new { e.VaultId, e.ChangeSequence });
+
+ builder.HasIndex(e => e.VaultId)
+ .HasFilter("deleted_at_utc IS NULL")
+ .HasDatabaseName("ix_connection_log_entry_vault_live");
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_connection_log_entry_version",
+ "version >= 1"));
+ }
+}
+
+///
+/// Maps .
+///
+///
+public sealed class ActivityLogEntryConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("activity_log_entry");
+ builder.HasKey(e => e.Id);
+
+ builder.Property(e => e.Id).ValueGeneratedNever();
+ builder.UseXminConcurrencyToken();
+
+ builder.Property(e => e.Payload).IsRequired();
+
+ builder.HasIndex(e => new { e.VaultId, e.ChangeSequence });
+
+ builder.HasIndex(e => e.VaultId)
+ .HasFilter("deleted_at_utc IS NULL")
+ .HasDatabaseName("ix_activity_log_entry_vault_live");
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_activity_log_entry_version",
+ "version >= 1"));
+ }
+}
+
+///
+/// Maps .
+///
+///
+/// again, which is the right comparison: a secret access key is a
+/// password, and the endpoint beside it is an address on somebody's own network as often as it is Amazon's.
+/// Neither belongs in a column.
+///
+public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("object_store");
+ builder.HasKey(o => o.Id);
+
+ // Client-generated UUIDv7: a bucket must be addable offline, with its id.
+ builder.Property(o => o.Id).ValueGeneratedNever();
+ builder.UseXminConcurrencyToken();
+
+ builder.Property(o => o.Payload).IsRequired();
+
+ builder.HasIndex(o => new { o.VaultId, o.ChangeSequence });
+
+ builder.HasIndex(o => o.VaultId)
+ .HasFilter("deleted_at_utc IS NULL")
+ .HasDatabaseName("ix_object_store_vault_live");
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_object_store_version",
+ "version >= 1"));
+ }
+}
+
/// Maps .
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration
{
diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs
index 79995f9..14b0bde 100644
--- a/src/DodoSSH.Infrastructure/DodoDbContext.cs
+++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs
@@ -63,6 +63,21 @@ public class DodoDbContext(DbContextOptions options) : DbContext(
/// Trusted SSH host keys, held as ciphertext.
public DbSet KnownHostKeys => Set();
+ /// Host groups, held as ciphertext.
+ public DbSet HostGroups => Set();
+
+ /// Saved commands, held as ciphertext.
+ public DbSet Snippets => Set();
+
+ /// Connections that were made, held as ciphertext.
+ public DbSet ConnectionLog => Set();
+
+ /// Changes made to keychain items, held as ciphertext.
+ public DbSet ActivityLog => Set();
+
+ /// S3-compatible buckets and their credentials, held as ciphertext.
+ public DbSet ObjectStores => Set();
+
/// The per-vault change log that delta sync reads.
public DbSet VaultChanges => Set();
diff --git a/src/DodoSSH.Infrastructure/Migrations/20260731132935_AddHostGroupAndSnippetItems.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260731132935_AddHostGroupAndSnippetItems.Designer.cs
new file mode 100644
index 0000000..09a80bb
--- /dev/null
+++ b/src/DodoSSH.Infrastructure/Migrations/20260731132935_AddHostGroupAndSnippetItems.Designer.cs
@@ -0,0 +1,1437 @@
+//