Public Access
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
using System.Threading.Channels;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Records keychain changes into the vault they happened in, without making the save wait.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="ConnectionRecorder"/>'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 <see cref="Record"/> posts to a bounded channel and returns, and one background task
|
||||
/// does the work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Session-scoped, unlike the connection recorder.</b> 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 <see cref="VaultSession"/> rather than by the shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every failure is swallowed.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionRecorder" path="/remarks/para[4]" />
|
||||
private const int QueueDepth = 512;
|
||||
|
||||
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly Channel<ActivityLogSecret> pending = Channel.CreateBounded<ActivityLogSecret>(
|
||||
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;
|
||||
|
||||
/// <param name="log">Where entries go.</param>
|
||||
/// <param name="vaultId">The vault they belong to.</param>
|
||||
/// <param name="actorUserId">Which account is making them.</param>
|
||||
/// <param name="deviceName">What this machine calls itself.</param>
|
||||
/// <param name="clock">Time source.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Record(
|
||||
Guid vaultId,
|
||||
SyncEntityType kind,
|
||||
Guid entityId,
|
||||
string label,
|
||||
ActivityOperation operation,
|
||||
IReadOnlyList<string> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using System.Threading.Channels;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>A connection that has started and has no log entry yet, because it has not ended.</summary>
|
||||
/// <param name="HostLabel">What the host is called.</param>
|
||||
/// <param name="Address">The address as dialled.</param>
|
||||
/// <param name="StartedAt">When it opened.</param>
|
||||
public sealed record OpenConnection(string HostLabel, string Address, DateTimeOffset StartedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Records connections into whichever vault is open, without ever making the caller wait.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A process-lifetime object with session-scoped contents</b>, exactly like <see cref="VaultKnownHostStore"/>
|
||||
/// 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. <see cref="Open"/> on unlock, <see cref="Close"/> on lock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing on the calling thread does any work.</b> 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 — <c>Closed</c> is called from a <c>finally</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A shell can outlive the vault, so close-out has to as well.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The queue is bounded and drops the oldest when full.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ConnectionRecorder : IConnectionLogSink, IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// How many close-outs may be waiting to be written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private const int QueueDepth = 256;
|
||||
|
||||
/// <summary>How long <see cref="DisposeAsync"/> waits for the queue to be written.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly Channel<PendingEntry> pending = Channel.CreateBounded<PendingEntry>(
|
||||
new BoundedChannelOptions(QueueDepth)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
});
|
||||
|
||||
private readonly Dictionary<uint, OpenTicket> 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;
|
||||
|
||||
/// <param name="clock">Time source. Used only for a duration this type did not receive.</param>
|
||||
/// <param name="deviceName">What this machine calls itself, recorded on every entry.</param>
|
||||
public ConnectionRecorder(TimeProvider clock, string deviceName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
|
||||
|
||||
this.clock = clock;
|
||||
this.deviceName = deviceName;
|
||||
|
||||
drain = DrainAsync(lifetime.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The connections that have opened and not yet been recorded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<OpenConnection> Open()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return
|
||||
[
|
||||
.. tickets.Values
|
||||
.Select(ticket => new OpenConnection(
|
||||
ticket.HostLabel, ticket.Address, ticket.StartedAt))
|
||||
.OrderByDescending(open => open.StartedAt),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether a vault is open behind this recorder.</summary>
|
||||
public bool IsOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return binding is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts recording into an unlocked vault.</summary>
|
||||
/// <param name="session">The unlocked session. Its active vault is the one written to.</param>
|
||||
/// <param name="actorUserId">Which account this is, recorded on every entry.</param>
|
||||
public void Open(VaultSession session, Guid actorUserId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
binding = new Binding(session.ConnectionLog, session.ActiveVaultId, actorUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops recording new connections.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Open tickets are deliberately <em>not</em> 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 <em>start</em> a ticket, because a
|
||||
/// connection made while locked has no vault to belong to.
|
||||
/// </remarks>
|
||||
public void Close()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
binding = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names the host an already-open session belongs to.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session, as the workspace knows it.</param>
|
||||
/// <param name="hostLabel">What the host is called in the keychain.</param>
|
||||
/// <param name="hostId">The host item.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The workspace takes an <c>SshConnectionRequest</c>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A session id with no ticket is ignored, which is what a connection made while the vault was locked
|
||||
/// looks like.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a connection that was never a workspace session.
|
||||
/// </summary>
|
||||
/// <param name="address">The address that was dialled.</param>
|
||||
/// <param name="hostLabel">What the host is called.</param>
|
||||
/// <param name="hostId">The host item, if there was one.</param>
|
||||
/// <param name="kind">Which sort of session it was.</param>
|
||||
/// <param name="startedAt">When it began.</param>
|
||||
/// <param name="endedAt">When it ended, which is the same instant for an attempt that failed.</param>
|
||||
/// <param name="outcome">How it ended.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <c>ConnectAsync</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes out every still-open connection and writes what is queued, within a bounded wait.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Closing the application is the ordinary way a session ends</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The wait is bounded and the remainder is dropped.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Which vault entries go to, and who is making them.</summary>
|
||||
private sealed record Binding(ConnectionLogRepository Log, Guid VaultId, Guid ActorUserId);
|
||||
|
||||
/// <summary>A connection that has started and not yet been recorded.</summary>
|
||||
/// <remarks>
|
||||
/// It carries its own <see cref="Binding"/> 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.
|
||||
/// </remarks>
|
||||
private sealed record OpenTicket(
|
||||
Binding Binding,
|
||||
string Address,
|
||||
string HostLabel,
|
||||
Guid? HostId,
|
||||
ConnectionKind Kind,
|
||||
DateTimeOffset StartedAt);
|
||||
|
||||
private sealed record PendingEntry(Binding Binding, ConnectionLogSecret Entry);
|
||||
}
|
||||
@@ -24,6 +24,14 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
<!--
|
||||
The terminal layer, for one interface: IConnectionLogSink, which ConnectionRecorder implements. The
|
||||
direction is the point. Client.Terminal references only Client.Ssh and must keep doing so — a workspace
|
||||
that knew about vaults would be a workspace that could not keep a shell running through a lock — so the
|
||||
hole is declared down there and filled up here, exactly as VaultKnownHostStore fills IKnownHostStore.
|
||||
Nothing in Client.Terminal references this project, so the graph stays acyclic.
|
||||
-->
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>How much log a vault keeps.</summary>
|
||||
/// <param name="MaxAge">How far back entries are kept.</param>
|
||||
/// <param name="MaxEntries">How many entries of each kind are kept, whatever their age.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Retention is not optional here the way it is for a local log file.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record LogRetention(TimeSpan MaxAge, int MaxEntries)
|
||||
{
|
||||
/// <summary>Ninety days, or five thousand entries of each kind.</summary>
|
||||
public static LogRetention Default { get; } = new(TimeSpan.FromDays(90), 5_000);
|
||||
}
|
||||
|
||||
/// <summary>What one pruning pass removed.</summary>
|
||||
/// <param name="Connections">Connection entries deleted.</param>
|
||||
/// <param name="Activity">Activity entries deleted.</param>
|
||||
public sealed record LogPruneResult(int Connections, int Activity)
|
||||
{
|
||||
/// <summary>Whether anything went.</summary>
|
||||
public bool RemovedAnything => Connections > 0 || Activity > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes log entries a vault has agreed to stop keeping.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A real tombstone delete that pushes</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Age is read from the entry, not from the item.</b> 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 <em>written</em>, which
|
||||
/// for a connection is when it ended.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LogPruner
|
||||
{
|
||||
/// <summary>Deletes whatever falls outside the retention policy.</summary>
|
||||
/// <param name="session">The open vault.</param>
|
||||
/// <param name="retention">What to keep.</param>
|
||||
/// <param name="now">The moment to measure age from.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static async Task<LogPruneResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>The ids of the entries that fall outside the policy, newest kept.</summary>
|
||||
private static IReadOnlyList<Guid> Stale<TSecret>(
|
||||
IReadOnlyList<VaultItem<TSecret>> entries,
|
||||
LogRetention retention,
|
||||
DateTimeOffset cutoff,
|
||||
Func<VaultItem<TSecret>, 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,16 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
private readonly VaultKeyring keyring;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly SyncOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Records what is done to this vault's items, for as long as this session lasts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||
@@ -114,6 +137,36 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
public KnownHostRepository KnownHosts { get; }
|
||||
|
||||
/// <summary>The groups hosts are filed under, decrypted, with unpushed local changes laid over them.</summary>
|
||||
/// <remarks>
|
||||
/// Membership is not in here. Each host carries its own <c>GroupId</c>, 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.
|
||||
/// </remarks>
|
||||
public HostGroupRepository HostGroups { get; }
|
||||
|
||||
/// <summary>Saved commands, decrypted, with unpushed local changes laid over them.</summary>
|
||||
public SnippetRepository Snippets { get; }
|
||||
|
||||
/// <summary>S3-compatible buckets and their credentials, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public ObjectStoreRepository ObjectStores { get; }
|
||||
|
||||
/// <summary>The connections this vault has recorded, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// Written through <see cref="ConnectionRecorder"/> 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.
|
||||
/// </remarks>
|
||||
public ConnectionLogRepository ConnectionLog { get; }
|
||||
|
||||
/// <summary>The keychain changes this vault has recorded, decrypted.</summary>
|
||||
public ActivityLogRepository ActivityLog { get; }
|
||||
|
||||
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
|
||||
public IReadOnlyList<Guid> 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);
|
||||
}
|
||||
|
||||
/// <summary>How many local changes are waiting to be pushed.</summary>
|
||||
/// <summary>
|
||||
/// How many local changes the <em>user</em> has made that are waiting to be pushed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Log entries are excluded, and the exclusion is the honest reading rather than a convenience.</b>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<int> 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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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)
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the device key store this machine can actually offer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
||||
/// passphrase — which is the honest answer rather than a degraded one.
|
||||
/// </remarks>
|
||||
public static class DeviceKeyStores
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
||||
? new WindowsDeviceKeyStore(paths)
|
||||
: new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The consent is enforced by CNG, not by this class</b>, and that distinction is the entire security
|
||||
/// value. A store that read a DPAPI blob after showing its own prompt would be trivially bypassed:
|
||||
/// malware running as the user would skip the prompt, read the file and call
|
||||
/// <c>CryptUnprotectData</c> itself. Here the unwrapping key lives in the TPM under
|
||||
/// <see cref="CngUIProtectionLevels.ProtectKey"/>, so the Windows consent dialog is a condition of
|
||||
/// <em>using</em> the key. Malware can ask; it cannot answer, and the attempt is visible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is a refinement of what ADR 0007 describes, and stronger than it: the ADR reasoned about a
|
||||
/// gesture gating a protected blob and did not notice that a gate inside the process is not a gate. The
|
||||
/// mechanism recorded there has been corrected to match this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The TPM key is RSA rather than the ECDH one might expect, because it is used to encrypt 32 bytes and
|
||||
/// nothing else. OAEP over a 2048-bit key carries 190, so there is no need for an agreement step, and no
|
||||
/// need for the device key itself to be an algorithm the TPM understands — which is what keeps the DSH1
|
||||
/// device wrap unchanged at X25519. See ADR 0007 for why changing that is a separate decision.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both ends prompt, and that was measured rather than assumed.</b> Encrypting uses only the public
|
||||
/// half, so it was reasonable to expect sealing to be silent — it is not. <c>CngKey.Create</c> with this
|
||||
/// UI policy blocks on a dialog at <em>creation</em>, because <c>ProtectKey</c> means "protect this key
|
||||
/// with a PIN", and Windows asks the user to set that up there and then. So registering a device shows one
|
||||
/// setup dialog and every unlock shows a consent dialog.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That is the right shape for an opt-in feature, but it has two consequences worth knowing before
|
||||
/// touching this file. Every method except <see cref="IsSupported"/> and the empty case of
|
||||
/// <see cref="TryLoadAsync"/> needs an interactive desktop, so <b>none of them can be exercised by an
|
||||
/// automated test</b> — see <c>WindowsDeviceKeyStoreTests</c> for where that line falls. And a caller must
|
||||
/// treat <see cref="SaveAsync"/> as user-facing: it belongs on a UI thread, behind a button somebody
|
||||
/// pressed, never on a background pass.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
{
|
||||
/// <remarks>
|
||||
/// Versioned, so a future change of algorithm or padding can create a new key beside the old one
|
||||
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
|
||||
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
||||
/// their gesture had stopped working.
|
||||
/// </remarks>
|
||||
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
||||
|
||||
private const string PlatformProvider = "Microsoft Platform Crypto Provider";
|
||||
|
||||
/// <remarks>Shown in the Windows consent dialog, so it has to read as a sentence to a person.</remarks>
|
||||
private const string ConsentPrompt = "Unlock your DodoSSH vault";
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public WindowsDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this machine has a TPM the platform provider will hold a key in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Probed by creating a throwaway key and deleting it, rather than by asking the provider whether it
|
||||
/// exists. The provider is registered on machines with no usable TPM as well, and reports itself
|
||||
/// present right up to the point where creating a key fails — so the only honest test is the one that
|
||||
/// does the thing. No UI policy on the probe, so nothing prompts.
|
||||
/// </remarks>
|
||||
internal static bool IsSupported()
|
||||
{
|
||||
var probe = $"DodoSSH.Probe.{Guid.CreateVersion7():N}";
|
||||
|
||||
try
|
||||
{
|
||||
using var key = CngKey.Create(
|
||||
CngAlgorithm.Rsa,
|
||||
probe,
|
||||
new CngKeyCreationParameters { Provider = new CngProvider(PlatformProvider) });
|
||||
|
||||
key.Delete();
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(IsSupported());
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SaveAsync(
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var key = OpenOrCreate();
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
||||
|
||||
paths.EnsureCreated();
|
||||
|
||||
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
File.Delete(paths.DeviceKeyFile);
|
||||
}
|
||||
|
||||
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
key.Delete();
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks: the
|
||||
/// key may be gone, the user may have cancelled, the TPM may be locked out after too many wrong PINs,
|
||||
/// or the blob may predate a key that has since been replaced. None of them are distinguishable to a
|
||||
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
||||
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||
/// </remarks>
|
||||
private static byte[]? Unseal(byte[] sealedKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The UI policy is set at creation and cannot be added afterwards, which is why this opens an existing
|
||||
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
||||
/// silently is the one behaviour this whole file exists to prevent.
|
||||
/// </remarks>
|
||||
private static CngKey OpenOrCreate()
|
||||
{
|
||||
var provider = new CngProvider(PlatformProvider);
|
||||
|
||||
if (CngKey.Exists(KeyName, provider))
|
||||
{
|
||||
return CngKey.Open(KeyName, provider);
|
||||
}
|
||||
|
||||
return CngKey.Create(
|
||||
CngAlgorithm.Rsa,
|
||||
KeyName,
|
||||
new CngKeyCreationParameters
|
||||
{
|
||||
Provider = provider,
|
||||
UIPolicy = new CngUIPolicy(CngUIProtectionLevels.ProtectKey, ConsentPrompt),
|
||||
|
||||
// Machine-wide would put one key behind every account on the computer. This key stands for
|
||||
// "this user, on this machine", which is what a device wrap means.
|
||||
KeyCreationOptions = CngKeyCreationOptions.None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user