Public Access
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b81da597 | ||
|
|
0e8fe4fc80 | ||
|
|
2ff9dff560 | ||
|
|
6846f67c20 | ||
|
|
f332d9a56f | ||
|
|
03c8e8589a | ||
|
|
4c7f47c026 | ||
|
|
412c314aec |
No files matched your search
@@ -0,0 +1,49 @@
|
||||
using DodoSSH.Domain;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Authorization;
|
||||
|
||||
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
|
||||
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
|
||||
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
|
||||
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
|
||||
/// that no endpoint can be reached without it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
|
||||
/// reached by this; it ends on its own idle and lifetime limits.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
|
||||
|
||||
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
|
||||
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
|
||||
: AuthorizationHandler<ActiveAccountRequirement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ActiveAccountRequirement requirement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
|
||||
// as well would turn that into a 403.
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||
|
||||
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (user.Status == UserStatus.Active)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
|
||||
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
||||
/// requests from one user would each read the row, each set the timestamp, and the second would
|
||||
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
||||
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
|
||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
||||
/// <para>
|
||||
/// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
|
||||
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
|
||||
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
|
||||
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
|
||||
/// against the version from before the update and failed, so enrolling more than an hour after first
|
||||
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
|
||||
return;
|
||||
}
|
||||
|
||||
await database.Users
|
||||
var written = await database.Users
|
||||
.Where(u => u.Id == user.Id
|
||||
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
||||
.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (written > 0)
|
||||
{
|
||||
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||
|
||||
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(authorizeResult);
|
||||
|
||||
var accountNotActive = authorizeResult.Forbidden
|
||||
&& authorizeResult.AuthorizationFailure is { } refusal
|
||||
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
|
||||
|
||||
if (accountNotActive)
|
||||
{
|
||||
await TypedResults.Problem(
|
||||
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
|
||||
statusCode: StatusCodes.Status403Forbidden,
|
||||
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
|
||||
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["code"] = ProblemCodes.AccountNotActive,
|
||||
})
|
||||
.ExecuteAsync(context)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var enrollmentMissing = authorizeResult.Forbidden
|
||||
&& authorizeResult.AuthorizationFailure is { } failure
|
||||
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
||||
|
||||
@@ -536,9 +536,21 @@ internal sealed class SyncService(
|
||||
|
||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
||||
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
||||
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
|
||||
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
|
||||
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
|
||||
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
|
||||
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
|
||||
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
|
||||
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
|
||||
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||
Vault vault,
|
||||
@@ -571,10 +583,13 @@ internal sealed class SyncService(
|
||||
}
|
||||
}
|
||||
|
||||
return [.. changes.Select(change => Describe(
|
||||
change,
|
||||
kinds.GetValueOrDefault(change.EntityType),
|
||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||
return [.. changes
|
||||
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|
||||
|| row.ChangeSequence <= change.Sequence)
|
||||
.Select(change => Describe(
|
||||
change,
|
||||
kinds.GetValueOrDefault(change.EntityType),
|
||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||
}
|
||||
|
||||
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
||||
|
||||
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
|
||||
}
|
||||
|
||||
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
|
||||
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
|
||||
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
|
||||
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
|
||||
// checks it again, because deleting a team's last vault archives it too.
|
||||
if (!access.IsOwner)
|
||||
{
|
||||
return Problems.Coded(
|
||||
|
||||
@@ -244,8 +244,16 @@ internal sealed class TeamService(
|
||||
Team team,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actor);
|
||||
ArgumentNullException.ThrowIfNull(team);
|
||||
|
||||
// Both callers check this first and answer the caller properly; this is what stops a third one that
|
||||
// forgets from letting an admin archive the team out from under its owner.
|
||||
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
|
||||
{
|
||||
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||
}
|
||||
|
||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (vaultCount > 0)
|
||||
|
||||
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
|
||||
{
|
||||
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
||||
|
||||
if (orphaned is not null)
|
||||
// Archived only by its owner. Archiving revokes every membership, the owner's included, and an
|
||||
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
|
||||
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
|
||||
// the owner, who can archive it or make a vault in it.
|
||||
if (orphaned is not null
|
||||
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
|
||||
{
|
||||
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ builder.Services.AddDodoRelay();
|
||||
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
||||
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
|
||||
|
||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||
|
||||
@@ -62,7 +62,14 @@ internal static class Auth
|
||||
|
||||
private static void AddDodoPolicies(AuthorizationOptions options)
|
||||
{
|
||||
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
||||
// Both policies also require the account to be active. A suspended or deprovisioned account still
|
||||
// holds a token the identity provider will vouch for until it expires — the status is this server's
|
||||
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
|
||||
options.AddPolicy(
|
||||
AuthenticatedPolicy,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new Authorization.ActiveAccountRequirement()));
|
||||
|
||||
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
||||
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
||||
@@ -72,7 +79,7 @@ internal static class Auth
|
||||
EnrolledPolicy,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new Authorization.EnrolledRequirement()));
|
||||
.AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
|
||||
|
||||
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||
|
||||
@@ -352,39 +352,9 @@ internal sealed partial class DodoSshApp : Application
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
readClipboard: ClipboardReader(desktop),
|
||||
updates: updates,
|
||||
openInEditor: OpenInDefaultEditorAsync,
|
||||
openInEditor: LocalEditor.OpenAsync,
|
||||
watchFile: WatchFile);
|
||||
|
||||
/// <summary>
|
||||
/// Hands a local file to whatever this machine opens that kind of file with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
|
||||
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
|
||||
/// Services binding depending on which desktop this is, and watching a file is a platform object with
|
||||
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
|
||||
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
|
||||
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
|
||||
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
|
||||
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static Task OpenInDefaultEditorAsync(string path)
|
||||
{
|
||||
using var opened = System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches one file for saves, until the handle is disposed.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// What a platform key store calls the hardware key behind one profile's device unlock.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>One key per profile, because the key outlives the file and is shared by name.</b> The CNG key and
|
||||
/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
|
||||
/// profile directory. While every profile used the same name, the release build and the nightly — which
|
||||
/// <see cref="ClientPaths.ForChannel"/> keeps apart precisely so neither can damage the other — shared one
|
||||
/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The default profile keeps the name it has always had</b>, so an existing install's sealed key still
|
||||
/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
|
||||
/// than the directory's own name, because a portable copy can live in any folder and two such folders can
|
||||
/// end in the same name.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class DeviceKeyNames
|
||||
{
|
||||
/// <summary>The name of the key behind <paramref name="paths"/>' device unlock.</summary>
|
||||
/// <param name="paths">The profile.</param>
|
||||
/// <param name="legacy">The name every profile used before names were per profile.</param>
|
||||
internal static string For(ClientPaths paths, string legacy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
var directory = Normalise(paths.DataDirectory);
|
||||
|
||||
if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return legacy;
|
||||
}
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
|
||||
|
||||
return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
|
||||
}
|
||||
|
||||
private static string Normalise(string directory) =>
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a local copy of a remote file for editing, without running it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
|
||||
/// shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch Services binding
|
||||
/// depending on which desktop this is. The shell decides <em>when</em>; see
|
||||
/// <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
|
||||
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
|
||||
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
|
||||
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
|
||||
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
|
||||
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
|
||||
/// which is the default text editor whatever the type. Everything else still goes to its association, which
|
||||
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
|
||||
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
|
||||
/// by every mainstream desktop's file associations in an editor, not run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
|
||||
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class LocalEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
|
||||
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
|
||||
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
|
||||
/// items, and the script hosts that installers do not register there.
|
||||
/// </remarks>
|
||||
private static readonly string[] WindowsRunsThese =
|
||||
[
|
||||
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
|
||||
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
|
||||
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
|
||||
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
|
||||
];
|
||||
|
||||
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
|
||||
private static readonly string[] MacRunsThese =
|
||||
[
|
||||
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
|
||||
".tool", ".webloc", ".workflow",
|
||||
];
|
||||
|
||||
/// <summary>Opens <paramref name="path"/> for editing.</summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// This desktop would run the file and has no editor to offer in its place.
|
||||
/// </exception>
|
||||
internal static Task OpenAsync(string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
using var opened = Process.Start(StartInfoFor(path));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
|
||||
internal static ProcessStartInfo StartInfoFor(string path)
|
||||
{
|
||||
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
|
||||
{
|
||||
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
|
||||
|
||||
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return edit;
|
||||
}
|
||||
|
||||
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
|
||||
{
|
||||
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
|
||||
+ "Download it and open it in an editor instead.");
|
||||
}
|
||||
|
||||
return new ProcessStartInfo(path) { UseShellExecute = true };
|
||||
}
|
||||
|
||||
/// <summary>Whether Windows' default action for this file is to run it.</summary>
|
||||
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
|
||||
{
|
||||
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
|
||||
}
|
||||
|
||||
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
|
||||
/// <remarks>
|
||||
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
|
||||
/// <c>run.bat</c> by the time anything acts on it.
|
||||
/// </remarks>
|
||||
private static bool HasExtension(string path, IEnumerable<string> extensions)
|
||||
{
|
||||
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
|
||||
|
||||
return extension.Length > 0
|
||||
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="KeyTag"/> for the default profile and a tag of its own for every other, so that signing
|
||||
/// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
|
||||
/// registered before this is still sealed to the shared tag and is read from there until it registers
|
||||
/// again; it is never deleted from a profile that does not own it.
|
||||
/// </remarks>
|
||||
private readonly string keyTag;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public MacDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
keyTag = DeviceKeyNames.For(paths, KeyTag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sealedKey = Seal(devicePrivateKey.Span)
|
||||
var sealedKey = Seal(devicePrivateKey.Span, keyTag)
|
||||
?? throw new InvalidOperationException(
|
||||
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||
|
||||
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
// The shared tag only while this profile has no key of its own, so one unlock is one prompt.
|
||||
return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
||||
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
||||
// left in the enclave with no ciphertext to open is inert.
|
||||
_ = DeleteKey(symbols, KeyTag);
|
||||
_ = DeleteKey(symbols, keyTag);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
||||
/// them is the same — do not offer a device unlock.
|
||||
/// </remarks>
|
||||
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
|
||||
/// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
|
||||
private static bool HasKey(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey, string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
|
||||
// Created on first use rather than at registration, so that a device key re-registered after
|
||||
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
||||
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
|
||||
var privateKey = FindKey(scope, symbols, tag, prompt: null);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
||||
privateKey = CreateEnclaveKey(scope, symbols, tag);
|
||||
}
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
/// on a thread that is also expected to draw the window behind it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static byte[]? Unseal(byte[] sealedKey)
|
||||
private static byte[]? Unseal(byte[] sealedKey, string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
return null;
|
||||
}
|
||||
|
||||
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
||||
var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
|
||||
@@ -123,6 +123,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// This is the default profile's key, and the name every profile shared before keys were per profile;
|
||||
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
||||
|
||||
@@ -133,11 +137,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>This profile's own key.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
|
||||
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
|
||||
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
|
||||
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
|
||||
/// profile that does not own it, which is the half of this that matters.
|
||||
/// </remarks>
|
||||
private readonly string keyName;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public WindowsDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
keyName = DeviceKeyNames.For(paths, KeyName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -182,7 +197,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var key = OpenOrCreate();
|
||||
using var key = OpenOrCreate(keyName);
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
||||
@@ -204,7 +219,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
// The shared key only while this profile has none of its own: a profile registered before keys were
|
||||
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
|
||||
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
|
||||
? KeyName
|
||||
: keyName;
|
||||
|
||||
return Unseal(sealedKey, sealedTo);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -215,9 +236,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
File.Delete(paths.DeviceKeyFile);
|
||||
}
|
||||
|
||||
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
// This profile's key and never the shared one, unless this is the profile that owns it. Another
|
||||
// profile's device unlock may still be sealed to it.
|
||||
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
|
||||
key.Delete();
|
||||
}
|
||||
|
||||
@@ -231,16 +254,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
/// 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)
|
||||
private static byte[]? Unseal(byte[] sealedKey, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
||||
@@ -256,18 +279,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
/// 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()
|
||||
private static CngKey OpenOrCreate(string name)
|
||||
{
|
||||
var provider = new CngProvider(PlatformProvider);
|
||||
|
||||
if (CngKey.Exists(KeyName, provider))
|
||||
if (CngKey.Exists(name, provider))
|
||||
{
|
||||
return CngKey.Open(KeyName, provider);
|
||||
return CngKey.Open(name, provider);
|
||||
}
|
||||
|
||||
return CngKey.Create(
|
||||
CngAlgorithm.Rsa,
|
||||
KeyName,
|
||||
name,
|
||||
new CngKeyCreationParameters
|
||||
{
|
||||
Provider = provider,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Width="420" SizeToContent="Height"
|
||||
CanResize="False" ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Surface}"
|
||||
Background="{StaticResource Panel}"
|
||||
Title="DodoSSH">
|
||||
|
||||
<!--
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
|
||||
<TextBlock Foreground="{StaticResource TextDim}" FontSize="12" TextWrapping="Wrap"
|
||||
x:Name="Explanation" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -39,7 +39,10 @@ public sealed partial class DevicePinWindow : Window
|
||||
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
||||
public DevicePinWindow()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
// InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
|
||||
// fields below, and with Load the first of them was null — so this window threw on construction and
|
||||
// no Linux PIN prompt was ever shown.
|
||||
InitializeComponent();
|
||||
|
||||
Accept.Click += OnAccept;
|
||||
Cancel.Click += OnCancel;
|
||||
|
||||
@@ -683,7 +683,7 @@
|
||||
TextTrimming never fires and the name would run on past the card's own
|
||||
border instead of trimming. See the longer note this carried in v4.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
@@ -708,17 +708,27 @@
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Width="9" Height="9" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
||||
<!--
|
||||
"not synced" and its kin sit on the address line. Beside the name, where it
|
||||
used to be, it took seventy of the card's pixels from the one thing a card
|
||||
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
|
||||
both drew as "k8s…". The address can give the room up — it trims, and the
|
||||
tooltip and the drawer both carry it whole.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
Which vault this host is in, and only when there is more than one to be
|
||||
in. It decides who else can see the host and where an edit goes back to,
|
||||
@@ -734,8 +744,13 @@
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
||||
Margin="0,8,0,0">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4"
|
||||
VerticalAlignment="Bottom">
|
||||
<!--
|
||||
A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
|
||||
measures its children with infinite width, so the tags' WrapPanel below
|
||||
never wrapped and ran past the card's edge; the last child of a DockPanel
|
||||
gets the width that is actually left, and wraps in it.
|
||||
-->
|
||||
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
|
||||
|
||||
<!--
|
||||
The pin badge: how many folders are pinned on this host, reading
|
||||
@@ -743,8 +758,8 @@
|
||||
property's own remarks. Only when there is at least one; a badge reading
|
||||
zero would be a fact nobody asked for.
|
||||
-->
|
||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
||||
IsVisible="{Binding HasPins}">
|
||||
<Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
|
||||
Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||
@@ -767,12 +782,13 @@
|
||||
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
||||
Margin="0,0,4,4">
|
||||
<TextBlock Text="{Binding}" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<!--
|
||||
HostRowViewModel.LastConnectedText is filled from the connection log on
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
item this application stores carries a timestamp; see the file-level remark above and
|
||||
docs/design-import-gaps.md.
|
||||
-->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Margin="0,10,16,6"
|
||||
IsVisible="{Binding HasVaultItems}">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
@@ -290,17 +290,23 @@
|
||||
Margin="8,0,8,10">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
<Grid ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Height="40" Margin="6,0,10,0">
|
||||
<!--
|
||||
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
|
||||
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
|
||||
columns beside it.
|
||||
-->
|
||||
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
|
||||
</Grid>
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
||||
|
||||
@@ -244,6 +244,13 @@ internal sealed partial class MainWindow : Window
|
||||
viewModel.CloseImportCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
// The members dialog on Settings → Vaults, by the same rule: it is drawn over the page, so it goes
|
||||
// first and settings stays.
|
||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode && viewModel.Vaults.IsMembersPanelOpen)
|
||||
{
|
||||
viewModel.Vaults.CloseMembersPanelCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
||||
{
|
||||
viewModel.LeaveSettingsCommand.Execute(null);
|
||||
|
||||
@@ -46,19 +46,21 @@
|
||||
<TextBlock Classes="settingstitle" Text="Security" />
|
||||
|
||||
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
|
||||
<!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
|
||||
Margin="0,0,16,0" VerticalAlignment="Top">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
||||
Foreground="{StaticResource Live}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
||||
|
||||
@@ -283,6 +283,9 @@ internal sealed class S3FileStore : IRemoteFileStore
|
||||
/// have cost.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool CanWriteFromAnOffset => false;
|
||||
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
@@ -145,6 +145,26 @@ public sealed class VaultKnownHostStore : IKnownHostStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>From the snapshot, like <see cref="FindAsync"/>, and for the same reason.</remarks>
|
||||
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
IReadOnlyList<HostKeyPresentation> found = pins.Values
|
||||
.Where(pin =>
|
||||
KnownHostIdentity.Comparer.Equals(pin.Secret.Host, host) && pin.Secret.Port == port)
|
||||
.Select(pin => new HostKeyPresentation(
|
||||
pin.Secret.Host, pin.Secret.Port, pin.Secret.Algorithm, pin.Secret.Fingerprint))
|
||||
.ToArray();
|
||||
|
||||
return ValueTask.FromResult(found);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The vault is not open, so there is nowhere to record trust.
|
||||
|
||||
@@ -566,7 +566,7 @@ internal sealed partial class HetznerImportViewModel(
|
||||
// and visible rather than harmless and confusing.
|
||||
Refresh(resetSelection: true);
|
||||
|
||||
Status = WhatWasStored(outcome.Hosts);
|
||||
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
|
||||
|
||||
Step = HetznerImportStep.Done;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,27 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
||||
/// </param>
|
||||
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
||||
|
||||
/// <summary>What an import stored.</summary>
|
||||
/// <summary>What an import stored, and what it did not.</summary>
|
||||
/// <param name="Hosts">Hosts written.</param>
|
||||
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
||||
internal sealed record ImportOutcome(int Hosts, int Keys);
|
||||
/// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
|
||||
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
|
||||
/// <remarks>
|
||||
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
|
||||
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
|
||||
/// </remarks>
|
||||
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
|
||||
{
|
||||
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
|
||||
internal string Caveats =>
|
||||
(Refused switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => " 1 was skipped because it is not a host this keychain can connect to.",
|
||||
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
|
||||
})
|
||||
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||
/// <remarks>
|
||||
@@ -409,7 +426,7 @@ internal sealed partial class ImportViewModel(
|
||||
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
||||
};
|
||||
|
||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
|
||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1795,6 +1795,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ActiveSettingsPage = null;
|
||||
IsImportOpen = false;
|
||||
IsHetznerImportOpen = false;
|
||||
|
||||
// Drawn over Settings → Vaults. Left open, it was still up the next time Settings opened, over
|
||||
// whichever page that was.
|
||||
vaults.IsMembersPanelOpen = false;
|
||||
Screen = settingsReturnScreen;
|
||||
Surface = settingsReturnSurface;
|
||||
}
|
||||
@@ -5107,6 +5111,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Settings is a mode of the unlocked window, and it brings its own title bar while the main one
|
||||
// is hidden. Locking or signing out from it — LOCK NOW is on Preferences, sign-out on Account —
|
||||
// left the window in settings mode under the lock card: no title bar, so nothing to drag, minimise
|
||||
// or close, and an unlock that came back to Settings rather than to Hosts.
|
||||
LeaveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -879,6 +879,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private ObjectStoreRowViewModel? selectedBucket;
|
||||
|
||||
/// <summary>The host and bucket somebody chose, as opposed to whatever the pickers show this instant.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="RefreshHosts"/> re-finds the selection by id, and it runs on every change to the vault's
|
||||
/// lists — including the <c>Clear()</c> a reload starts with, at which point there is nothing to re-find
|
||||
/// and the picker's own selection becomes null. Re-finding from that null on the next <c>Add</c> moved the
|
||||
/// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then
|
||||
/// dialled the first host with the password typed for theirs. So the choice is remembered here, written
|
||||
/// only when a person changes the picker, and is what every refresh looks for.
|
||||
/// </remarks>
|
||||
private Guid? chosenHost;
|
||||
|
||||
/// <inheritdoc cref="chosenHost" path="/remarks" />
|
||||
private Guid? chosenBucket;
|
||||
|
||||
/// <summary>Whether a selection change is <see cref="RefreshHosts"/> putting things back, not a choice.</summary>
|
||||
private bool refreshingPickers;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a bucket to open at all.
|
||||
/// </summary>
|
||||
@@ -969,6 +986,17 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private string typedPassword = string.Empty;
|
||||
|
||||
/// <summary>The host showing when <see cref="TypedPassword"/> was typed, which is the only one it is for.</summary>
|
||||
/// <remarks>
|
||||
/// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first —
|
||||
/// and the box does not empty when it does, because it cannot tell that from a reload refilling the list.
|
||||
/// So the password is checked against this at the moment it would be sent instead, and never goes to a
|
||||
/// host it was not typed for.
|
||||
/// </remarks>
|
||||
private Guid? typedPasswordFor;
|
||||
|
||||
partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId;
|
||||
|
||||
/// <remarks>
|
||||
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
|
||||
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
|
||||
@@ -1780,8 +1808,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
|
||||
|
||||
if (!open.TryBuildConnectionRequest(
|
||||
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
|
||||
row.Host, password, out var request, out var identityLabel, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -2677,9 +2707,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
|
||||
{
|
||||
plan.Unsafe++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.IsFile)
|
||||
{
|
||||
plan.Downloads.Add((Path.Combine(LocalPath, row.Name), row.Entry));
|
||||
plan.Downloads.Add((local, row.Entry));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2695,11 +2731,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
await WalkRemoteAsync(
|
||||
row.FullPath,
|
||||
Path.Combine(LocalPath, row.Name),
|
||||
local,
|
||||
plan,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
EnqueueDownloads(plan);
|
||||
|
||||
Status = Describe(plan, "download into", LocalPath);
|
||||
}
|
||||
|
||||
/// <summary>Makes the directories a download plan needs here, then hands its files to the queue.</summary>
|
||||
private void EnqueueDownloads(TransferPlan plan)
|
||||
{
|
||||
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
|
||||
{
|
||||
try
|
||||
@@ -2719,8 +2763,6 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
plan.Queued++;
|
||||
}
|
||||
|
||||
Status = Describe(plan, "download into", LocalPath);
|
||||
}
|
||||
|
||||
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
|
||||
@@ -2774,30 +2816,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
switch (entry.Kind)
|
||||
{
|
||||
case SftpEntryKind.File:
|
||||
plan.Downloads.Add((Path.Combine(toDirectory, entry.Name), entry));
|
||||
break;
|
||||
|
||||
case SftpEntryKind.Directory:
|
||||
pending.Enqueue((entry.FullPath, Path.Combine(toDirectory, entry.Name)));
|
||||
break;
|
||||
|
||||
case SftpEntryKind.SymbolicLink:
|
||||
plan.Links++;
|
||||
break;
|
||||
|
||||
default:
|
||||
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to
|
||||
// create, and counting it as a refusal would overstate what went wrong.
|
||||
plan.Skipped++;
|
||||
break;
|
||||
}
|
||||
PlanRemoteEntry(plan, pending, entry, toDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds one entry of a remote listing to a download plan.</summary>
|
||||
private static void PlanRemoteEntry(
|
||||
TransferPlan plan,
|
||||
Queue<(string Remote, string Local)> pending,
|
||||
SftpEntry entry,
|
||||
string toDirectory)
|
||||
{
|
||||
var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory
|
||||
? LocalDirectory.ChildPath(toDirectory, entry.Name)
|
||||
: null;
|
||||
|
||||
switch (entry.Kind)
|
||||
{
|
||||
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
|
||||
plan.Unsafe++;
|
||||
break;
|
||||
|
||||
case SftpEntryKind.File:
|
||||
plan.Downloads.Add((here!, entry));
|
||||
break;
|
||||
|
||||
case SftpEntryKind.Directory:
|
||||
pending.Enqueue((entry.FullPath, here!));
|
||||
break;
|
||||
|
||||
case SftpEntryKind.SymbolicLink:
|
||||
plan.Links++;
|
||||
break;
|
||||
|
||||
default:
|
||||
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
|
||||
// counting it as a refusal would overstate what went wrong.
|
||||
plan.Skipped++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What an upload of this local file should carry to the host with it.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -2893,6 +2953,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
|
||||
public int Skipped { get; set; }
|
||||
|
||||
/// <summary>Remote names that would land outside the directory they were meant for.</summary>
|
||||
/// <remarks>See <see cref="LocalDirectory.ChildPath"/>.</remarks>
|
||||
public int Unsafe { get; set; }
|
||||
|
||||
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
|
||||
public bool Truncated { get; set; }
|
||||
|
||||
@@ -2937,6 +3001,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
said += $" {other} not a file.";
|
||||
}
|
||||
|
||||
if (plan.Unsafe > 0)
|
||||
{
|
||||
var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were";
|
||||
said += $" {named} left on the host.";
|
||||
}
|
||||
|
||||
if (plan.Truncated)
|
||||
{
|
||||
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
|
||||
@@ -3162,13 +3232,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
$"Fetching {row.Name}…",
|
||||
async () =>
|
||||
{
|
||||
var copy = Path.Combine(
|
||||
var folder = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"dodossh-edits",
|
||||
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
|
||||
row.Name);
|
||||
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(copy)!);
|
||||
if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy)
|
||||
{
|
||||
Status = $"{row.Name} is not a name this machine can open as a file.";
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -3922,31 +3997,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
private void RefreshHosts()
|
||||
{
|
||||
var host = SelectedHost?.EntityId;
|
||||
var bucket = SelectedBucket?.EntityId;
|
||||
refreshingPickers = true;
|
||||
|
||||
Hosts.Clear();
|
||||
|
||||
if (vault is not { } open)
|
||||
try
|
||||
{
|
||||
return;
|
||||
}
|
||||
Hosts.Clear();
|
||||
|
||||
foreach (var row in open.Hosts)
|
||||
if (vault is not { } open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var row in open.Hosts)
|
||||
{
|
||||
Hosts.Add(row);
|
||||
}
|
||||
|
||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
|
||||
|
||||
Buckets.Clear();
|
||||
|
||||
foreach (var row in open.ObjectStores)
|
||||
{
|
||||
Buckets.Add(row);
|
||||
}
|
||||
|
||||
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Hosts.Add(row);
|
||||
refreshingPickers = false;
|
||||
}
|
||||
|
||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
|
||||
|
||||
Buckets.Clear();
|
||||
|
||||
foreach (var row in open.ObjectStores)
|
||||
{
|
||||
Buckets.Add(row);
|
||||
}
|
||||
|
||||
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
@@ -4106,8 +4187,30 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value)
|
||||
{
|
||||
if (!refreshingPickers)
|
||||
{
|
||||
chosenHost = value?.EntityId;
|
||||
|
||||
// A person moving to another host gets an empty box rather than one still showing a password
|
||||
// that, by typedPasswordFor, would no longer be sent.
|
||||
if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor)
|
||||
{
|
||||
TypedPassword = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
}
|
||||
|
||||
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
|
||||
{
|
||||
if (!refreshingPickers)
|
||||
{
|
||||
chosenBucket = value?.EntityId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
||||
|
||||
@@ -8838,53 +8838,94 @@ internal sealed partial class VaultViewModel(
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var target = vaultId ?? session.ActiveVaultId;
|
||||
var hosts = 0;
|
||||
var keys = 0;
|
||||
var refused = 0;
|
||||
var tally = new ImportTally();
|
||||
var ran = false;
|
||||
string? failure = null;
|
||||
|
||||
await RunAsync(
|
||||
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
||||
async () =>
|
||||
{
|
||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||
// front: the second host naming a file has to land on the id the first one produced, and
|
||||
// nothing is reloaded until the batch finishes.
|
||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
ran = true;
|
||||
|
||||
foreach (var request in requests)
|
||||
try
|
||||
{
|
||||
if (!request.Host.TryValidate(out _))
|
||||
{
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(
|
||||
request, bound, target, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
{
|
||||
keys++;
|
||||
}
|
||||
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts
|
||||
.CreateAsync(target, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
hosts++;
|
||||
await ImportEachAsync(requests, target, tally, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
// Reported, and the hosts written before it are still reloaded below: they are in the
|
||||
// keychain whether or not the rest followed, and the screen has to say both.
|
||||
failure = exception.Message;
|
||||
}
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = DescribeImport(hosts, keys, refused);
|
||||
Status = DescribeImport(tally.Hosts, tally.Keys, tally.Refused)
|
||||
+ (failure is null ? string.Empty : $" The import stopped early: {failure}");
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
if (!ran)
|
||||
{
|
||||
// RunAsync returns at once while anything else is running — a sync pass, a save — and the
|
||||
// screen asking would otherwise have said it imported nothing as though that were the result.
|
||||
failure = "the keychain was busy with something else. Nothing was imported; try again.";
|
||||
}
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return new ImportOutcome(hosts, keys);
|
||||
return new ImportOutcome(tally.Hosts, tally.Keys, tally.Refused, failure);
|
||||
}
|
||||
|
||||
/// <summary>Writes each host of an import, and any key it brought, counting as it goes.</summary>
|
||||
/// <remarks>
|
||||
/// Counted into <paramref name="tally"/> rather than returned, so that when a write fails partway the
|
||||
/// caller still knows how many landed before it — which is what it has to tell the person.
|
||||
/// </remarks>
|
||||
private async Task ImportEachAsync(
|
||||
IReadOnlyList<ImportedHostRequest> batch,
|
||||
Guid into,
|
||||
ImportTally tally,
|
||||
CancellationToken token)
|
||||
{
|
||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||
// front: the second host naming a file has to land on the id the first one produced, and nothing
|
||||
// is reloaded until the batch finishes.
|
||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var request in batch)
|
||||
{
|
||||
if (!request.Host.TryValidate(out _))
|
||||
{
|
||||
tally.Refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, into, token)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
{
|
||||
tally.Keys++;
|
||||
}
|
||||
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts.CreateAsync(into, host, token).ConfigureAwait(true);
|
||||
|
||||
tally.Hosts++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What one import has written so far.</summary>
|
||||
private sealed class ImportTally
|
||||
{
|
||||
internal int Hosts { get; set; }
|
||||
|
||||
internal int Keys { get; set; }
|
||||
|
||||
internal int Refused { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
||||
|
||||
@@ -1639,11 +1639,32 @@ internal sealed partial class VaultsViewModel(
|
||||
}
|
||||
|
||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||
// from a list box, which has no cancellation token and no way to await. Wrapped here rather than
|
||||
// inside the read, because the read is also awaited from inside a reload that already holds the
|
||||
// busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
|
||||
// members list entirely.
|
||||
_ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
|
||||
// from a list box, which has no cancellation token and no way to await.
|
||||
//
|
||||
// ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
|
||||
// a share, a reload — so a vault chosen in that moment never had its people read, and the panel
|
||||
// went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
|
||||
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
|
||||
// and every answer for an earlier selection is discarded by it.
|
||||
_ = LoadSelectionAsync();
|
||||
}
|
||||
|
||||
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
|
||||
private async Task LoadSelectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPendingActionChanged(VaultActionRequest? value)
|
||||
|
||||
@@ -64,6 +64,22 @@ public interface IKnownHostStore
|
||||
/// </remarks>
|
||||
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns every key pinned for one endpoint, whatever the algorithm.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>What stops a pin being sidestepped by changing algorithm.</b> <see cref="FindAsync"/> answers
|
||||
/// per algorithm, so on its own a host pinned for <c>ssh-ed25519</c> that is answered by somebody offering
|
||||
/// only <c>ecdsa-sha2-nistp256</c> looks like a host never seen before — and "never seen before" is a
|
||||
/// question the user is asked, where "changed" is a refusal. Knowing the endpoint has pins at all is what
|
||||
/// lets the connection factory tell the two apart; see <c>SshNetConnectionFactory</c>.
|
||||
/// </para>
|
||||
/// <para>The same rule as <see cref="FindAsync"/>: no I/O.</para>
|
||||
/// </remarks>
|
||||
ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Records a host key as trusted.</summary>
|
||||
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
|
||||
|
||||
@@ -156,6 +172,22 @@ public sealed class InMemoryKnownHostStore : IKnownHostStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
IReadOnlyList<HostKeyPresentation> found = pins.Values
|
||||
.Where(pin => KnownHostIdentity.Comparer.Equals(pin.Host, host) && pin.Port == port)
|
||||
.ToArray();
|
||||
|
||||
return ValueTask.FromResult(found);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -523,6 +523,14 @@ public interface IRemoteFileStore : IAsyncDisposable
|
||||
/// <param name="cancellationToken">Abandons the open.</param>
|
||||
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Whether <see cref="OpenWriteAsync"/> can start anywhere but the beginning.</summary>
|
||||
/// <remarks>
|
||||
/// True for SFTP, which is a file system. False for a bucket, where an object is written whole: an
|
||||
/// interrupted upload there starts again rather than resuming, and the transfer queue reads this to know
|
||||
/// — asking for an offset would be refused on every retry, leaving a row that can never finish.
|
||||
/// </remarks>
|
||||
bool CanWriteFromAnOffset => true;
|
||||
|
||||
/// <summary>Creates one directory, whose parent must exist.</summary>
|
||||
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
@@ -187,7 +187,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
|
||||
var endpointPins = await knownHosts
|
||||
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
|
||||
|
||||
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
|
||||
|
||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||
|
||||
@@ -217,17 +223,73 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
return gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The server takes the first algorithm in the client's list that it also supports, so this is what
|
||||
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
|
||||
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
|
||||
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
|
||||
/// may not be, which is exactly the choice it must not be left with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
|
||||
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
|
||||
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
|
||||
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void PreferPinnedAlgorithms(
|
||||
ConnectionInfo info,
|
||||
IReadOnlyList<HostKeyPresentation> pins)
|
||||
{
|
||||
if (pins.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pinned = pins
|
||||
.Select(pin => pin.Algorithm)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var ordered = info.HostKeyAlgorithms
|
||||
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
|
||||
.ToList();
|
||||
|
||||
info.HostKeyAlgorithms.Clear();
|
||||
|
||||
foreach (var algorithm in ordered)
|
||||
{
|
||||
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separate from the connect method because the decision is the security-relevant part and reads
|
||||
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
|
||||
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
|
||||
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
|
||||
/// — so somebody in the middle who simply offered a different key type got the first-use question
|
||||
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
|
||||
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
|
||||
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
|
||||
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
|
||||
/// refusing a server that still has its pinned key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class HostKeyGate(
|
||||
IKnownHostStore knownHosts,
|
||||
SshConnectionRequest request,
|
||||
IReadOnlyList<HostKeyPresentation> endpointPins,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -264,13 +326,21 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
if (pinned is null)
|
||||
if (pinned is null && endpointPins.Count == 0)
|
||||
{
|
||||
// Refused, not prompted. The caller decides, off the handshake thread.
|
||||
e.CanTrust = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
|
||||
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
|
||||
// against the endpoint's first pin. See the class remarks.
|
||||
pinned ??= endpointPins
|
||||
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
|
||||
?.Fingerprint
|
||||
?? endpointPins[0].Fingerprint;
|
||||
|
||||
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
||||
mismatch = !matches;
|
||||
e.CanTrust = matches;
|
||||
|
||||
@@ -350,6 +350,17 @@ internal sealed class SyncStateRow
|
||||
/// decide which edit wins.
|
||||
/// </remarks>
|
||||
public long ServerTimeSkewMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The item types <see cref="Cursor"/> was read under, as this build names them; null before this was kept.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A pull asks for the types this build can apply, and the cursor it gets back is past every change it
|
||||
/// read — including the changes of types it did not ask for. So a build that adds a type to that list
|
||||
/// and carries on from the old cursor never sees the items of that type made before it arrived. Kept
|
||||
/// beside the cursor so a change of list starts the pull over; see <c>SyncEngine.PullAsync</c>.
|
||||
/// </remarks>
|
||||
public string? PulledTypes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
|
||||
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DodoSSH.Client.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClientCacheContext))]
|
||||
[Migration("20261002104920_AddSyncStatePulledTypes")]
|
||||
partial class AddSyncStatePulledTypes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||
.HasName("pk_item");
|
||||
|
||||
b.HasIndex("VaultId", "ChangeSequence")
|
||||
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType")
|
||||
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||
|
||||
b.ToTable("item", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("WrappedKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_key");
|
||||
|
||||
b.HasKey("VaultId", "KeyGeneration")
|
||||
.HasName("pk_vault_key_wrap");
|
||||
|
||||
b.ToTable("vault_key_wrap", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<bool>("Hidden")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("hidden");
|
||||
|
||||
b.Property<bool>("IsPersonal")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_personal");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("permissions");
|
||||
|
||||
b.Property<bool>("RekeyRequired")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("rekey_required");
|
||||
|
||||
b.Property<Guid?>("TeamId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<byte[]>("WrappedVaultKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_vault_key");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_vault");
|
||||
|
||||
b.ToTable("vault", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Acknowledged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("acknowledged");
|
||||
|
||||
b.Property<byte[]>("Detail")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("detail");
|
||||
|
||||
b.Property<long>("DetectedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("detected_at_utc");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_conflict");
|
||||
|
||||
b.HasIndex("VaultId", "Acknowledged")
|
||||
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||
|
||||
b.ToTable("conflict", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<byte?>("AncestorAadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_aad_version");
|
||||
|
||||
b.Property<Guid?>("AncestorDataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ancestor_data_key_id");
|
||||
|
||||
b.Property<uint?>("AncestorKeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_key_generation");
|
||||
|
||||
b.Property<byte[]>("AncestorPayload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_payload");
|
||||
|
||||
b.Property<byte[]>("AncestorProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_protected_fields");
|
||||
|
||||
b.Property<int?>("AncestorVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_version");
|
||||
|
||||
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_wrapped_data_key");
|
||||
|
||||
b.Property<int>("Attempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("attempts");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int?>("ExpectedVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("expected_version");
|
||||
|
||||
b.Property<bool>("IsParked")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_parked");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<Guid>("OperationId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("operation_id");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("QueuedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("queued_at_utc");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_outbox");
|
||||
|
||||
b.HasIndex("OperationId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_operation_id");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||
|
||||
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||
|
||||
b.ToTable("outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_remembered_sign_in");
|
||||
|
||||
b.ToTable("remembered_sign_in", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<string>("Cursor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cursor");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<long?>("LastPulledAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pulled_at_utc");
|
||||
|
||||
b.Property<long?>("LastPushedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pushed_at_utc");
|
||||
|
||||
b.Property<string>("PulledTypes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pulled_types");
|
||||
|
||||
b.Property<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_sync_state");
|
||||
|
||||
b.ToTable("sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("DeviceId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("device_id");
|
||||
|
||||
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("device_wrapped_private_key");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("issuer");
|
||||
|
||||
b.Property<string>("KdfAlgorithm")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("kdf_algorithm");
|
||||
|
||||
b.Property<int>("KdfMemoryKibibytes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_memory_kibibytes");
|
||||
|
||||
b.Property<int>("KdfParallelism")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_parallelism");
|
||||
|
||||
b.Property<int>("KdfPasses")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_passes");
|
||||
|
||||
b.Property<byte[]>("KdfSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("kdf_salt");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<byte[]>("WrappedPrivateKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_private_key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_unlock_material");
|
||||
|
||||
b.ToTable("unlock_material", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSyncStatePulledTypes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "pulled_types",
|
||||
table: "sync_state",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "pulled_types",
|
||||
table: "sync_state");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,10 @@ namespace DodoSSH.Client.Storage.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pushed_at_utc");
|
||||
|
||||
b.Property<string>("PulledTypes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pulled_types");
|
||||
|
||||
b.Property<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
@@ -44,6 +44,14 @@ public sealed record QueuedChange(
|
||||
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
|
||||
/// on the newest state with nothing for the user to arbitrate.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Which is only true if an answer is applied to the operation it answers.</b> A push reads a row,
|
||||
/// sends it, and acts on the reply some time later; a user who saves in between coalesces a new state into
|
||||
/// that same row. So <see cref="CompleteAsync"/>, <see cref="ReviseAsync"/> and <see cref="ParkAsync"/>
|
||||
/// take the operation that was sent and do nothing when the row has since become a different one. Keyed by
|
||||
/// sequence alone, an acknowledgement of the earlier state deleted the row holding the later one, and the
|
||||
/// edit was gone with nothing to say so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OutboxStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
@@ -116,7 +124,7 @@ public sealed class OutboxStore(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<PendingOperation?> ReviseAsync(
|
||||
long sequence,
|
||||
PendingOperation revising,
|
||||
SyncOperation operation,
|
||||
int? expectedVersion,
|
||||
EncryptedPayload? payload,
|
||||
@@ -127,12 +135,18 @@ public sealed class OutboxStore(
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(revising);
|
||||
|
||||
var row = await context.Set<OutboxRow>()
|
||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||
.SingleOrDefaultAsync(
|
||||
r => r.Sequence == revising.Sequence && r.OperationId == revising.OperationId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
// Completed, or replaced by a newer edit since it was read. Either way the merge was of a state
|
||||
// that is no longer the one queued, and the next push will meet the server with the current one.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -233,16 +247,19 @@ public sealed class OutboxStore(
|
||||
|
||||
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
|
||||
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
|
||||
UpdateAsync(sequence, row => row.Attempts++, cancellationToken);
|
||||
UpdateAsync(sequence, operationId: null, row => row.Attempts++, cancellationToken);
|
||||
|
||||
/// <summary>Removes an operation the server accepted.</summary>
|
||||
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken)
|
||||
/// <summary>Removes an operation the server accepted, unless a newer edit has replaced it since.</summary>
|
||||
/// <returns>Whether a row was removed.</returns>
|
||||
public async Task<bool> CompleteAsync(PendingOperation operation, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var removed = await context.Set<OutboxRow>()
|
||||
.Where(r => r.Sequence == sequence)
|
||||
.Where(r => r.Sequence == operation.Sequence && r.OperationId == operation.OperationId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -257,9 +274,10 @@ public sealed class OutboxStore(
|
||||
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
|
||||
/// worse, would block every change queued behind it in a vault the user can still write to.
|
||||
/// </remarks>
|
||||
public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
||||
public Task ParkAsync(PendingOperation operation, string reason, CancellationToken cancellationToken) =>
|
||||
UpdateAsync(
|
||||
sequence,
|
||||
operation.Sequence,
|
||||
operation.OperationId,
|
||||
row =>
|
||||
{
|
||||
row.IsParked = true;
|
||||
@@ -269,7 +287,7 @@ public sealed class OutboxStore(
|
||||
|
||||
/// <summary>Records a transient failure without parking the operation.</summary>
|
||||
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
||||
UpdateAsync(sequence, row => row.LastError = reason, cancellationToken);
|
||||
UpdateAsync(sequence, operationId: null, row => row.LastError = reason, cancellationToken);
|
||||
|
||||
private static Task<OutboxRow?> FindRowAsync(
|
||||
ClientCacheContext context,
|
||||
@@ -302,6 +320,7 @@ public sealed class OutboxStore(
|
||||
|
||||
private async Task UpdateAsync(
|
||||
long sequence,
|
||||
Guid? operationId,
|
||||
Action<OutboxRow> mutate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -309,7 +328,9 @@ public sealed class OutboxStore(
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<OutboxRow>()
|
||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||
.SingleOrDefaultAsync(
|
||||
r => r.Sequence == sequence && (operationId == null || r.OperationId == operationId),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
|
||||
@@ -183,13 +183,15 @@ public sealed record PendingOperation(
|
||||
/// <param name="LastPulledAt">When the last pull completed.</param>
|
||||
/// <param name="LastPushedAt">When the last push completed.</param>
|
||||
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
|
||||
/// <param name="PulledTypes">The item types the cursor was read under; see <c>SyncStateRow.PulledTypes</c>.</param>
|
||||
public sealed record StoredSyncState(
|
||||
Guid VaultId,
|
||||
string? Cursor,
|
||||
uint KeyGeneration,
|
||||
DateTimeOffset? LastPulledAt = null,
|
||||
DateTimeOffset? LastPushedAt = null,
|
||||
long ServerTimeSkewMs = 0);
|
||||
long ServerTimeSkewMs = 0,
|
||||
string? PulledTypes = null);
|
||||
|
||||
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
|
||||
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
|
||||
|
||||
@@ -46,7 +46,8 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
|
||||
row.KeyGeneration,
|
||||
row.LastPulledAtUtc,
|
||||
row.LastPushedAtUtc,
|
||||
row.ServerTimeSkewMs);
|
||||
row.ServerTimeSkewMs,
|
||||
row.PulledTypes);
|
||||
}
|
||||
|
||||
/// <summary>Records a vault's position.</summary>
|
||||
@@ -72,6 +73,7 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
|
||||
row.LastPulledAtUtc = state.LastPulledAt;
|
||||
row.LastPushedAtUtc = state.LastPushedAt;
|
||||
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
|
||||
row.PulledTypes = state.PulledTypes;
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
{
|
||||
return pending.Operation == SyncOperation.Delete
|
||||
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
|
||||
? outbox.CompleteAsync(pending.Sequence, cancellationToken)
|
||||
? outbox.CompleteAsync(pending, cancellationToken)
|
||||
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
}
|
||||
|
||||
await outbox.ReviseAsync(
|
||||
pending.Sequence,
|
||||
pending,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion: null,
|
||||
kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1),
|
||||
@@ -212,7 +212,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
{
|
||||
// Field for field the same item: this is our own create coming back. Nothing to do but stop
|
||||
// trying to send it again.
|
||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Now the tombstone can stand.
|
||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
@@ -385,7 +385,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
@@ -413,7 +413,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
var nextVersion = SyncVersions.NextVersion(remote.Version);
|
||||
|
||||
await outbox.ReviseAsync(
|
||||
pending.Sequence,
|
||||
pending,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion: remote.Version,
|
||||
kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion),
|
||||
@@ -486,7 +486,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await outbox.ParkAsync(
|
||||
pending.Sequence,
|
||||
pending,
|
||||
$"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -512,7 +512,7 @@ internal sealed class ItemReconciler<TSecret>(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await outbox.ParkAsync(
|
||||
pending.Sequence,
|
||||
pending,
|
||||
$"The local or the server copy of this {kind.Noun} could not be decrypted.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ public sealed class SyncEngine
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
var state = await ReadStateAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// At most one restart per pull. A server that rejects the cursor it has just issued is not
|
||||
// telling this client anything it can act on, and replaying the whole log against it would turn
|
||||
@@ -245,6 +245,26 @@ public sealed class SyncEngine
|
||||
/// the beginning as well, rather than meeting the same refusal again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
||||
/// <remarks>
|
||||
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
|
||||
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
|
||||
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
|
||||
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
|
||||
/// starts over once for the same reason: it may already have missed some.
|
||||
/// </remarks>
|
||||
private async Task<StoredSyncState> ReadStateAsync(
|
||||
Guid vaultId,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return state.Cursor is not null && !string.Equals(state.PulledTypes, PulledTypes, StringComparison.Ordinal)
|
||||
? await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false)
|
||||
: state;
|
||||
}
|
||||
|
||||
private async Task<StoredSyncState> StartOverAsync(
|
||||
StoredSyncState state,
|
||||
SyncReportBuilder report,
|
||||
@@ -282,9 +302,14 @@ public sealed class SyncEngine
|
||||
KeyGeneration = response.CurrentKeyGeneration,
|
||||
LastPulledAt = now,
|
||||
ServerTimeSkewMs = skew,
|
||||
PulledTypes = PulledTypes,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The types this build pulls, in a form a stored state can be compared against.</summary>
|
||||
private static string PulledTypes { get; } =
|
||||
string.Join(',', ItemKinds.SyncedTypes.Select(type => (int)type).Order());
|
||||
|
||||
private async Task ApplyAsync(
|
||||
Guid vaultId,
|
||||
SyncChange change,
|
||||
@@ -446,7 +471,7 @@ public sealed class SyncEngine
|
||||
}
|
||||
|
||||
await outbox.ReviseAsync(
|
||||
operation.Sequence,
|
||||
operation,
|
||||
operation.Operation,
|
||||
operation.ExpectedVersion,
|
||||
resealed,
|
||||
@@ -551,7 +576,76 @@ public sealed class SyncEngine
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
var completed = await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!completed && canMirror && !isDelete)
|
||||
{
|
||||
await RebaseOntoAcceptedAsync(vaultId, operation, version, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves an edit made while <paramref name="accepted"/> was in flight onto the version that push produced.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The user saved again between the push being read and its answer arriving, so the row now holds a
|
||||
/// later state branched from the same base as the one the server just took. Left alone it is still the
|
||||
/// user's edit and still goes — but it goes expecting the old version, comes back <c>Conflict</c>, and is
|
||||
/// merged against an ancestor that predates the save the server already has, which makes every field
|
||||
/// changed twice look like a disagreement with somebody else. Its true parent is the state just accepted,
|
||||
/// so that becomes its ancestor and expected version, and its payload is re-sealed at the version it
|
||||
/// will now produce, because the AAD binds that.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only for a row still branched from the same base. One that is not has been revised by something else
|
||||
/// since, and the ordinary conflict path is the one that knows what to do with it; so is a row whose
|
||||
/// payload this session cannot re-seal.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RebaseOntoAcceptedAsync(
|
||||
Guid vaultId,
|
||||
PendingOperation accepted,
|
||||
int version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var later = await outbox
|
||||
.FindAsync(vaultId, accepted.EntityType, accepted.EntityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (later is null || later.ExpectedVersion != accepted.ExpectedVersion || accepted.Payload is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EncryptedPayload? payload = null;
|
||||
|
||||
if (later.Payload is { } queued)
|
||||
{
|
||||
payload = PayloadReseal.TryReseal(
|
||||
keyring,
|
||||
vaultId,
|
||||
later.EntityType,
|
||||
later.EntityId,
|
||||
queued,
|
||||
openAtVersion: SyncVersions.NextVersion(later.ExpectedVersion),
|
||||
sealAtVersion: SyncVersions.NextVersion(version));
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await outbox.ReviseAsync(
|
||||
later,
|
||||
later.Operation,
|
||||
version,
|
||||
payload,
|
||||
later.Fields,
|
||||
new StoredAncestor(version, accepted.Payload, accepted.Fields),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> ResolveAsync(
|
||||
@@ -617,7 +711,7 @@ public sealed class SyncEngine
|
||||
if (operation.Operation == SyncOperation.Delete)
|
||||
{
|
||||
// Nothing there to delete, so the intent is already satisfied.
|
||||
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -645,7 +739,7 @@ public sealed class SyncEngine
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await outbox.ParkAsync(operation.Sequence, reason, cancellationToken).ConfigureAwait(false);
|
||||
await outbox.ParkAsync(operation, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
|
||||
@@ -327,7 +327,7 @@ internal sealed class VaultItemRepository<TSecret>(
|
||||
|
||||
if (pending is not null && NeverReachedTheServer(pending))
|
||||
{
|
||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
await outbox.CompleteAsync(pending, 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
|
||||
|
||||
@@ -56,6 +56,16 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
private const int MaximumRequestBytes = 16 * 1024;
|
||||
private const int ReceiveBufferBytes = 64 * 1024;
|
||||
|
||||
/// <summary>The largest message from the page this host will reassemble.</summary>
|
||||
/// <remarks>
|
||||
/// A paste is one input frame however long it is, and so is a copied selection, so the receive buffer
|
||||
/// is the size of a read and not the size of a message. Sixteen megabytes is far beyond anything a
|
||||
/// person pastes into a shell on purpose and still small enough that a page sending garbage cannot make
|
||||
/// this process hold an unbounded amount of it. A message beyond it is dropped whole — never its tail,
|
||||
/// which would be parsed as a frame whose first bytes are somebody's paste.
|
||||
/// </remarks>
|
||||
private const int MaximumMessageBytes = 16 * 1024 * 1024;
|
||||
|
||||
private readonly TcpListener listener;
|
||||
private readonly ITerminalAssetProvider assets;
|
||||
private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
|
||||
@@ -451,6 +461,11 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
{
|
||||
var buffer = new byte[ReceiveBufferBytes];
|
||||
|
||||
// Grown only when a message spans more than one read, and kept for the next one that does.
|
||||
var message = Array.Empty<byte>();
|
||||
var length = 0;
|
||||
var oversized = false;
|
||||
|
||||
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var result = await webSocket
|
||||
@@ -462,14 +477,73 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.EndOfMessage)
|
||||
// The common case — a keystroke, an acknowledgement — is one read, and goes straight through.
|
||||
if (result.EndOfMessage && length == 0 && !oversized)
|
||||
{
|
||||
// A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
|
||||
// so this is either a bug or something hostile; dropping is the safe response.
|
||||
DispatchSafely(buffer.AsSpan(0, result.Count));
|
||||
continue;
|
||||
}
|
||||
|
||||
Dispatch(buffer.AsSpan(0, result.Count));
|
||||
if (!oversized && length + result.Count <= MaximumMessageBytes)
|
||||
{
|
||||
if (length + result.Count > message.Length)
|
||||
{
|
||||
Array.Resize(ref message, Math.Min(MaximumMessageBytes, Math.Max(message.Length * 2, length + result.Count)));
|
||||
}
|
||||
|
||||
buffer.AsSpan(0, result.Count).CopyTo(message.AsSpan(length));
|
||||
length += result.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
oversized = true;
|
||||
}
|
||||
|
||||
if (!result.EndOfMessage)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!oversized)
|
||||
{
|
||||
DispatchSafely(message.AsSpan(0, length));
|
||||
}
|
||||
|
||||
length = 0;
|
||||
oversized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Dispatches one frame, so that what goes wrong with one session stays with that session.</summary>
|
||||
/// <remarks>
|
||||
/// Every session shares this socket, and an exception escaping the receive loop closes it — so a resize
|
||||
/// that reached a shell SSH.NET had already disposed, which is an ordinary moment at the end of every
|
||||
/// session, used to drop every other tab to "reconnecting" and lose the output sent meanwhile. A frame
|
||||
/// that cannot be acted on is a frame not acted on; the session it was for reports its own ending.
|
||||
/// </remarks>
|
||||
private void DispatchSafely(ReadOnlySpan<byte> frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
Dispatch(frame);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException)
|
||||
{
|
||||
// See the remarks.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes one input frame, without letting a session that is going away fault anything.</summary>
|
||||
private async Task WriteInputAsync(TerminalSessionPump pump, byte[] input)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pump.WriteInputAsync(input, lifetime.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The shell ended or the connection dropped between the keystroke and the write. The session's
|
||||
// own read loop reports that; a keystroke that had nowhere to go has nothing to add.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +576,7 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
||||
case TerminalClientOpcode.Input:
|
||||
// Fire and forget: a blocked write must not stall acknowledgements for other sessions
|
||||
// sharing this socket.
|
||||
_ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
|
||||
_ = WriteInputAsync(pump, payload.ToArray());
|
||||
break;
|
||||
|
||||
case TerminalClientOpcode.Acknowledge:
|
||||
|
||||
@@ -107,6 +107,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
private readonly OutputBacklog backlog;
|
||||
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
/// <summary>One write to the remote at a time, in the order they were asked for.</summary>
|
||||
/// <remarks>
|
||||
/// The data plane hands each input frame over without waiting for the one before it — a blocked write
|
||||
/// must not hold up the acknowledgements of every other session on the socket — so without this, two
|
||||
/// frames' writes run at once. SSH.NET's <c>ShellStream</c> takes no lock around its write buffer, and its
|
||||
/// flush sends what is there and then empties it by length: bytes added mid-flush are discarded unsent.
|
||||
/// A snippet's RUN is its text and then a carriage return as two frames, which is exactly the shape that lost or
|
||||
/// reordered keystrokes. Waiters on a <see cref="SemaphoreSlim"/> are released in the order they arrived,
|
||||
/// and the data plane calls this from one receive loop, so arrival order is the order typed.
|
||||
/// </remarks>
|
||||
private readonly SemaphoreSlim inputGate = new(1, 1);
|
||||
|
||||
private int disposed;
|
||||
|
||||
/// <param name="sessionId">Identifies this session in every frame.</param>
|
||||
@@ -267,9 +280,20 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwards keystrokes to the remote.</summary>
|
||||
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
||||
session.WriteAsync(data, cancellationToken);
|
||||
/// <summary>Forwards keystrokes to the remote, after any still being written.</summary>
|
||||
public async ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||
{
|
||||
await inputGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await session.WriteAsync(data, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
inputGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tells the remote the terminal was resized.</summary>
|
||||
public void Resize(TerminalSize size) => session.Resize(size);
|
||||
|
||||
@@ -701,7 +701,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
|
||||
var part = destination + PartSuffix;
|
||||
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
var offset = ResumableLength(entry, existing);
|
||||
|
||||
// A bucket writes an object whole, so a part left by a failed attempt is started over rather than
|
||||
// resumed — and replaced, since writing an object replaces it.
|
||||
var offset = session.CanWriteFromAnOffset ? ResumableLength(entry, existing) : 0;
|
||||
|
||||
if (offset == 0 && existing is not null)
|
||||
{
|
||||
|
||||
@@ -94,6 +94,49 @@ public static class LocalDirectory
|
||||
/// </remarks>
|
||||
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
|
||||
|
||||
/// <summary>
|
||||
/// Where a file another machine named should land inside <paramref name="directory"/>, or null when that
|
||||
/// name cannot be a file directly inside it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The name is the remote's, and the remote is not trusted to have chosen one that stays put.</b>
|
||||
/// A POSIX file name may contain a backslash, a colon or a leading drive letter, and on Windows any of
|
||||
/// those turns <c>Path.Combine(directory, name)</c> into somewhere else: <c>..\..\AppData\…\Startup\x.bat</c>
|
||||
/// climbs out, and <c>C:\…</c> is rooted, so <see cref="Path.Combine(string, string)"/> drops the
|
||||
/// directory altogether. An S3 key is worse, because <c>../</c> is an ordinary prefix there on every
|
||||
/// platform. Whoever can write a name into a shared directory or bucket would otherwise choose where a
|
||||
/// download writes on this machine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Characters this platform cannot put in a file name are replaced rather than refused, so a Linux file
|
||||
/// called <c>backup:2026-10-02</c> still downloads to Windows, as <c>backup_2026-10-02</c>. What is
|
||||
/// refused is a name that is nothing but a reference to here or above. The final check resolves the path
|
||||
/// and requires its parent to be the directory, which also catches what Windows does to a trailing dot
|
||||
/// or space — <c>.. </c> is <c>..</c> by the time it reaches the disk.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static string? ChildPath(string directory, string name)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(directory);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
|
||||
var cleaned = string.Join('_', name.Split(Path.GetInvalidFileNameChars()));
|
||||
|
||||
if (cleaned.Trim() is "" or "." or "..")
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||
var child = Path.GetFullPath(Path.Combine(root, cleaned));
|
||||
|
||||
return string.Equals(Path.GetDirectoryName(child), root, StringComparison.Ordinal)
|
||||
&& !string.Equals(child, root, StringComparison.Ordinal)
|
||||
? child
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
||||
/// </summary>
|
||||
|
||||
@@ -28,6 +28,9 @@ public static class ProblemCodes
|
||||
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
|
||||
public const string EnrollmentRequired = "enrollment-required";
|
||||
|
||||
/// <summary>The caller's account has been suspended or deprovisioned by an operator.</summary>
|
||||
public const string AccountNotActive = "account-not-active";
|
||||
|
||||
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
|
||||
public const string AlreadyEnrolled = "already-enrolled";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable enable
|
||||
const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.AccountNotActive = "account-not-active" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.EventsUnavailable = "events-unavailable" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
|
||||
|
||||
@@ -192,6 +192,69 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
|
||||
user.EnrolledAtUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Suspended means "sign-in blocked", and nothing used to block it: the identity provider still vouches
|
||||
/// for the person, and every endpoint resolved them by issuer and subject alone. The status is this
|
||||
/// server's decision, so this server has to be the one refusing — on its own endpoints and on vaults.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASuspendedAccount_IsRefusedEverywhereWithACodeSayingWhy()
|
||||
{
|
||||
using var enrollment = NewEnrollment();
|
||||
var client = enrollment.CreateClient(fixture);
|
||||
|
||||
await EnrollAsync(client, enrollment.Build());
|
||||
|
||||
await using (var scope = fixture.CreateScope())
|
||||
{
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
await database.Users
|
||||
.Where(u => u.Subject == enrollment.Subject)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(u => u.Status, UserStatus.Suspended));
|
||||
}
|
||||
|
||||
await ShouldBeProblemAsync(
|
||||
await client.GetAsync(new Uri(MeUrl, UriKind.Relative)),
|
||||
HttpStatusCode.Forbidden,
|
||||
ProblemCodes.AccountNotActive);
|
||||
|
||||
await ShouldBeProblemAsync(
|
||||
await client.PostContractAsync(
|
||||
$"/api/v1/vaults/{enrollment.VaultId}/sync/pull", new SyncPullRequest(null, null, null)),
|
||||
HttpStatusCode.Forbidden,
|
||||
ProblemCodes.AccountNotActive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The last-seen stamp is written past the change tracker once an hour, which moves the row's
|
||||
/// concurrency token under the entity the request is holding. Enrolment then saved that same user and
|
||||
/// failed on the stale token — so anyone who signed in, left, and came back an hour later to finish
|
||||
/// setting up got a 500 on their first try.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Enroll_MoreThanAnHourAfterFirstSigningIn_Succeeds()
|
||||
{
|
||||
using var enrollment = NewEnrollment();
|
||||
var client = enrollment.CreateClient(fixture);
|
||||
|
||||
(await client.GetAsync(new Uri(MeUrl, UriKind.Relative))).EnsureSuccessStatusCode();
|
||||
|
||||
await using (var scope = fixture.CreateScope())
|
||||
{
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
await database.Users
|
||||
.Where(u => u.Subject == enrollment.Subject)
|
||||
.ExecuteUpdateAsync(setters => setters.SetProperty(
|
||||
u => u.LastSeenAtUtc, TimeProvider.System.GetUtcNow().AddHours(-2)));
|
||||
}
|
||||
|
||||
var response = await EnrollAsync(client, enrollment.Build());
|
||||
|
||||
response.KeyGeneration.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enroll_AppendsToTheKeyLogAndTheEntryReproducesItsOwnHash()
|
||||
{
|
||||
|
||||
@@ -276,6 +276,52 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
||||
second.Changes.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An item edited several times is pulled once, at the revision its ciphertext is sealed at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The table holds only the newest payload, and its AAD binds the newest version. Hydrating each older
|
||||
/// log entry with it paired revision 1 or 2 with a payload sealed at 3, which no client can open — and a
|
||||
/// client that could not open it parked its own pending edit as undecryptable.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Pull_OfAnItemEditedSinceItsEarlierEntries_SendsOnlyItsCurrentRevision()
|
||||
{
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var create = NewCreateBatch();
|
||||
var entityId = create.Operations[0].EntityId;
|
||||
await client.PostContractAsync(PushUrl(vaultId), create);
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]),
|
||||
]));
|
||||
|
||||
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
|
||||
[
|
||||
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
|
||||
]));
|
||||
|
||||
var pulled = await (await client.PostContractAsync(
|
||||
PullUrl(vaultId),
|
||||
new SyncPullRequest(null, null, null))).Content.ReadContractAsync<SyncPullResponse>();
|
||||
|
||||
pulled.ShouldNotBeNull();
|
||||
|
||||
var change = pulled.Changes.ShouldHaveSingleItem();
|
||||
change.Version.ShouldBe(3);
|
||||
change.Payload!.Envelope.ShouldBe([3, 3, 3]);
|
||||
|
||||
// And the cursor is past all three entries, so the next pull does not offer the old ones again.
|
||||
var next = await (await client.PostContractAsync(
|
||||
PullUrl(vaultId),
|
||||
new SyncPullRequest(pulled.NextCursor, null, null))).Content.ReadContractAsync<SyncPullResponse>();
|
||||
|
||||
next!.Changes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pull_WithACursorFromAnotherVault_Is400()
|
||||
{
|
||||
|
||||
@@ -368,6 +368,42 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
.ShouldNotContain(row => row.TeamId == team.TeamId, "the team existed to carry that vault");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The archive endpoint refuses an admin, and deleting a team's last vault archives the team too — so
|
||||
/// without the same rule there, an admin could archive the team out from under its owner one vault at a
|
||||
/// time, revoking the owner's membership on the way. The delete is theirs to make; the team is not.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAdminDeletingTheLastVault_DeletesItButLeavesTheTeamToItsOwner()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("vault-delete-admin-owner", "vdaowner@example.com");
|
||||
var admin = await EnrolledClientAsync("vault-delete-admin-admin", "vdaadmin@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Owned");
|
||||
var vaultId = await CreateVaultAsync(owner, team.TeamId);
|
||||
var entry = await LookupAsync(owner, "vdaadmin@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);
|
||||
|
||||
await owner.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/grants", GrantRequest(entry, generation: 1));
|
||||
|
||||
(await DeleteAsync(admin, VaultUrl(vaultId))).StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
|
||||
(await ReadAsync<MeResponse>(owner, MeUrl)).Vaults
|
||||
.ShouldNotContain(vault => vault.VaultId == vaultId);
|
||||
|
||||
(await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
|
||||
.ShouldContain(row => row.TeamId == team.TeamId, "only the owner archives a team");
|
||||
|
||||
var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
|
||||
|
||||
(await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId)))
|
||||
.Where(row => row.UserId == ownerMe.UserId)
|
||||
.ShouldHaveSingleItem()
|
||||
.Role.ShouldBe(TeamMemberRole.Owner);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A team carrying several vaults keeps its own life when one of them goes, which is the same line the
|
||||
/// rename draws — and the other vault has to survive, which is the assertion that a deletion aimed at
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using DodoSSH.Client.App.Views;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Linux device-key PIN window, which no other test constructs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It named two palette resources that have never existed, <c>Surface</c> and <c>MutedText</c>, and a
|
||||
/// <c>StaticResource</c> that cannot be found throws when the window is built — so on the one platform that
|
||||
/// shows it, registering or unlocking with the TPM failed before any PIN was asked for. Only constructing it
|
||||
/// against the real palette can catch that, which is what the harness's application provides.
|
||||
/// </remarks>
|
||||
public sealed class DevicePinWindowTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task TheWindowBuildsAgainstThePalette()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var window = new DevicePinWindow();
|
||||
|
||||
try
|
||||
{
|
||||
LayoutHarness.Settle(window, window.Width, 240);
|
||||
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>DeviceKeyNames</c>: which hardware key a profile's device unlock is sealed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The stores themselves cannot be exercised here — every call past the probe raises a Windows Hello or
|
||||
/// Touch ID dialog — so the rule that keeps two installations off one key is tested on its own. It is the
|
||||
/// whole of what stops signing out of the nightly deleting the key the release build unlocks with.
|
||||
/// </remarks>
|
||||
public sealed class DeviceKeyNamesTests
|
||||
{
|
||||
private const string Legacy = "DodoSSH.DeviceKey.v1";
|
||||
|
||||
[Fact]
|
||||
public void TheDefaultProfile_KeepsTheNameEveryExistingInstallIsSealedTo() =>
|
||||
DeviceKeyNames.For(ClientPaths.Default, Legacy).ShouldBe(Legacy);
|
||||
|
||||
[Fact]
|
||||
public void TheNightlyProfile_HasAKeyOfItsOwn()
|
||||
{
|
||||
var nightly = DeviceKeyNames.For(ClientPaths.ForChannel("nightly"), Legacy);
|
||||
|
||||
nightly.ShouldNotBe(Legacy);
|
||||
nightly.ShouldStartWith(Legacy + ".");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoProfilesWhoseFoldersShareAName_HaveDifferentKeys()
|
||||
{
|
||||
var first = new ClientPaths(Path.Combine(Path.GetTempPath(), "one", "DodoSSH"));
|
||||
var second = new ClientPaths(Path.Combine(Path.GetTempPath(), "two", "DodoSSH"));
|
||||
|
||||
DeviceKeyNames.For(first, Legacy).ShouldNotBe(DeviceKeyNames.For(second, Legacy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneProfile_AlwaysNamesTheSameKey()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "portable", "DodoSSH");
|
||||
|
||||
DeviceKeyNames.For(new ClientPaths(directory), Legacy)
|
||||
.ShouldBe(DeviceKeyNames.For(new ClientPaths(directory + Path.DirectorySeparatorChar), Legacy));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>LocalEditor</c>: a remote file opened for editing is never run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The file's name was chosen by whoever can write to the host, so the question for every name here is
|
||||
/// whether the desktop's default action would execute it. Only the Windows rule is exercised directly — it
|
||||
/// is a pure function of the name and <c>PATHEXT</c> — while what each platform starts is checked on the
|
||||
/// platform the suite is running on.
|
||||
/// </remarks>
|
||||
public sealed class LocalEditorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("deploy.bat")]
|
||||
[InlineData("DEPLOY.CMD")]
|
||||
[InlineData("notes.hta")]
|
||||
[InlineData("setup.js")]
|
||||
[InlineData("link.lnk")]
|
||||
[InlineData("run.ps1")]
|
||||
[InlineData("settings.reg")]
|
||||
[InlineData("deploy.bat.")]
|
||||
[InlineData("deploy.bat ")]
|
||||
public void AFileWindowsWouldRun_IsRecognised(string name) =>
|
||||
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeTrue();
|
||||
|
||||
[Theory]
|
||||
[InlineData("nginx.conf")]
|
||||
[InlineData("authorized_keys")]
|
||||
[InlineData(".bashrc")]
|
||||
[InlineData("app.json")]
|
||||
[InlineData("bat")]
|
||||
public void AnOrdinaryConfigFile_IsLeftToItsAssociation(string name) =>
|
||||
LocalEditor.WouldRunOnWindows(name, pathExt: string.Empty).ShouldBeFalse();
|
||||
|
||||
[Fact]
|
||||
public void WhateverPathExtAdds_IsTreatedAsRunnableToo() =>
|
||||
LocalEditor.WouldRunOnWindows("tool.foo", pathExt: ".COM;.EXE;.FOO").ShouldBeTrue();
|
||||
|
||||
[Fact]
|
||||
public void ARunnableFile_IsNeverHandedToTheDefaultOpenAction()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "deploy.bat");
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
// No portable editor to name, and a .bat is not something a Linux desktop runs.
|
||||
LocalEditor.StartInfoFor(path).UseShellExecute.ShouldBeTrue();
|
||||
Should.Throw<InvalidOperationException>(() => LocalEditor.StartInfoFor("/tmp/app.desktop"));
|
||||
return;
|
||||
}
|
||||
|
||||
var start = LocalEditor.StartInfoFor(OperatingSystem.IsMacOS() ? "/tmp/run.command" : path);
|
||||
|
||||
// Either the type's own "edit" verb, or a named editor with the file as an argument — never the
|
||||
// file itself under its default verb.
|
||||
(start.Verb == "edit" || !start.UseShellExecute).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOrdinaryFile_StillOpensWithItsAssociation()
|
||||
{
|
||||
var start = LocalEditor.StartInfoFor(Path.Combine(Path.GetTempPath(), "nginx.conf"));
|
||||
|
||||
start.UseShellExecute.ShouldBeTrue();
|
||||
start.Verb.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1871,6 +1871,31 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
/// connect and restored afterwards — and the dial itself goes through the ordinary connect path, so the
|
||||
/// host key gate and the log entry are the ones every other connection gets.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// A sync pass reloads the vault, which empties its host list and fills it again — and the picker used to
|
||||
/// re-find its selection from the empty moment, landing on the first host every minute. CONNECT then
|
||||
/// dialled that host with the password typed for the one chosen.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheFilesHostPicker_KeepsTheChosenHostAndItsPasswordAcrossAVaultReload()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await AddHostAsync(vault, "staging-db");
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
|
||||
var chosen = shell.Transfers.Hosts.Single(row => row.Host.Label == "staging-db");
|
||||
shell.Transfers.SelectedHost = chosen;
|
||||
shell.Transfers.TypedPassword = "typed for staging";
|
||||
|
||||
shell.Transfers.Hosts[0].ShouldNotBe(chosen, "otherwise falling back to the first proves nothing");
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
shell.Transfers.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(chosen.EntityId);
|
||||
shell.Transfers.TypedPassword.ShouldBe("typed for staging");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReconnectingALostFileSession_DialsAgainAndGoesBackToTheDirectoryItWasShowing()
|
||||
{
|
||||
@@ -5705,6 +5730,16 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
|
||||
.IsSelected.ShouldBeTrue();
|
||||
|
||||
// A keychain busy with something else — a sync pass, a save — used to skip the whole batch and let this
|
||||
// screen say "Imported 0 host(s)" as though that were the result.
|
||||
vault.IsBusy = true;
|
||||
|
||||
await import.ImportCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsBusy = false;
|
||||
vault.Hosts.Count.ShouldBe(1);
|
||||
import.Status.ShouldContain("Nothing was imported");
|
||||
|
||||
await import.ImportCommand.ExecuteAsync(null);
|
||||
|
||||
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
||||
@@ -10628,6 +10663,35 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// LOCK NOW is on the Preferences page. Settings mode hides the main title bar and draws its own, which
|
||||
/// the lock card then covers — so a lock that stayed in settings mode left a window with nothing to drag,
|
||||
/// minimise or close, and an unlock that came back to Settings rather than to Hosts.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LockingFromSettings_LeavesSettingsModeAndUnlockingLandsOnHosts()
|
||||
{
|
||||
await ReadyToConnectAsync();
|
||||
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults[0]);
|
||||
shell.Vaults.IsMembersPanelOpen.ShouldBeTrue("otherwise its closing proves nothing");
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.State.ShouldBe(ShellState.Locked);
|
||||
shell.IsSettingsMode.ShouldBeFalse("the lock card needs the main title bar above it");
|
||||
shell.Vaults.IsMembersPanelOpen.ShouldBeFalse();
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsSettingsMode.ShouldBeFalse();
|
||||
shell.Screen.ShouldBe(ShellScreen.Hosts);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
|
||||
/// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
|
||||
|
||||
@@ -131,6 +131,35 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
/// SFTP fake that a real connect produces.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// A name the host chose cannot choose where on this machine the download lands.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Backslashes and colons are ordinary in a POSIX file name and are separators and drive letters on
|
||||
/// Windows, and a slash arrives in a name from an S3 key — so every one of these would have escaped the
|
||||
/// folder through <c>Path.Combine</c> somewhere. They are queued inside it under a cleaned name, and the
|
||||
/// bare <c>..</c>, which no cleaning can make a file name, is left on the host and said so.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ADownloadNamedToEscapeTheFolder_LandsInsideItOrNotAtAll()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.QueueDownloads(
|
||||
[
|
||||
RemoteFile(@"..\..\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\x.bat"),
|
||||
RemoteFile(@"C:\Windows\evil.dll"),
|
||||
RemoteFile("../../escaped.txt"),
|
||||
RemoteFile(".."),
|
||||
]);
|
||||
|
||||
var landed = Queued().Select(row => row.Transfer.LocalPath).ToList();
|
||||
|
||||
landed.Count.ShouldBe(3);
|
||||
landed.ShouldAllBe(path => Path.GetDirectoryName(path) == directory);
|
||||
transfers.Status.ShouldContain("1 name points outside the folder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
|
||||
{
|
||||
|
||||
@@ -2177,10 +2177,70 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A vault chosen while anything else on the screen was running — a reload, a share — went through a busy
|
||||
/// gate that answered by doing nothing, so its people were never read and the panel kept showing the
|
||||
/// previous vault's under the new one's name. SHARE and REVOKE then acted on that mixture.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AVaultChosenWhileAReloadIsRunning_ShowsItsOwnPeople()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
server.AddAccount("bob@example.com", "Bob Example");
|
||||
|
||||
await CreateVaultAsync(vaults, "Alone");
|
||||
var alone = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await CreateVaultAsync(vaults, "Shared with Bob");
|
||||
var shared = vaults.SelectedVault!.VaultId;
|
||||
|
||||
vaults.NewMemberEmail = "bob@example.com";
|
||||
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||
|
||||
vaults.SelectedVault = vaults.Vaults.Single(row => row.VaultId == alone);
|
||||
await EventuallyAsync(() => vaults.Members.Count == 1, "the vault on its own has only its owner");
|
||||
|
||||
var gate = new TaskCompletionSource();
|
||||
server.MemberReadGate = gate;
|
||||
|
||||
var reload = vaults.LoadAsync(Token);
|
||||
|
||||
vaults.IsBusy.ShouldBeTrue("the reload is what holds the screen busy while its read is held");
|
||||
|
||||
vaults.SelectedVault = vaults.Vaults.Single(row => row.VaultId == shared);
|
||||
|
||||
gate.SetResult();
|
||||
server.MemberReadGate = null;
|
||||
await reload;
|
||||
|
||||
await EventuallyAsync(() => vaults.Members.Count == 2, "Bob is in the vault now showing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Through the form rather than straight at the command, because the name is what the form is for —
|
||||
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
|
||||
/// </remarks>
|
||||
/// <summary>Waits for something a fire-and-forget read will make true, within a bound.</summary>
|
||||
private static async Task EventuallyAsync(Func<bool> condition, string because)
|
||||
{
|
||||
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
|
||||
|
||||
while (TimeProvider.System.GetUtcNow() < deadline)
|
||||
{
|
||||
if (condition())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
|
||||
}
|
||||
|
||||
throw new ShouldAssertException(because);
|
||||
}
|
||||
|
||||
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
|
||||
private static string PrivateKey(string body) =>
|
||||
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A pin is a pin for the endpoint, whichever key type the server answers with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pins are stored per algorithm, and the gap these close was that an endpoint whose only pins were for
|
||||
/// other algorithms read as a host never seen before. Somebody in the middle needed nothing more than a key
|
||||
/// of a different type to turn the hard refusal a pin exists for into a first-use question the user is
|
||||
/// invited to click through.
|
||||
/// </remarks>
|
||||
[Collection(SshCollection.Name)]
|
||||
public sealed class HostKeyPinningTests(SshServerFixture fixture)
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>
|
||||
/// The endpoint is pinned under a key type this server does not have, which is exactly what a server in
|
||||
/// the middle looks like from here: the pinned key is not on offer, and a key of another type is.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AKeyOfAnotherTypeOnAPinnedEndpoint_IsAMismatchRatherThanAnUnknownHost()
|
||||
{
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
|
||||
await knownHosts.TrustAsync(
|
||||
new HostKeyPresentation(fixture.Host, fixture.Port, "ssh-dss", "SHA256:the-key-that-was-approved"),
|
||||
Token);
|
||||
|
||||
var mismatch = await Should.ThrowAsync<SshHostKeyMismatchException>(async () =>
|
||||
await new SshNetConnectionFactory(knownHosts).ConnectAsync(Request(), progress: null, Token));
|
||||
|
||||
mismatch.PinnedFingerprint.ShouldBe("SHA256:the-key-that-was-approved");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same public key under another algorithm's name — an RSA key signs as <c>rsa-sha2-256</c> or
|
||||
/// <c>rsa-sha2-512</c> — is the key that was approved, and its fingerprint is what says so.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TheApprovedKeyUnderAnotherAlgorithmName_IsStillTrusted()
|
||||
{
|
||||
var learned = new InMemoryKnownHostStore();
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await new SshNetConnectionFactory(learned).ConnectAsync(Request(), progress: null, Token));
|
||||
|
||||
var knownHosts = new InMemoryKnownHostStore();
|
||||
|
||||
await knownHosts.TrustAsync(unknown.Presentation with { Algorithm = "x-renamed-algorithm" }, Token);
|
||||
|
||||
await using var connection = await new SshNetConnectionFactory(knownHosts)
|
||||
.ConnectAsync(Request(), progress: null, Token);
|
||||
|
||||
connection.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnEndpointWithNoPinsAtAll_IsStillAnUnknownHost() =>
|
||||
await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
|
||||
.ConnectAsync(Request(), progress: null, Token));
|
||||
|
||||
private SshConnectionRequest Request() => new(
|
||||
fixture.Host,
|
||||
fixture.Port,
|
||||
SshServerFixture.Username,
|
||||
new SshPasswordCredential(SshServerFixture.Password));
|
||||
}
|
||||
@@ -173,7 +173,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
|
||||
var merged = new StoredAncestor(4, Payload(seed: 20), new SyncPlaintextFields());
|
||||
|
||||
var revised = await harness.Outbox.ReviseAsync(
|
||||
queued.Sequence,
|
||||
queued,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion: 4,
|
||||
Payload(seed: 30),
|
||||
@@ -199,7 +199,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
|
||||
var queued = await harness.Outbox.QueueAsync(Change(parked), Token);
|
||||
await harness.Outbox.QueueAsync(Change(healthy), Token);
|
||||
|
||||
await harness.Outbox.ParkAsync(queued.Sequence, "Entity type not supported.", Token);
|
||||
await harness.Outbox.ParkAsync(queued, "Entity type not supported.", Token);
|
||||
|
||||
var pending = await harness.Outbox.TakeAsync(VaultId, 10, Token);
|
||||
pending.ShouldHaveSingleItem().EntityId.ShouldBe(healthy);
|
||||
@@ -216,7 +216,7 @@ public sealed class OutboxStoreTests : IAsyncLifetime
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
var queued = await harness.Outbox.QueueAsync(Change(entityId), Token);
|
||||
await harness.Outbox.ParkAsync(queued.Sequence, "nope", Token);
|
||||
await harness.Outbox.ParkAsync(queued, "nope", Token);
|
||||
|
||||
var requeued = await harness.Outbox.QueueAsync(Change(entityId, seed: 5), Token);
|
||||
|
||||
@@ -230,11 +230,42 @@ public sealed class OutboxStoreTests : IAsyncLifetime
|
||||
{
|
||||
var queued = await harness.Outbox.QueueAsync(Change(Guid.CreateVersion7()), Token);
|
||||
|
||||
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeTrue();
|
||||
(await harness.Outbox.CompleteAsync(queued, Token)).ShouldBeTrue();
|
||||
(await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldBeEmpty();
|
||||
|
||||
// Idempotent: a drain that retries after a crash must not fail on an already-cleared row.
|
||||
(await harness.Outbox.CompleteAsync(queued.Sequence, Token)).ShouldBeFalse();
|
||||
(await harness.Outbox.CompleteAsync(queued, Token)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An answer to an operation that has since been replaced by a newer edit applies to nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The newer edit coalesced into the same row, so the sequence still matches; only the operation id says
|
||||
/// the row is no longer the one that was sent. Completing, revising or parking it would act on the newer
|
||||
/// edit on the strength of an answer about the older one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAnswerToAReplacedOperation_LeavesTheNewerEditAlone()
|
||||
{
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
var sent = await harness.Outbox.QueueAsync(Change(entityId, seed: 1), Token);
|
||||
var newer = await harness.Outbox.QueueAsync(Change(entityId, seed: 2), Token);
|
||||
|
||||
newer.Sequence.ShouldBe(sent.Sequence, "the newer edit coalesces into the same row");
|
||||
|
||||
(await harness.Outbox.CompleteAsync(sent, Token)).ShouldBeFalse();
|
||||
await harness.Outbox.ParkAsync(sent, "refused", Token);
|
||||
|
||||
(await harness.Outbox.ReviseAsync(
|
||||
sent, sent.Operation, sent.ExpectedVersion, sent.Payload, sent.Fields, sent.Ancestor, Token))
|
||||
.ShouldBeNull();
|
||||
|
||||
var left = (await harness.Outbox.TakeAsync(VaultId, 10, Token)).ShouldHaveSingleItem();
|
||||
|
||||
left.OperationId.ShouldBe(newer.OperationId);
|
||||
left.IsParked.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -231,4 +231,68 @@ public sealed class SyncEngineTests
|
||||
async () => await harness.First.Hosts.ListAsync(
|
||||
unknown, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A save made while the previous save of the same host is on its way to the server survives that push
|
||||
/// being acknowledged, and reaches the server as the newer state rather than as a conflict.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The outbox coalesces the second save into the row the push read. Acknowledging by sequence alone
|
||||
/// deleted that row — the newer state with it — and the server and both machines kept the older one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASaveMadeWhileAPushIsInFlight_IsNotLostWhenThatPushIsAcknowledged()
|
||||
{
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
var id = await harness.First.CreateAsync(Host("prod-db", hostname: "first.internal"));
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
await harness.First.UpdateAsync(id, Host("prod-db", hostname: "second.internal"));
|
||||
|
||||
harness.Server.OnPush = () => harness.First
|
||||
.UpdateAsync(id, Host("prod-db", hostname: "third.internal"))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
await harness.First.SyncAsync();
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
(await harness.First.FindAsync(id)).Secret.Hostname.ShouldBe("third.internal");
|
||||
(await harness.First.ConflictsAsync()).ShouldBeEmpty();
|
||||
|
||||
await harness.Second.SyncAsync();
|
||||
|
||||
(await harness.Second.FindAsync(id)).Secret.Hostname.ShouldBe("third.internal");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A cursor read under a shorter list of item types starts the pull over, once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cursor a pull returns is past every change it read, including the changes of types it did not ask
|
||||
/// for. A build that adds a type to the list — SpelunkKey did, ObjectStore before it — and carried on from
|
||||
/// that cursor would never receive the items of the new type made before it arrived.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ACursorReadUnderAnotherListOfTypes_StartsThePullOverOnce()
|
||||
{
|
||||
using var harness = await CreateAsync();
|
||||
|
||||
await harness.First.CreateAsync(Host("prod-db"));
|
||||
await harness.First.SyncAsync();
|
||||
|
||||
var state = await harness.First.SyncState.ReadAsync(VaultId, TestContext.Current.CancellationToken);
|
||||
state.Cursor.ShouldNotBeNull();
|
||||
state.PulledTypes.ShouldNotBeNull();
|
||||
|
||||
// What an older build, which pulled fewer types, would have left behind.
|
||||
await harness.First.SyncState.SaveAsync(
|
||||
state with { PulledTypes = "1" }, TestContext.Current.CancellationToken);
|
||||
|
||||
(await harness.First.SyncAsync()).ResyncedFromStart.ShouldBeTrue();
|
||||
(await harness.First.SyncAsync()).ResyncedFromStart.ShouldBeFalse("the list is recorded with the cursor");
|
||||
|
||||
(await harness.First.ListAsync()).Items.ShouldHaveSingleItem();
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,14 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
/// <summary>The last size the pump forwarded, or null if it forwarded none.</summary>
|
||||
public TerminalSize? LastResize { get; private set; }
|
||||
|
||||
/// <summary>How long the first write takes, so a test can see whether a second one overtakes it.</summary>
|
||||
public TimeSpan FirstWriteDelay { get; init; }
|
||||
|
||||
/// <summary>Whether a resize throws, as SSH.NET's does once it has disposed the shell's stream.</summary>
|
||||
public bool ResizeThrows { get; init; }
|
||||
|
||||
private int writes;
|
||||
|
||||
/// <summary>How many resizes were forwarded, so a dropped one is observable.</summary>
|
||||
public int ResizeCount { get; private set; }
|
||||
|
||||
@@ -119,19 +127,27 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Increment(ref writes) == 1 && FirstWriteDelay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(FirstWriteDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
written.AddRange(data.ToArray());
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Resize(TerminalSize size)
|
||||
{
|
||||
if (ResizeThrows)
|
||||
{
|
||||
throw new ObjectDisposedException("ShellStream");
|
||||
}
|
||||
|
||||
// Mirrors the real session: an unusable size is dropped rather than forwarded.
|
||||
if (!size.IsUsable)
|
||||
{
|
||||
|
||||
@@ -285,6 +285,86 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A paste is one input frame however long it is, and one longer than a single read used to be dropped
|
||||
/// — or worse, its tail parsed as a frame of its own.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task APasteLargerThanOneRead_ReachesTheRemoteWhole()
|
||||
{
|
||||
Start();
|
||||
|
||||
using var socket = await ConnectAsync();
|
||||
|
||||
await using var session = new FakeShellSession();
|
||||
await using var pump = CreatePump(session);
|
||||
plane.Register(SessionId, pump);
|
||||
|
||||
var paste = new byte[300 * 1024];
|
||||
Random.Shared.NextBytes(paste);
|
||||
|
||||
await SendAsync(socket, (byte)TerminalClientOpcode.Input, paste);
|
||||
|
||||
await WaitUntilAsync(() => session.Written.Length >= paste.Length);
|
||||
|
||||
session.Written.ShouldBe(paste);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frames are handed over without waiting for the write before them, so a slow write must still be
|
||||
/// followed rather than overtaken: a snippet's RUN is its text and then a carriage return.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InputFrames_AreWrittenInTheOrderTheyArrived_EvenWhenAWriteIsSlow()
|
||||
{
|
||||
Start();
|
||||
|
||||
using var socket = await ConnectAsync();
|
||||
|
||||
await using var session = new FakeShellSession { FirstWriteDelay = TimeSpan.FromMilliseconds(300) };
|
||||
await using var pump = CreatePump(session);
|
||||
plane.Register(SessionId, pump);
|
||||
|
||||
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "sudo systemctl restart api"u8.ToArray());
|
||||
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "\r"u8.ToArray());
|
||||
|
||||
await WaitUntilAsync(() => session.Written.Length >= 27);
|
||||
|
||||
Encoding.UTF8.GetString(session.Written).ShouldBe("sudo systemctl restart api\r");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A resize that reaches a shell SSH.NET has already disposed is an ordinary moment at the end of a
|
||||
/// session, and it must not close the socket every other session shares.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AResizeThatThrows_LeavesTheSocketWorkingForEverythingElse()
|
||||
{
|
||||
Start();
|
||||
|
||||
using var socket = await ConnectAsync();
|
||||
|
||||
await using var ending = new FakeShellSession { ResizeThrows = true };
|
||||
await using var endingPump = CreatePump(ending);
|
||||
plane.Register(SessionId, endingPump);
|
||||
|
||||
await using var other = new FakeShellSession();
|
||||
await using var otherPump = CreatePump(other);
|
||||
plane.Register(SessionId + 1, otherPump);
|
||||
|
||||
await SendAsync(
|
||||
socket,
|
||||
(byte)TerminalClientOpcode.Resize,
|
||||
TerminalFrame.CreateResizePayload(new TerminalSize(80, 24, 800, 600)));
|
||||
|
||||
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray(), SessionId + 1);
|
||||
|
||||
await WaitUntilAsync(() => other.Written.Length > 0);
|
||||
|
||||
Encoding.UTF8.GetString(other.Written).ShouldBe("still here");
|
||||
socket.State.ShouldBe(WebSocketState.Open);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AResize_ReachesTheRemote()
|
||||
{
|
||||
|
||||
@@ -93,6 +93,15 @@ internal sealed class FakeSftpSession : ISftpSession
|
||||
/// </remarks>
|
||||
public int? FailReadAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a write may start anywhere but zero. False makes this behave like a bucket, which refuses an
|
||||
/// offset — and, like a bucket, still keeps whatever an abandoned write had sent when it is closed.
|
||||
/// </summary>
|
||||
public bool CanWriteFromAnOffset { get; set; } = true;
|
||||
|
||||
/// <summary>Fails the next write after this many bytes, once, the way a dropped upload does.</summary>
|
||||
public int? FailWriteAfter { get; set; }
|
||||
|
||||
/// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
|
||||
/// <remarks>
|
||||
/// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
|
||||
@@ -195,11 +204,19 @@ internal sealed class FakeSftpSession : ISftpSession
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanWriteFromAnOffset && offset != 0)
|
||||
{
|
||||
throw new SftpPathException(path, "An object cannot be written to from the middle.");
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
var existing = files.GetValueOrDefault(path, []);
|
||||
var failAfter = FailWriteAfter;
|
||||
FailWriteAfter = null;
|
||||
|
||||
return Task.FromResult<Stream>(new CommittingStream(
|
||||
failAfter,
|
||||
existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
|
||||
content =>
|
||||
{
|
||||
@@ -300,10 +317,42 @@ internal sealed class FakeSftpSession : ISftpSession
|
||||
}
|
||||
|
||||
/// <summary>A write that lands on the fake host when it is disposed.</summary>
|
||||
private sealed class CommittingStream(byte[] prefix, Action<byte[]> commit) : MemoryStream()
|
||||
private sealed class CommittingStream(int? failAfter, byte[] prefix, Action<byte[]> commit) : MemoryStream()
|
||||
{
|
||||
private bool committed;
|
||||
|
||||
public override void Write(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
FailIfDue(buffer.Length);
|
||||
base.Write(buffer);
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
FailIfDue(count);
|
||||
base.Write(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
FailIfDue(buffer.Length);
|
||||
return base.WriteAsync(buffer, cancellationToken);
|
||||
}
|
||||
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
FailIfDue(count);
|
||||
return base.WriteAsync(buffer, offset, count, cancellationToken);
|
||||
}
|
||||
|
||||
private void FailIfDue(int count)
|
||||
{
|
||||
if (failAfter is { } limit && Length + count > limit)
|
||||
{
|
||||
throw new IOException("The connection dropped mid-upload.");
|
||||
}
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
if (!committed)
|
||||
|
||||
@@ -145,6 +145,37 @@ public sealed class FileTransferQueueTests : IDisposable
|
||||
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bucket writes an object whole, so a retried upload starts again rather than resuming.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The failed attempt leaves a part object behind — a bucket keeps what was sent when the stream closes —
|
||||
/// and the retry used to ask for the offset that part had reached. A bucket refuses any offset, so every
|
||||
/// retry failed until the row was discarded, under a message saying it would start again.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARetriedUploadToAStoreThatCannotResume_StartsAgainAndLands()
|
||||
{
|
||||
var content = Bytes(300_000);
|
||||
await File.WriteAllBytesAsync(Local("artefact.tar"), content, Token);
|
||||
|
||||
host.CanWriteFromAnOffset = false;
|
||||
host.FailWriteAfter = 100_000;
|
||||
|
||||
await using var queue = NewQueue();
|
||||
|
||||
var broken = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Upload, Local("artefact.tar"), Remote("artefact.tar"), content.Length);
|
||||
|
||||
broken.State.ShouldBe(TransferState.Failed);
|
||||
host.Exists(Remote("artefact.tar") + FileTransferQueue.PartSuffix).ShouldBeTrue("what a bucket keeps");
|
||||
|
||||
var retried = await AwaitFinishAsync(queue, () => queue.Retry(broken.Id));
|
||||
|
||||
retried.State.ShouldBe(TransferState.Completed, retried.Failure);
|
||||
host.Read(Remote("artefact.tar")).ShouldBe(content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUploadOntoAFileThatIsAlreadyThere_IsRefusedAndLeavesItAlone()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace DodoSSH.Client.Transfer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>LocalDirectory.ChildPath</c>: where a name another machine chose may land on this one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The assertion that matters is the same in every row — the result's parent is the directory asked about,
|
||||
/// or there is no result — because that is the property a download relies on, and it is checked against the
|
||||
/// resolved path rather than the string so that what the file system does to a name counts too.
|
||||
/// </remarks>
|
||||
public sealed class LocalDirectoryTests
|
||||
{
|
||||
private static readonly string Directory = Path.Combine(Path.GetTempPath(), "dodossh-child-path");
|
||||
|
||||
[Theory]
|
||||
[InlineData("notes.txt")]
|
||||
[InlineData("backup:2026-10-02")]
|
||||
[InlineData(@"..\..\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\x.bat")]
|
||||
[InlineData(@"C:\Windows\evil.dll")]
|
||||
[InlineData("../../escaped.txt")]
|
||||
[InlineData("/etc/passwd")]
|
||||
[InlineData("...")]
|
||||
[InlineData(".hidden")]
|
||||
public void ANameThatCanBeAFileHere_LandsDirectlyInsideTheDirectory(string name)
|
||||
{
|
||||
var child = LocalDirectory.ChildPath(Directory, name);
|
||||
|
||||
if (child is null)
|
||||
{
|
||||
// Only acceptable for "...", which Windows reduces to nothing; everywhere else it is a name.
|
||||
OperatingSystem.IsWindows().ShouldBeTrue($"'{name}' was refused");
|
||||
name.ShouldBe("...");
|
||||
return;
|
||||
}
|
||||
|
||||
Path.GetDirectoryName(child).ShouldBe(Path.GetFullPath(Directory));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(".")]
|
||||
[InlineData("..")]
|
||||
public void ANameThatIsOnlyHereOrAbove_IsRefused(string name) =>
|
||||
LocalDirectory.ChildPath(Directory, name).ShouldBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// Windows drops a trailing dot or space from a path component, so <c>.. </c> is <c>..</c> by the time
|
||||
/// it reaches the disk — which a check of the string alone would have let through.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ATrailingSpaceOrDot_DoesNotSmuggleAParentReferencePastTheCheck()
|
||||
{
|
||||
foreach (var name in new[] { ".. ", "...", ".. .", ". " })
|
||||
{
|
||||
var child = LocalDirectory.ChildPath(Directory, name);
|
||||
|
||||
if (child is not null)
|
||||
{
|
||||
Path.GetDirectoryName(child).ShouldBe(Path.GetFullPath(Directory), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOrdinaryName_IsKeptAsItIs() =>
|
||||
Path.GetFileName(LocalDirectory.ChildPath(Directory, "nginx.conf")).ShouldBe("nginx.conf");
|
||||
}
|
||||
Reference in new issue
Block a user