Public Access
Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh machine takes a server URL, signs in through the browser, enrolls, and from then on opens with the passphrase alone. DodoSSH.Client.Session is the composition layer: where a profile lives, how it unlocks, and how a machine gets one. ClientPaths picks a non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%, because a SQLite cache that roams between two machines is a corrupt one, and each machine's outbox is its own. SessionOpener needs no transport at all and could not reach one if it wanted to; that is the offline unlock, asserted rather than asserted about. A wrong passphrase, a stale KDF and a grant revoked by a rekey are three different answers, because the remedies are three different things and telling someone to retype a passphrase that was never the problem is worse than saying nothing. The shell's states are the onboarding story. The recovery code gets its own state that cannot be clicked past: it exists for one moment, losing it with the passphrase loses the vault, and there is no server-side reset by design. It is dropped from memory on confirmation rather than merely hidden. Sign-in is a delegate over IVaultServer, so the whole state machine runs in a test against an in-memory server — no browser, no identity provider, no toolkit. The view models are plain observable objects, which is what makes that possible. What it does not cover is whether the XAML binds to the right names; that needs a rendered tree and Avalonia.Headless, and is its own piece of work. Three things found by doing it rather than by reading it: - Pooled SQLite connections keep the database file open after the last context is disposed. On Windows that means locked, so the application could never replace its own cache — and a test could not clean up after itself, which is how it surfaced. Dispose now clears the pool. - EF's SQLite provider puts the database in WAL mode, so the cache is three files. A comment in ClientCacheFactory claimed the opposite; reading PRAGMA journal_mode off a real launch settled it. WAL is the right mode here — a sync pass writes while the interface reads — so the comment was wrong on the merits as well as on the fact. - Enrolling a device key with nowhere to keep the private half would put a wrap on the server nobody can open and make the device list claim this machine can unlock without a passphrase. Device binding is now optional and the shell declines it until the OS keystore is wired. Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db and migrated it on first launch, and msedgewebview2 held an established connection to the data plane while the unlock overlay covered it — which is the point of covering the WebView rather than collapsing it, since a NativeWebView that is never laid out is never realised. 630 tests, up from 593. The recovery-code gate and the offline unlock were each verified by breaking them and watching the right test fail. Still to do for M1's actual definition of done: the manual run against the real API and a real Keycloak. Credentials are not a synced entity type yet, so a connection still asks for a password, and the interface says so rather than implying otherwise.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>What the server said about this account.</summary>
|
||||
public enum ProvisionStatus
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>
|
||||
/// No identity key exists yet. The user must choose a passphrase and enroll before anything else
|
||||
/// works.
|
||||
/// </summary>
|
||||
EnrollmentRequired = 1,
|
||||
|
||||
/// <summary>Enrolled, and everything an offline unlock needs is now cached.</summary>
|
||||
Ready = 2,
|
||||
}
|
||||
|
||||
/// <summary>The result of talking to the server about this account.</summary>
|
||||
/// <param name="Status">What happened.</param>
|
||||
/// <param name="Me">The profile the server reported.</param>
|
||||
/// <param name="RecoveryCode">
|
||||
/// Present only immediately after enrolling. <b>Must be shown once and never stored.</b> Losing this
|
||||
/// along with the passphrase and every enrolled device means the vault is unrecoverable, and there is no
|
||||
/// server-side reset by design — see docs/crypto.md §10.
|
||||
/// </param>
|
||||
/// <param name="Message">Something to show the user.</param>
|
||||
public sealed record ProvisionOutcome(
|
||||
ProvisionStatus Status,
|
||||
MeResponse Me,
|
||||
string? RecoveryCode,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine from "signed in" to "has everything an offline unlock needs".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The only part of the client that requires a network. Everything it does is in service of the part
|
||||
/// that does not: it caches the KDF salt, the wrapped identity bundle and the vault grants, so every
|
||||
/// later launch opens the vault with nothing but the passphrase.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// After enrolling it re-reads <c>/me</c> rather than caching what it believes it sent. That is a
|
||||
/// deliberate round trip: it proves the server stored what this client thinks it did, and the passphrase
|
||||
/// the user just chose is then verified against the cached wrap on the very next unlock rather than on
|
||||
/// some future launch when they have forgotten which one they typed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AccountProvisioner(
|
||||
IAccountApi api,
|
||||
IKeyBindingAuthorizer keyBinding,
|
||||
ClientCacheFactory caches,
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
/// <summary>Reads the account and caches whatever an offline unlock will need.</summary>
|
||||
public async Task<ProvisionOutcome> RefreshAsync(
|
||||
string serverUrl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
|
||||
|
||||
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (me.EnrollmentRequired)
|
||||
{
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.EnrollmentRequired,
|
||||
me,
|
||||
null,
|
||||
"This account has no vault key yet. Choose a passphrase to create one.");
|
||||
}
|
||||
|
||||
await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.Ready, me, null, "Signed in. Unlock with your vault passphrase.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates this account's identity key and personal vault.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The server this profile belongs to.</param>
|
||||
/// <param name="passphrase">The vault passphrase. Never transmitted or stored.</param>
|
||||
/// <param name="deviceName">Human-readable name for this machine, shown in the key statement.</param>
|
||||
/// <param name="vaultName">Display name for the personal vault.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <remarks>
|
||||
/// <b>No device key is registered.</b> Its private half belongs in the OS keystore, and nothing wires
|
||||
/// one up yet — so registering it would put a wrap on the server that no key can open and would make
|
||||
/// the account's device list claim this machine can unlock without a passphrase. Until the keystore
|
||||
/// is wired, the passphrase is required on every launch. That is a limitation, not a design choice.
|
||||
/// </remarks>
|
||||
public async Task<ProvisionOutcome> EnrollAsync(
|
||||
string serverUrl,
|
||||
string passphrase,
|
||||
string deviceName,
|
||||
string vaultName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
|
||||
ArgumentException.ThrowIfNullOrEmpty(passphrase);
|
||||
|
||||
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!me.EnrollmentRequired)
|
||||
{
|
||||
// Someone else already enrolled this account — another machine, or a retry whose answer was
|
||||
// lost. Caching what is there is the right move; re-enrolling would replace a key other
|
||||
// people may already have wrapped vault keys to.
|
||||
await CacheAsync(serverUrl, me, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.Ready,
|
||||
me,
|
||||
null,
|
||||
"This account was already enrolled. Unlock with your existing vault passphrase.");
|
||||
}
|
||||
|
||||
var enrollment = new ClientEnrollment(api, keyBinding, clock, passphraseProfile);
|
||||
|
||||
var outcome = await enrollment
|
||||
.EnrollAsync(me, passphrase, deviceName, vaultName, bindThisDevice: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var enrolled = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await CacheAsync(serverUrl, enrolled, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ProvisionOutcome(
|
||||
ProvisionStatus.Ready,
|
||||
enrolled,
|
||||
outcome.RecoveryCode,
|
||||
"Your vault was created. Write the recovery code down before continuing.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The session keys are re-derived from the cached wrap at unlock, so nothing here needs to
|
||||
// survive this method — and a vault key left in a managed array is a vault key in a heap dump.
|
||||
outcome.Bundle.Dispose();
|
||||
CryptographicOperations.ZeroMemory(outcome.PersonalVaultKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both halves matter. Without the unlock material there is no offline unlock; without the vault
|
||||
/// grants an offline launch could open the identity bundle and still not decrypt a single item.
|
||||
/// </remarks>
|
||||
private async Task CacheAsync(
|
||||
string serverUrl,
|
||||
MeResponse me,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (me.WrappedPrivateKey is null || me.KdfParameters is null || me.KeyGeneration is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The server reported an enrolled account without the material needed to unlock it. "
|
||||
+ "Refusing to cache a profile that could never be opened.");
|
||||
}
|
||||
|
||||
await new UnlockStore(caches, clock).SaveAsync(
|
||||
new StoredUnlockMaterial(
|
||||
serverUrl,
|
||||
me.UserId,
|
||||
me.Issuer,
|
||||
me.Subject,
|
||||
me.Email,
|
||||
me.DisplayName,
|
||||
(uint)me.KeyGeneration.Value,
|
||||
me.WrappedPrivateKey,
|
||||
me.KdfParameters,
|
||||
clock.GetUtcNow()),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await new VaultStore(caches, clock).ReplaceAllAsync(
|
||||
[.. me.Vaults.Select(ToStored)],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static StoredVault ToStored(VaultSummary summary) =>
|
||||
new(
|
||||
summary.VaultId,
|
||||
summary.Name,
|
||||
summary.IsPersonal,
|
||||
summary.TeamId,
|
||||
summary.KeyGeneration,
|
||||
summary.Permissions,
|
||||
summary.WrappedVaultKey,
|
||||
summary.RekeyRequired);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Where this machine keeps its profile.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A record with an explicit directory rather than a static lookup, so a test — or a portable install —
|
||||
/// can point it somewhere else without an environment variable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The choice of directory matters more than it looks.</b> The cache is a SQLite file written by one
|
||||
/// process, and the whole design assumes each machine has its own: the outbox holds changes this machine
|
||||
/// has made and not yet pushed, and two machines sharing one file through a cloud sync client corrupts
|
||||
/// it. So this deliberately picks a <em>local</em>, non-roaming location on every platform. On Windows
|
||||
/// that means <c>%LOCALAPPDATA%</c> and never <c>%APPDATA%</c>, which roams in a domain environment and
|
||||
/// would do exactly the wrong thing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="DataDirectory">The profile directory. Created on demand.</param>
|
||||
public sealed record ClientPaths(string DataDirectory)
|
||||
{
|
||||
private const string WindowsFolderName = "DodoSSH";
|
||||
private const string UnixFolderName = "dodossh";
|
||||
|
||||
/// <summary>The conventional location for this platform.</summary>
|
||||
public static ClientPaths Default { get; } = new(ResolveDataDirectory());
|
||||
|
||||
/// <summary>The encrypted local cache.</summary>
|
||||
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
|
||||
|
||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||
/// during startup diagnostics and by tests that have no business creating directories.
|
||||
/// </remarks>
|
||||
public void EnsureCreated() => Directory.CreateDirectory(DataDirectory);
|
||||
|
||||
/// <remarks>
|
||||
/// The platform branches are explicit rather than delegating to
|
||||
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> everywhere. That enumeration does
|
||||
/// the right thing on Windows, but on macOS the runtime maps it to <c>~/.local/share</c> rather than
|
||||
/// to <c>~/Library/Application Support</c>, and relying on framework behaviour that differs per
|
||||
/// platform for a path users will look at is how a file ends up somewhere nobody expects.
|
||||
/// <para>
|
||||
/// <c>XDG_DATA_HOME</c> is honoured explicitly for the same reason: it is the spec, and reading it
|
||||
/// here is one line versus depending on whether the runtime happens to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string ResolveDataDirectory()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
WindowsFolderName);
|
||||
}
|
||||
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return Path.Combine(home, "Library", "Application Support", WindowsFolderName);
|
||||
}
|
||||
|
||||
var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(xdgDataHome)
|
||||
? Path.Combine(home, ".local", "share")
|
||||
: xdgDataHome;
|
||||
|
||||
return Path.Combine(root, UnixFolderName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The client's session lifecycle: where a profile lives on disk, how a vault is unlocked, and how a
|
||||
fresh machine gets one in the first place.
|
||||
|
||||
This is the composition layer the application shell sits on, and it is deliberately Avalonia-free
|
||||
like every other Client.* project except App. That is what lets the part that actually matters —
|
||||
that an unlock works with no network, and that a wrong passphrase is a return value rather than a
|
||||
crash — be a fast unit test instead of something only reachable by clicking.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Session.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,262 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for a token provider before anyone has signed in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Discovery has to happen before authentication is possible — a client cannot know how to authenticate
|
||||
/// until it has asked — but the API client takes a token provider in its constructor. Rather than make
|
||||
/// that provider mutable and hope no authenticated call slips through early, the unauthenticated phase
|
||||
/// gets a provider that says exactly what went wrong.
|
||||
/// </remarks>
|
||||
internal sealed class UnavailableAccessTokenProvider : IAccessTokenProvider
|
||||
{
|
||||
internal static UnavailableAccessTokenProvider Instance { get; } = new();
|
||||
|
||||
public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException(
|
||||
"An authenticated call was attempted before sign-in. Only /meta and the discovery document "
|
||||
+ "are reachable at this point.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the bearer token fresh for the life of a connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The refresh happens under a lock with the expiry re-checked inside it. Without that second check,
|
||||
/// several concurrent calls all decide the token is stale and all refresh — and because many providers
|
||||
/// rotate the refresh token on use, every attempt after the first fails, turning one expiry into a forced
|
||||
/// re-authentication.
|
||||
/// </remarks>
|
||||
internal sealed class RefreshingAccessTokenProvider(
|
||||
OidcClient oidc,
|
||||
TokenSet initial,
|
||||
TimeProvider clock) : IAccessTokenProvider, IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private TokenSet tokens = initial;
|
||||
|
||||
public async ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
}
|
||||
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
}
|
||||
|
||||
if (tokens.RefreshToken is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The access token has expired and no refresh token was granted. Sign in again.");
|
||||
}
|
||||
|
||||
tokens = await oidc.RefreshAsync(tokens.RefreshToken, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return tokens.AccessToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => gate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a signed-in server offers, as everything above the session layer needs it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An interface rather than the concrete connection, for one specific reason: establishing a real one
|
||||
/// requires discovery, a browser and a token exchange. A shell that depended on the concrete type would
|
||||
/// make its own state machine — sign in, enroll, unlock, sync — reachable only by clicking through an
|
||||
/// identity provider, which is the part of an application that most needs a test and least often has one.
|
||||
/// </remarks>
|
||||
public interface IVaultServer : IDisposable
|
||||
{
|
||||
/// <summary>The server this is connected to.</summary>
|
||||
Uri ServerUrl { get; }
|
||||
|
||||
/// <summary>Who am I, and publish my first key.</summary>
|
||||
IAccountApi Account { get; }
|
||||
|
||||
/// <summary>Pull and push.</summary>
|
||||
ISyncApi Sync { get; }
|
||||
|
||||
/// <summary>Obtains the identity provider's signature over a key statement.</summary>
|
||||
IKeyBindingAuthorizer KeyBinding { get; }
|
||||
|
||||
/// <summary>Sync tuning derived from what this server actually accepts.</summary>
|
||||
SyncOptions SyncOptions { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signed-in connection to one DodoSSH server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The onboarding story in one object: the user types a server URL, the client reads
|
||||
/// <c>/.well-known/dodossh-configuration</c> to learn the identity provider, the client id and the
|
||||
/// scopes, and everything else follows. Nothing about the identity provider is configured on this
|
||||
/// machine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A session outlives this. Losing the network invalidates the connection, not the vault — which is why
|
||||
/// syncing takes an <see cref="ISyncApi"/> per call rather than the session holding one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ServerConnection : IVaultServer
|
||||
{
|
||||
private readonly HttpClient http;
|
||||
private readonly RefreshingAccessTokenProvider tokens;
|
||||
private bool disposed;
|
||||
|
||||
private ServerConnection(
|
||||
Uri serverUrl,
|
||||
HttpClient http,
|
||||
DodoSshConfiguration configuration,
|
||||
MetaResponse meta,
|
||||
OidcClient oidc,
|
||||
RefreshingAccessTokenProvider tokens,
|
||||
DodoSshApiClient api)
|
||||
{
|
||||
ServerUrl = serverUrl;
|
||||
this.http = http;
|
||||
Configuration = configuration;
|
||||
Meta = meta;
|
||||
Oidc = oidc;
|
||||
this.tokens = tokens;
|
||||
Api = api;
|
||||
}
|
||||
|
||||
/// <summary>The server this is connected to.</summary>
|
||||
public Uri ServerUrl { get; }
|
||||
|
||||
/// <summary>What the server told us about itself and its identity provider.</summary>
|
||||
public DodoSshConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>Versions, features and limits.</summary>
|
||||
public MetaResponse Meta { get; }
|
||||
|
||||
/// <summary>The identity provider client, which is also the key-binding authorizer.</summary>
|
||||
public OidcClient Oidc { get; }
|
||||
|
||||
/// <summary>The authenticated API client.</summary>
|
||||
public DodoSshApiClient Api { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAccountApi Account => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Sync tuning derived from what this server actually accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what capability negotiation is for, and why there is no URL API version. A client and a
|
||||
/// server that upgrade independently — normal for self-hosted software — have to agree on limits by
|
||||
/// asking rather than by assuming. Sending a batch larger than the server's cap would have the whole
|
||||
/// push rejected rather than the excess trimmed.
|
||||
/// </remarks>
|
||||
/// <inheritdoc cref="IVaultServer.SyncOptions" />
|
||||
public SyncOptions SyncOptions => new()
|
||||
{
|
||||
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Discovers the server, signs the user in through their browser, and returns the connection.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The DodoSSH server's base URL — the only thing the user has to know.</param>
|
||||
/// <param name="browser">Opens the system browser. Never an embedded one; see RFC 8252.</param>
|
||||
/// <param name="clock">Time source, for token expiry.</param>
|
||||
/// <param name="cancellationToken">Cancels the wait for the browser.</param>
|
||||
public static async Task<ServerConnection> SignInAsync(
|
||||
Uri serverUrl,
|
||||
IBrowserLauncher browser,
|
||||
TimeProvider clock,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serverUrl);
|
||||
ArgumentNullException.ThrowIfNull(browser);
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
|
||||
var transport = new HttpClient { BaseAddress = serverUrl };
|
||||
|
||||
try
|
||||
{
|
||||
var discovery = new DodoSshApiClient(transport, UnavailableAccessTokenProvider.Instance);
|
||||
|
||||
var configuration = await discovery.GetConfigurationAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var oidc = new OidcClient(transport, browser, clock, BuildOidcOptions(configuration));
|
||||
|
||||
var tokenSet = await oidc.SignInAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var refreshing = new RefreshingAccessTokenProvider(oidc, tokenSet, clock);
|
||||
|
||||
return new ServerConnection(
|
||||
serverUrl,
|
||||
transport,
|
||||
configuration,
|
||||
meta,
|
||||
oidc,
|
||||
refreshing,
|
||||
new DodoSshApiClient(transport, refreshing));
|
||||
}
|
||||
catch
|
||||
{
|
||||
transport.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
tokens.Dispose();
|
||||
http.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// HTTPS is required for the provider's metadata unless the authority is loopback, which is what a
|
||||
/// development Keycloak looks like. A configuration flag would be the alternative and a worse one:
|
||||
/// it would be set once during development and never unset. Loopback is not a weaker channel — it
|
||||
/// never leaves the machine — so the exemption is narrow and does not need a switch.
|
||||
/// </remarks>
|
||||
private static OidcClientOptions BuildOidcOptions(DodoSshConfiguration configuration) =>
|
||||
new()
|
||||
{
|
||||
Authority = configuration.Oidc.Authority,
|
||||
ClientId = configuration.Oidc.ClientId,
|
||||
Scopes = configuration.Oidc.Scopes,
|
||||
RequireHttpsMetadata = !configuration.Oidc.Authority.IsLoopback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>Why an unlock did or did not produce a session.</summary>
|
||||
public enum UnlockStatus
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>The vault is open.</summary>
|
||||
Unlocked = 1,
|
||||
|
||||
/// <summary>
|
||||
/// This machine has never been enrolled, so there is nothing here to unlock. The user has to sign in
|
||||
/// to a server first, which needs a network.
|
||||
/// </summary>
|
||||
NotEnrolled = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The passphrase did not open the wrap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The overwhelmingly common failure, and a return value rather than an exception for that reason.
|
||||
/// It is also indistinguishable from a tampered wrap, which is correct: the AEAD tag is the only
|
||||
/// evidence either way, and no passphrase verifier is stored anywhere. See docs/crypto.md §2.
|
||||
/// </remarks>
|
||||
WrongPassphrase = 3,
|
||||
|
||||
/// <summary>
|
||||
/// The identity opened but no vault grant did, so there is nothing readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What a rekey looks like before new grants arrive. Distinguished from a wrong passphrase because
|
||||
/// the remedy is completely different — this one needs a member with Share to finish the rekey, and
|
||||
/// telling the user to retype their passphrase would be actively misleading.
|
||||
/// </remarks>
|
||||
NoReadableVault = 4,
|
||||
|
||||
/// <summary>The cached KDF parameters are not something this build can use.</summary>
|
||||
UnsupportedKdf = 5,
|
||||
}
|
||||
|
||||
/// <summary>The result of an unlock attempt.</summary>
|
||||
/// <param name="Status">What happened.</param>
|
||||
/// <param name="Session">The open vault, present only when <paramref name="Status"/> is unlocked.</param>
|
||||
/// <param name="Message">Something to show the user. Never contains secret material.</param>
|
||||
public sealed record UnlockOutcome(UnlockStatus Status, VaultSession? Session, string Message)
|
||||
{
|
||||
/// <summary>Whether a session came back.</summary>
|
||||
public bool IsUnlocked => Status == UnlockStatus.Unlocked && Session is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the vault from what is already on this machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This path touches no network, deliberately and testably.</b> The Argon2id salt, its cost
|
||||
/// parameters and the wrapped identity bundle are all cached at enrollment, so deriving the master key
|
||||
/// and opening the bundle need nothing but the passphrase. Fetching any of it at unlock time would make
|
||||
/// an offline launch impossible, which is the single most common moment a user actually needs their
|
||||
/// hosts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing derived here is persisted. The master key exists for the duration of this method and is
|
||||
/// zeroed before it returns; what survives is the cache subkey and the identity keys, in the session,
|
||||
/// until the session is disposed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SessionOpener(
|
||||
ClientCacheFactory caches,
|
||||
TimeProvider clock,
|
||||
SyncOptions? options = null)
|
||||
{
|
||||
private readonly SyncOptions options = options ?? SyncOptions.Default;
|
||||
|
||||
/// <summary>Reads who this machine is enrolled as, without needing a passphrase.</summary>
|
||||
/// <remarks>
|
||||
/// Lets the unlock screen greet the user by name and show which server they are enrolled against,
|
||||
/// which is the difference between an unlock prompt and an unexplained password box.
|
||||
/// </remarks>
|
||||
public Task<StoredUnlockMaterial?> ReadProfileAsync(CancellationToken cancellationToken) =>
|
||||
new UnlockStore(caches, clock).ReadAsync(cancellationToken);
|
||||
|
||||
/// <summary>Attempts to open the vault.</summary>
|
||||
public async Task<UnlockOutcome> UnlockAsync(string passphrase, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(passphrase);
|
||||
|
||||
var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.NotEnrolled,
|
||||
null,
|
||||
"This machine is not enrolled yet. Sign in to a DodoSSH server to set it up.");
|
||||
}
|
||||
|
||||
if (!TryReadKdf(profile, out var kdf))
|
||||
{
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.UnsupportedKdf,
|
||||
null,
|
||||
$"The stored key derivation settings ('{profile.KdfParameters.Algorithm}') are not "
|
||||
+ "supported by this version. Update DodoSSH.");
|
||||
}
|
||||
|
||||
var bundle = OpenBundle(profile, passphrase, kdf, out var protector);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.WrongPassphrase, null, "That passphrase did not open the vault.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await BuildSessionAsync(profile, bundle, protector!, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
protector!.Dispose();
|
||||
bundle.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The master key lives only inside this method. Both things derived from it — the cache subkey and
|
||||
/// the identity bundle — outlive it, which is why they are produced together here rather than by two
|
||||
/// calls that would each need the master key again.
|
||||
/// </remarks>
|
||||
private static UserSecretBundle? OpenBundle(
|
||||
StoredUnlockMaterial profile,
|
||||
string passphrase,
|
||||
Argon2Profile kdf,
|
||||
out LocalCacheProtector? protector)
|
||||
{
|
||||
protector = null;
|
||||
|
||||
using var master = MasterKey.Derive(passphrase, profile.KdfParameters.Salt, kdf);
|
||||
|
||||
var descriptor = DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration);
|
||||
var bundle = master.TryOpenBundle(profile.WrappedPrivateKey, descriptor);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
protector = LocalCacheProtector.From(master);
|
||||
return bundle;
|
||||
}
|
||||
catch
|
||||
{
|
||||
bundle.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<UnlockOutcome> BuildSessionAsync(
|
||||
StoredUnlockMaterial profile,
|
||||
UserSecretBundle bundle,
|
||||
LocalCacheProtector protector,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vaults = await new VaultStore(caches, clock)
|
||||
.ListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var keyring = VaultKeyring.Open(bundle, vaults);
|
||||
|
||||
try
|
||||
{
|
||||
var active = vaults.FirstOrDefault(vault => keyring.CanRead(vault.VaultId));
|
||||
|
||||
if (active is null)
|
||||
{
|
||||
keyring.Dispose();
|
||||
protector.Dispose();
|
||||
bundle.Dispose();
|
||||
|
||||
return new UnlockOutcome(
|
||||
UnlockStatus.NoReadableVault,
|
||||
null,
|
||||
vaults.Count == 0
|
||||
? "No vaults are cached on this machine yet. Sign in to synchronise them."
|
||||
: "Your key does not open any cached vault. It was probably rotated; a member "
|
||||
+ "with sharing rights needs to re-issue your access.");
|
||||
}
|
||||
|
||||
var session = new VaultSession(
|
||||
profile, vaults, active.VaultId, bundle, protector, keyring, caches, clock, options);
|
||||
|
||||
return new UnlockOutcome(UnlockStatus.Unlocked, session, $"Unlocked '{active.Name}'.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
keyring.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The parameters travel with the wrap so that raising them later is a per-user migration at next
|
||||
/// unlock rather than a breaking change. The cost of that is having to handle values this build does
|
||||
/// not recognise, which is what this is: a clear message beats an exception from inside libsodium.
|
||||
/// </remarks>
|
||||
private static bool TryReadKdf(StoredUnlockMaterial profile, out Argon2Profile kdf)
|
||||
{
|
||||
kdf = Argon2Profile.PassphraseDefault;
|
||||
|
||||
if (!string.Equals(profile.KdfParameters.Algorithm, "argon2id", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
kdf = Argon2Profile.FromStoredParameters(
|
||||
profile.KdfParameters.MemoryKibibytes,
|
||||
profile.KdfParameters.Passes,
|
||||
profile.KdfParameters.Parallelism);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>A conflict, decoded and ready to show.</summary>
|
||||
/// <param name="Id">The conflict record, so it can be acknowledged.</param>
|
||||
/// <param name="EntityId">The item it happened to.</param>
|
||||
/// <param name="Kind">What happened.</param>
|
||||
/// <param name="Summary">One line for a person.</param>
|
||||
/// <param name="Fields">
|
||||
/// Everything the merge overrode, with the discarded values. Empty for the kinds that have no field
|
||||
/// detail — a rejected push, or an item that would not decrypt.
|
||||
/// </param>
|
||||
/// <param name="DetectedAt">When it was noticed.</param>
|
||||
public sealed record ConflictNotice(
|
||||
Guid Id,
|
||||
Guid EntityId,
|
||||
ConflictKind Kind,
|
||||
string Summary,
|
||||
IReadOnlyList<ConflictDetailEntry> Fields,
|
||||
DateTimeOffset DetectedAt);
|
||||
|
||||
/// <summary>
|
||||
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything a session owns dies with it — the identity bundle, the vault keys and the cache key. That
|
||||
/// is the whole reason this is a disposable object rather than a set of long-lived services: locking is
|
||||
/// disposing, and there is exactly one place that has to be right.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The sync engine is <em>not</em> held here. It carries no state, so it is constructed per pass around
|
||||
/// whichever transport the caller currently has — which models the actual situation, where a session is
|
||||
/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultSession : IAsyncDisposable
|
||||
{
|
||||
private readonly UserSecretBundle bundle;
|
||||
private readonly LocalCacheProtector protector;
|
||||
private readonly VaultKeyring keyring;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly SyncOptions options;
|
||||
private bool disposed;
|
||||
|
||||
internal VaultSession(
|
||||
StoredUnlockMaterial profile,
|
||||
IReadOnlyList<StoredVault> vaults,
|
||||
Guid activeVaultId,
|
||||
UserSecretBundle bundle,
|
||||
LocalCacheProtector protector,
|
||||
VaultKeyring keyring,
|
||||
ClientCacheFactory caches,
|
||||
TimeProvider clock,
|
||||
SyncOptions options)
|
||||
{
|
||||
Profile = profile;
|
||||
Vaults = vaults;
|
||||
ActiveVaultId = activeVaultId;
|
||||
|
||||
this.bundle = bundle;
|
||||
this.protector = protector;
|
||||
this.keyring = keyring;
|
||||
this.clock = clock;
|
||||
this.options = options;
|
||||
|
||||
Items = new ItemStore(caches, protector);
|
||||
Outbox = new OutboxStore(caches, protector, clock);
|
||||
SyncState = new SyncStateStore(caches);
|
||||
Conflicts = new ConflictStore(caches, protector, clock);
|
||||
Vault = new VaultStore(caches, clock);
|
||||
Hosts = new HostRepository(Items, Outbox, keyring);
|
||||
}
|
||||
|
||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||
public StoredUnlockMaterial Profile { get; }
|
||||
|
||||
/// <summary>Every vault this user can reach, readable or not.</summary>
|
||||
public IReadOnlyList<StoredVault> Vaults { get; }
|
||||
|
||||
/// <summary>The vault the interface is showing. The personal one, for now.</summary>
|
||||
public Guid ActiveVaultId { get; }
|
||||
|
||||
/// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
|
||||
public HostRepository Hosts { get; }
|
||||
|
||||
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
|
||||
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
|
||||
|
||||
internal ItemStore Items { get; }
|
||||
|
||||
internal OutboxStore Outbox { get; }
|
||||
|
||||
internal SyncStateStore SyncState { get; }
|
||||
|
||||
internal ConflictStore Conflicts { get; }
|
||||
|
||||
internal VaultStore Vault { get; }
|
||||
|
||||
/// <summary>Runs one synchronisation pass over the active vault.</summary>
|
||||
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<SyncReport> SyncAsync(ISyncApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
|
||||
var engine = new SyncEngine(
|
||||
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
|
||||
|
||||
return engine.SyncAsync(ActiveVaultId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the conflicts a person still needs to see.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A conflict whose detail will not decode is still reported, with the reason in place of the
|
||||
/// summary. The record itself — which item, when, what kind — remains useful even when the
|
||||
/// discarded value has become unreadable, and dropping the row would be the one outcome the whole
|
||||
/// conflict log exists to avoid.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<ConflictNotice>> ReadConflictsAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
var stored = await Conflicts
|
||||
.ListAsync(ActiveVaultId, includeAcknowledged: false, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. stored.Select(Describe)];
|
||||
}
|
||||
|
||||
/// <summary>Marks a conflict as seen, keeping the discarded value retrievable.</summary>
|
||||
public Task<bool> AcknowledgeConflictAsync(Guid conflictId, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>How many local changes are waiting to be pushed.</summary>
|
||||
public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
|
||||
return pending.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
// Order is not important — none of these depend on another — but completeness is. Missing one
|
||||
// leaves key material in memory for the life of the process, which is the opposite of what
|
||||
// locking is supposed to mean.
|
||||
keyring.Dispose();
|
||||
protector.Dispose();
|
||||
bundle.Dispose();
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static ConflictNotice Describe(StoredConflict conflict)
|
||||
{
|
||||
var detail = ConflictDetails.TryRead(conflict.Detail);
|
||||
|
||||
return new ConflictNotice(
|
||||
conflict.Id,
|
||||
conflict.EntityId,
|
||||
conflict.Kind,
|
||||
detail?.Summary ?? "The details of this conflict could not be read.",
|
||||
detail?.Fields ?? [],
|
||||
conflict.DetectedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.134, )",
|
||||
"resolved": "3.0.134",
|
||||
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
|
||||
"dependencies": {
|
||||
"Microsoft.Data.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Caching.Memory": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Configuration.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "10.0.10",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Options": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Options": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Primitives": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.Extensions.Primitives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )",
|
||||
"EFCore.NamingConventions": "[10.0.1, )",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.sync": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Api": "[1.0.0, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.1, )",
|
||||
"resolved": "10.0.1",
|
||||
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
|
||||
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Relational": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
|
||||
"dependencies": {
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
|
||||
"Microsoft.Extensions.Caching.Memory": "10.0.10",
|
||||
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
|
||||
"Microsoft.Extensions.DependencyModel": "10.0.10",
|
||||
"Microsoft.Extensions.Logging": "10.0.10",
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
|
||||
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
|
||||
}
|
||||
},
|
||||
"SQLitePCLRaw.core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
|
||||
},
|
||||
"SQLitePCLRaw.lib.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
|
||||
},
|
||||
"SQLitePCLRaw.provider.e_sqlite3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.12, )",
|
||||
"resolved": "2.1.12",
|
||||
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
|
||||
"dependencies": {
|
||||
"SQLitePCLRaw.core": "2.1.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user