Public Access
Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed. DodoSSH.Client.Domain holds the decrypted item model and the three-way merge, with no I/O at all — so the suite that decides whether a credential can be lost runs in milliseconds with nothing to mock. Scalars defer to the server on a genuine clash so every replica resolves the same triple identically and two clients cannot ping-pong; directives merge per name so two people each adding one both keep theirs; the jump chain merges as a whole value because its order is the route. Whatever loses is returned rather than dropped. DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are already ciphertext, so an encrypted file would protect protected bytes at the cost of a native dependency. It keeps the server's state and the outbox in separate tables, which is what preserves the common ancestor a merge needs. One pending operation per item, enforced by a unique index. DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts — a change with no local work pending is plumbed as ciphertext — so a first sync of thousands of items does not run twice as many AEAD operations for nothing. Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The specification has required a per-item data key since crypto.md §3, the columns have existed since the first migration and DshAad.ItemPayload binds the id, but this record had nowhere to put either — so a spec-compliant item could not be transmitted at all. Found by writing the client that has to produce one. Also closes a hole in AadResourceType, which had no value for the HostTag and HostCredential that SyncEntityType has always listed. Four bugs the tests found, not review: - SQLite refuses to order or compare its own DateTimeOffset mapping, and throws at execution rather than model build. Collecting tombstones and listing conflicts are both that shape, so this was a crash waiting for the first user with a deleted host. Timestamps are integers now, by convention so a later field cannot be the one left unconverted. - SQLitePCLRaw 2.1.11, which EF resolves, is covered by GHSA-2m69-gcr7-jv3q. Pinned forward as a family. - Resurrecting content from a remote deletion cleared the original before queueing the copy. Two transactions, so a crash between them lost the work; reversed, and the rescued id is derived from the tombstone so a replay coalesces instead of duplicating. - Several equality assertions went through Shouldly's ShouldBe, which compares IEnumerable element-wise and so tested nothing about the Equals these types exist to provide. Corrected; the falsification that caught it went from 2 failures to 6. The push response's cursor is deliberately ignored. It sits after this client's own writes, so adopting it skips anything another client committed at a lower sequence in the window between a pull and a push — permanently. Re-reading one's own writes is idempotent and costs a page. The Contracts doc that invited the shortcut now says so. 593 tests, up from 448. The delete-versus-edit rules, the ancestor retention, the fresh operation id on coalesce and the cursor safeguard were each verified by breaking them and watching the right test fail.
This commit is contained in:
@@ -264,6 +264,11 @@ internal sealed class SyncService(
|
||||
return Invalid(operation, "An upsert requires a payload.");
|
||||
}
|
||||
|
||||
if (!ValidatePayload(operation.Payload, out var payloadError))
|
||||
{
|
||||
return Invalid(operation, payloadError);
|
||||
}
|
||||
|
||||
var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
|
||||
|
||||
if (!ValidateRelayFields(fields, out var relayError))
|
||||
@@ -392,6 +397,8 @@ internal sealed class SyncService(
|
||||
DateTimeOffset now)
|
||||
{
|
||||
host.Payload = payload.Envelope;
|
||||
host.DataKeyWrap = payload.WrappedDataKey;
|
||||
host.ContentKeyId = payload.DataKeyId;
|
||||
host.KeyGeneration = (int)payload.KeyGeneration;
|
||||
host.PayloadAadVersion = payload.AadVersion;
|
||||
host.RelayEnabled = fields.RelayEnabled;
|
||||
@@ -403,6 +410,41 @@ internal sealed class SyncService(
|
||||
host.UpdatedByUserId = actorUserId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a payload missing its data key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The server cannot read any of these bytes, so this is a structural check and nothing more.
|
||||
/// It is still worth making: docs/crypto.md §3 requires a per-item data key, the payload's AAD
|
||||
/// binds <see cref="EncryptedPayload.DataKeyId"/>, and a row stored without a wrap is a row no
|
||||
/// client will ever be able to open. Better to refuse it here — where the client is told which
|
||||
/// operation was wrong — than to store an item that silently reads as corrupt forever.
|
||||
/// </remarks>
|
||||
private static bool ValidatePayload(EncryptedPayload payload, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
|
||||
if (payload.Envelope.Length == 0)
|
||||
{
|
||||
error = "A payload envelope cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.WrappedDataKey.Length == 0)
|
||||
{
|
||||
error = "A payload requires its data key, wrapped under the vault key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.DataKeyId == Guid.Empty)
|
||||
{
|
||||
error = "A payload requires a data key identifier; it is part of the payload's AAD.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a
|
||||
/// constraint violation surfacing as a 500.
|
||||
@@ -566,6 +608,11 @@ internal sealed class SyncService(
|
||||
? null
|
||||
: new EncryptedPayload(
|
||||
host.Payload,
|
||||
// Non-null for every row a push can create: ValidatePayload refuses an
|
||||
// operation without them. The columns stay nullable because they are also
|
||||
// the seam for M5's per-item grants.
|
||||
host.DataKeyWrap ?? [],
|
||||
host.ContentKeyId ?? Guid.Empty,
|
||||
(uint)host.KeyGeneration,
|
||||
(byte)host.PayloadAadVersion),
|
||||
PlaintextFields: isDelete || host is null
|
||||
|
||||
@@ -18,6 +18,31 @@ public interface IAccessTokenProvider
|
||||
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed
|
||||
/// transport only proves that the right bytes were sent. Behind this interface the suite runs an
|
||||
/// in-memory server that enforces the real version checks, assigns real change sequences and issues
|
||||
/// real cursors — so a test can assert what happens when two clients edit one host, which is the
|
||||
/// question that actually matters.
|
||||
/// </remarks>
|
||||
public interface ISyncApi
|
||||
{
|
||||
/// <summary>Reads vault changes after a cursor.</summary>
|
||||
Task<SyncPullResponse> SyncPullAsync(
|
||||
Guid vaultId,
|
||||
SyncPullRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Applies a batch of vault changes.</summary>
|
||||
Task<SyncPushResponse> SyncPushAsync(
|
||||
Guid vaultId,
|
||||
SyncPushRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The typed client for one DodoSSH server.
|
||||
/// </summary>
|
||||
@@ -33,7 +58,7 @@ public interface IAccessTokenProvider
|
||||
/// Everything else carries a bearer token.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) : ISyncApi
|
||||
{
|
||||
private const string MetaPath = "/api/v1/meta";
|
||||
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The decrypted shape of a vault item, and the three-way merge over it.
|
||||
|
||||
Deliberately dependency-free: no Contracts, no Crypto, no EF. This is the one project that
|
||||
knows what a host *means*, and it is the only place the merge rules live. Keeping it free of
|
||||
the wire format and the cipher is what lets the conflict matrix be a pure, fast unit suite
|
||||
with no ciphertext or database in sight.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Domain.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// One SSH configuration directive on a host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Equality treats the name case-insensitively, matching how SSH reads keywords. Without that, two
|
||||
/// clients that resolved the same merge could end up holding <c>ServerAliveInterval</c> and
|
||||
/// <c>serveraliveinterval</c>, compare their hosts as different, and push over each other forever
|
||||
/// while agreeing on every actual value.
|
||||
/// </remarks>
|
||||
/// <param name="Name">Directive name, for example <c>ServerAliveInterval</c>.</param>
|
||||
/// <param name="Value">Directive value, verbatim and case-sensitive.</param>
|
||||
public sealed record HostOption(string Name, string Value)
|
||||
{
|
||||
/// <summary>Defines directive identity: SSH keywords are case-insensitive.</summary>
|
||||
public static StringComparer NameComparer => StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(HostOption? other) =>
|
||||
other is not null
|
||||
&& NameComparer.Equals(Name, other.Name)
|
||||
&& string.Equals(Value, other.Value, StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() =>
|
||||
HashCode.Combine(NameComparer.GetHashCode(Name), Value.GetHashCode(StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host's SSH directives: unique by name, held in name order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both invariants are load-bearing for the merge. Uniqueness gives every value a stable key, which
|
||||
/// is what lets two people add different directives to the same host and both survive — a
|
||||
/// whole-collection comparison would make that a conflict and discard one side. Name order makes the
|
||||
/// encoding deterministic, so re-encoding an unchanged host produces identical bytes and the sync
|
||||
/// engine does not push a spurious update on every pass.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The cost, stated plainly:</b> real <c>ssh_config</c> permits a directive to repeat, and for
|
||||
/// most keywords the first occurrence wins. That cannot be represented here. It is a deliberate M1
|
||||
/// limitation rather than an oversight — a repeated key has no merge key — and the import path must
|
||||
/// surface it rather than quietly keeping one of the duplicates.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostOptions : IReadOnlyList<HostOption>, IEquatable<HostOptions>
|
||||
{
|
||||
private readonly HostOption[] items;
|
||||
private readonly int hash;
|
||||
|
||||
private HostOptions(HostOption[] items)
|
||||
{
|
||||
this.items = items;
|
||||
hash = ComputeHash(items);
|
||||
}
|
||||
|
||||
/// <summary>No directives.</summary>
|
||||
public static HostOptions Empty { get; } = new([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count => items.Length;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostOption this[int index] => items[index];
|
||||
|
||||
/// <summary>
|
||||
/// Builds a canonical collection, sorting by name.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">A name repeats, or a name is blank.</exception>
|
||||
public static HostOptions Create(IEnumerable<HostOption> options)
|
||||
{
|
||||
if (!TryCreate(options, out var result, out var error))
|
||||
{
|
||||
throw new ArgumentException(error, nameof(options));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a canonical collection, reporting rather than throwing on bad input.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The non-throwing overload exists because these values arrive from two places neither of which
|
||||
/// is trusted: a decrypted payload written by another client, and an imported
|
||||
/// <c>ssh_config</c>. Neither should be able to raise an exception from inside a sync pass.
|
||||
/// </remarks>
|
||||
public static bool TryCreate(
|
||||
IEnumerable<HostOption> options,
|
||||
[NotNullWhen(true)] out HostOptions? result,
|
||||
[NotNullWhen(false)] out string? error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
result = null;
|
||||
var ordered = options.ToArray();
|
||||
|
||||
if (!Validate(ordered, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Array.Sort(
|
||||
ordered,
|
||||
static (left, right) => HostOption.NameComparer.Compare(left.Name, right.Name));
|
||||
|
||||
result = ordered.Length == 0 ? Empty : new HostOptions(ordered);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Looks up a directive by name, case-insensitively as SSH treats keywords.</summary>
|
||||
public bool TryGetValue(string name, [NotNullWhen(true)] out string? value)
|
||||
{
|
||||
foreach (var option in items)
|
||||
{
|
||||
if (HostOption.NameComparer.Equals(option.Name, name))
|
||||
{
|
||||
value = option.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(HostOptions? other)
|
||||
{
|
||||
if (ReferenceEquals(this, other))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (other is null || other.items.Length != items.Length || other.hash != hash)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return items.AsSpan().SequenceEqual(other.items);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => Equals(obj as HostOptions);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => hash;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerator<HostOption> GetEnumerator() => ((IEnumerable<HostOption>)items).GetEnumerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator();
|
||||
|
||||
/// <summary>Contents equality, tolerating nulls on either side.</summary>
|
||||
[SuppressMessage(
|
||||
"Usage",
|
||||
"CA2225:Operator overloads have named alternates",
|
||||
Justification = "Equals(HostOptions) is the named alternate.")]
|
||||
public static bool operator ==(HostOptions? left, HostOptions? right) =>
|
||||
left is null ? right is null : left.Equals(right);
|
||||
|
||||
/// <summary>Contents inequality.</summary>
|
||||
public static bool operator !=(HostOptions? left, HostOptions? right) => !(left == right);
|
||||
|
||||
/// <summary>Projects to a name-keyed map, for the per-directive merge.</summary>
|
||||
internal Dictionary<string, string> ToNameMap()
|
||||
{
|
||||
var map = new Dictionary<string, string>(items.Length, HostOption.NameComparer);
|
||||
|
||||
foreach (var option in items)
|
||||
{
|
||||
map[option.Name] = option.Value;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static bool Validate(HostOption[] ordered, [NotNullWhen(false)] out string? error)
|
||||
{
|
||||
foreach (var option in ordered)
|
||||
{
|
||||
if (option is null || string.IsNullOrWhiteSpace(option.Name))
|
||||
{
|
||||
error = "An SSH directive must have a name.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var duplicate = ordered
|
||||
.GroupBy(o => o.Name, HostOption.NameComparer)
|
||||
.FirstOrDefault(g => g.Count() > 1);
|
||||
|
||||
if (duplicate is not null)
|
||||
{
|
||||
error = $"The directive '{duplicate.Key}' appears more than once; M1 requires unique names.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ComputeHash(HostOption[] items)
|
||||
{
|
||||
var accumulator = new HashCode();
|
||||
accumulator.Add(items.Length);
|
||||
|
||||
foreach (var option in items)
|
||||
{
|
||||
accumulator.Add(option);
|
||||
}
|
||||
|
||||
return accumulator.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A host as the user sees it: everything the server never gets to read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The whole of this record lives inside the item's encrypted payload. In particular there is no
|
||||
/// plaintext label anywhere in the system — access-control administration runs on the client, which
|
||||
/// can decrypt names, so the server never needs a searchable title.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Hostname"/> and <see cref="Port"/> are here <em>and</em> may additionally appear as
|
||||
/// plaintext columns on the server, but only for a host the user has opted into the relay. That is
|
||||
/// the one deliberate privacy concession in the design: the relay must resolve its target
|
||||
/// server-side or it becomes an authenticated open TCP proxy into the operator's own network. The
|
||||
/// copy in here is the authoritative one; the plaintext column is a derived duplicate the client
|
||||
/// supplies only when relay is enabled. See ADR 0004.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Structural equality holds across every field, including the collections, which is what the merge
|
||||
/// relies on to tell "unchanged" from "changed to the same thing" from "changed differently".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record HostSecret
|
||||
{
|
||||
/// <summary>The default SSH port, used when a host does not say otherwise.</summary>
|
||||
public const int DefaultPort = 22;
|
||||
|
||||
/// <summary>Display name. The only name this host has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Hostname or address to connect to.</summary>
|
||||
public required string Hostname { get; init; }
|
||||
|
||||
/// <summary>TCP port.</summary>
|
||||
public int Port { get; init; } = DefaultPort;
|
||||
|
||||
/// <summary>Login user, when the host pins one.</summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Free-text notes.</summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The jump chain, nearest hop first, as host item ids.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Order is the meaning here, so this merges as a whole value rather than as a set: reordering a
|
||||
/// chain changes which machine is reached through which, and a set union of two different chains
|
||||
/// would produce a route neither user asked for.
|
||||
/// </remarks>
|
||||
public JumpChain JumpHostIds { get; init; } = JumpChain.Empty;
|
||||
|
||||
/// <summary>SSH directives, unique by name.</summary>
|
||||
public HostOptions Options { get; init; } = HostOptions.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this host may be dialled through the server relay.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Lives here, inside the encrypted payload, rather than only in the plaintext columns the server
|
||||
/// keeps. It has to: it is the flag that decides whether <see cref="Hostname"/> and
|
||||
/// <see cref="Port"/> are copied out into those columns, and a setting the merge cannot see is a
|
||||
/// setting two clients can silently disagree about — one of them re-exposing an address the other
|
||||
/// had just withdrawn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The plaintext copy is derived from this, in one place, so the address can only ever leave the
|
||||
/// payload as a consequence of the user turning this on. See ADR 0004.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool RelayEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks the fields that must hold before this can be stored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from construction on purpose. A view model binds directly to these properties and
|
||||
/// passes through empty and half-typed states on the way to a valid one; a constructor that threw
|
||||
/// would make the editor unusable. The sync layer validates before sealing, and the codec
|
||||
/// validates on decode, which are the two points where an invalid host would become durable.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
error = "A host needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Hostname))
|
||||
{
|
||||
error = "A host needs a hostname or address.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Port is < 1 or > 65535)
|
||||
{
|
||||
error = $"Port must be between 1 and 65535, not {Port}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (JumpHostIds.AsSpan().Contains(Guid.Empty))
|
||||
{
|
||||
error = "A jump chain cannot contain an empty host id.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded host payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Host">The host.</param>
|
||||
/// <param name="SchemaVersion">
|
||||
/// The version the writing client used. May exceed
|
||||
/// <see cref="HostSecretCodec.CurrentSchemaVersion"/>, which is the case this type exists to make
|
||||
/// visible.
|
||||
/// </param>
|
||||
public sealed record HostSecretDocument(HostSecret Host, int SchemaVersion)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this payload was written by a newer client than the one reading it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Such an item is safe to <em>read</em> — every field this build knows about decodes normally —
|
||||
/// but must not be re-encoded, because fields added by the newer schema are not represented here
|
||||
/// and would be dropped on write. Silently losing a field a colleague filled in is exactly the
|
||||
/// class of bug that makes people stop trusting a synced vault.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the rule is: display it, refuse to edit it, and tell the user to update. Preserving unknown
|
||||
/// fields through a round trip was the alternative and it is worse — it means carrying opaque
|
||||
/// JSON inside the domain model, which then has no usable structural equality and so breaks the
|
||||
/// merge.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool IsReadOnly => SchemaVersion > HostSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a host item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// JSON rather than the fixed binary layouts used elsewhere in the specification. The reasoning
|
||||
/// differs because the constraints differ: those layouts are hashed or signed, so canonicality is
|
||||
/// load-bearing, whereas this is only ever encrypted. What matters here instead is that the format
|
||||
/// grows a field without a migration — and the one thing that must not happen is an old client
|
||||
/// quietly dropping a field a new one wrote, which is what <see cref="HostSecretDocument.IsReadOnly"/>
|
||||
/// prevents.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Encoding is deterministic: property order is fixed by declaration, and directives are held in a
|
||||
/// sorted map. That matters because the sync engine decides whether to push by comparing values, and
|
||||
/// a codec that produced different bytes for the same host would make every pass look like a change.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HostSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
public static byte[] Encode(HostSecret host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
|
||||
if (!host.TryValidate(out var error))
|
||||
{
|
||||
throw new ArgumentException(error, nameof(host));
|
||||
}
|
||||
|
||||
var options = new SortedDictionary<string, string>(HostOption.NameComparer);
|
||||
foreach (var option in host.Options)
|
||||
{
|
||||
options[option.Name] = option.Value;
|
||||
}
|
||||
|
||||
var document = new HostPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = host.Label,
|
||||
Hostname = host.Hostname,
|
||||
Port = host.Port,
|
||||
Username = host.Username,
|
||||
Notes = host.Notes,
|
||||
JumpHostIds = [.. host.JumpHostIds],
|
||||
Options = options,
|
||||
RelayEnabled = host.RelayEnabled,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, HostPayloadJsonContext.Default.HostPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a decrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns <see langword="false"/> rather than throwing on anything malformed. These bytes
|
||||
/// authenticated under a key only vault members hold, so a failure here is not an attack — it is
|
||||
/// a bug in some client, or a truncated write. Either way it must degrade to one unreadable item
|
||||
/// rather than an exception that aborts the whole sync pass and strands every other change.
|
||||
/// </remarks>
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out HostSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
HostPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, HostPayloadJsonContext.Default.HostPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryBuild(parsed, out var host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new HostSecretDocument(host, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryBuild(
|
||||
HostPayloadDocument parsed,
|
||||
[NotNullWhen(true)] out HostSecret? host)
|
||||
{
|
||||
host = null;
|
||||
|
||||
var directives = (parsed.Options ?? [])
|
||||
.Select(entry => new HostOption(entry.Key, entry.Value));
|
||||
|
||||
if (!HostOptions.TryCreate(directives, out var options, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new HostSecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
Hostname = parsed.Hostname ?? string.Empty,
|
||||
Port = parsed.Port,
|
||||
Username = parsed.Username,
|
||||
Notes = parsed.Notes,
|
||||
JumpHostIds = JumpChain.Create(parsed.JumpHostIds ?? []),
|
||||
Options = options,
|
||||
RelayEnabled = parsed.RelayEnabled,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
host = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The serialised shape. Mutable and nullable because it models untrusted input.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately separate from <see cref="HostSecret"/>. A single type would force the domain model to
|
||||
/// carry the serialiser's requirements — a parameterless constructor, settable properties, nullable
|
||||
/// everything — and would let a decode failure produce a half-built host that looks valid to
|
||||
/// everything downstream.
|
||||
/// </remarks>
|
||||
internal sealed class HostPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
public string? Hostname { get; set; }
|
||||
|
||||
public int Port { get; set; }
|
||||
|
||||
public string? Username { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public Guid[]? JumpHostIds { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Sorted, so serialisation order is defined by the type rather than by insertion order — a
|
||||
/// plain <see cref="Dictionary{TKey,TValue}"/> does not guarantee enumeration order, and this
|
||||
/// encoding has to be reproducible.
|
||||
/// </remarks>
|
||||
public SortedDictionary<string, string>? Options { get; set; }
|
||||
|
||||
public bool RelayEnabled { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
// An unknown member means a newer client wrote a field this build has no concept of. Skipping it
|
||||
// is right; the guard against losing it lives in HostSecretDocument.IsReadOnly.
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(HostPayloadDocument))]
|
||||
internal sealed partial class HostPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A value the merge had to override, kept so the user can see it and put it back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This record is the reason the merge is allowed to pick a winner at all. Choosing a side is only
|
||||
/// acceptable because the other side is preserved verbatim and surfaced; without that, a
|
||||
/// field-level merge is just last-writer-wins with extra steps.
|
||||
/// </remarks>
|
||||
/// <param name="Field">
|
||||
/// Which field, as a path. A directive reads <c>Options[ServerAliveInterval]</c> so the user is told
|
||||
/// which one rather than merely that "options" changed.
|
||||
/// </param>
|
||||
/// <param name="DiscardedSide">Whose intent was overridden.</param>
|
||||
/// <param name="Kept">The value that survives, rendered for display.</param>
|
||||
/// <param name="Discarded">The value that lost, rendered for display.</param>
|
||||
/// <param name="DiscardedWasRemoval">
|
||||
/// True when what lost was a deletion rather than a different value.
|
||||
/// </param>
|
||||
public sealed record HostFieldConflict(
|
||||
string Field,
|
||||
MergeSide DiscardedSide,
|
||||
string? Kept,
|
||||
string? Discarded,
|
||||
bool DiscardedWasRemoval);
|
||||
|
||||
/// <summary>The merged host, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The host to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record HostMergeResult(
|
||||
HostSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a host against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Called when a pull brings down a change to an item that also has a local edit pending, and again
|
||||
/// when a push comes back <c>Conflict</c> carrying the server's current row. Both paths need the
|
||||
/// same answer, so both go through here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Scalar fields defer to the server on a genuine clash and the jump chain merges as a whole value,
|
||||
/// because its order is its meaning. Directives merge per name, which is what lets two people each
|
||||
/// add one and both keep it. See <see cref="ThreeWayMerge"/> for why the remote side wins.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HostSecretMerge
|
||||
{
|
||||
/// <summary>
|
||||
/// Produces the merged host.
|
||||
/// </summary>
|
||||
/// <param name="ancestor">
|
||||
/// The version both sides branched from — the ciphertext the client retained when it queued its
|
||||
/// local edit. Without it this degrades to a two-way diff, which cannot tell an edit from a
|
||||
/// revert and so cannot avoid resurrecting deleted values.
|
||||
/// </param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static HostMergeResult Merge(HostSecret ancestor, HostSecret local, HostSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merged = new HostSecret
|
||||
{
|
||||
Label = Text(nameof(HostSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts),
|
||||
Hostname = Text(
|
||||
nameof(HostSecret.Hostname), ancestor.Hostname, local.Hostname, remote.Hostname, conflicts),
|
||||
Port = Field(
|
||||
nameof(HostSecret.Port),
|
||||
ancestor.Port,
|
||||
local.Port,
|
||||
remote.Port,
|
||||
conflicts,
|
||||
static port => port.ToString(CultureInfo.InvariantCulture)),
|
||||
Username = Text(
|
||||
nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
|
||||
Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
|
||||
JumpHostIds = Field(
|
||||
nameof(HostSecret.JumpHostIds),
|
||||
ancestor.JumpHostIds,
|
||||
local.JumpHostIds,
|
||||
remote.JumpHostIds,
|
||||
conflicts,
|
||||
FormatChain),
|
||||
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
|
||||
RelayEnabled = Field(
|
||||
nameof(HostSecret.RelayEnabled),
|
||||
ancestor.RelayEnabled,
|
||||
local.RelayEnabled,
|
||||
remote.RelayEnabled,
|
||||
conflicts,
|
||||
static enabled => enabled ? "enabled" : "disabled"),
|
||||
};
|
||||
|
||||
return new HostMergeResult(merged, conflicts);
|
||||
}
|
||||
|
||||
private static string Text(
|
||||
string name,
|
||||
string? ancestor,
|
||||
string? local,
|
||||
string? remote,
|
||||
List<HostFieldConflict> conflicts) =>
|
||||
Field(name, ancestor, local, remote, conflicts, static value => value, StringComparer.Ordinal)!;
|
||||
|
||||
/// <remarks>
|
||||
/// A scalar clash always overrides the local side — see <see cref="ThreeWayMerge"/> — so the
|
||||
/// discarded side is fixed here rather than derived.
|
||||
/// </remarks>
|
||||
private static T Field<T>(
|
||||
string name,
|
||||
T ancestor,
|
||||
T local,
|
||||
T remote,
|
||||
List<HostFieldConflict> conflicts,
|
||||
Func<T, string?> format,
|
||||
IEqualityComparer<T>? comparer = null)
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
name,
|
||||
MergeSide.Local,
|
||||
format(merge.Value),
|
||||
merge.Discarded is null ? null : format(merge.Discarded),
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return merge.Value;
|
||||
}
|
||||
|
||||
private static HostOptions MergeOptions(
|
||||
HostOptions ancestor,
|
||||
HostOptions local,
|
||||
HostOptions remote,
|
||||
List<HostFieldConflict> conflicts)
|
||||
{
|
||||
var merge = ThreeWayMerge.Map(
|
||||
ancestor.ToNameMap(),
|
||||
local.ToNameMap(),
|
||||
remote.ToNameMap(),
|
||||
HostOption.NameComparer,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
foreach (var conflict in merge.Conflicts)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
$"{nameof(HostSecret.Options)}[{conflict.Key}]",
|
||||
conflict.DiscardedSide,
|
||||
conflict.Kept,
|
||||
conflict.Discarded,
|
||||
conflict.DiscardedWasRemoval));
|
||||
}
|
||||
|
||||
// The merged map is keyed by the same comparer, so uniqueness already holds and Create
|
||||
// cannot throw here.
|
||||
return HostOptions.Create(
|
||||
merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value)));
|
||||
}
|
||||
|
||||
private static string FormatChain(JumpChain chain) =>
|
||||
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// An ordered route to a host: the intermediate hosts to tunnel through, nearest hop first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A dedicated type rather than a list of ids, for two reasons. It compares by contents, which the
|
||||
/// merge depends on — a plain <see cref="IReadOnlyList{T}"/> on a record gets reference equality from
|
||||
/// the compiler-generated <c>Equals</c>, so every host would read as changed on every sync pass and
|
||||
/// two identical edits would register as a conflict. And it names the thing: the order here is the
|
||||
/// route, so this is not a set and must never be merged as one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Duplicate and empty hops are not rejected at construction. They arrive from a decrypted payload
|
||||
/// written by another client, and a constructor that threw would turn one bad item into a failed sync
|
||||
/// pass for every other item behind it. <see cref="HostSecret.TryValidate"/> is where that is caught.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class JumpChain : IReadOnlyList<Guid>, IEquatable<JumpChain>
|
||||
{
|
||||
private readonly Guid[] hops;
|
||||
private readonly int hash;
|
||||
|
||||
private JumpChain(Guid[] hops)
|
||||
{
|
||||
this.hops = hops;
|
||||
hash = ComputeHash(hops);
|
||||
}
|
||||
|
||||
/// <summary>A direct connection: no intermediate hosts.</summary>
|
||||
public static JumpChain Empty { get; } = new([]);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count => hops.Length;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid this[int index] => hops[index];
|
||||
|
||||
/// <summary>Copies a sequence of hops, preserving order.</summary>
|
||||
public static JumpChain Create(IEnumerable<Guid> hops)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hops);
|
||||
|
||||
var copy = hops.ToArray();
|
||||
return copy.Length == 0 ? Empty : new JumpChain(copy);
|
||||
}
|
||||
|
||||
/// <summary>Copies a span of hops, preserving order.</summary>
|
||||
public static JumpChain Create(ReadOnlySpan<Guid> hops) =>
|
||||
hops.IsEmpty ? Empty : new JumpChain(hops.ToArray());
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(JumpChain? other)
|
||||
{
|
||||
if (ReferenceEquals(this, other))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return other is not null
|
||||
&& other.hash == hash
|
||||
&& hops.AsSpan().SequenceEqual(other.hops);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => Equals(obj as JumpChain);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => hash;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerator<Guid> GetEnumerator() => ((IEnumerable<Guid>)hops).GetEnumerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
IEnumerator IEnumerable.GetEnumerator() => hops.GetEnumerator();
|
||||
|
||||
/// <summary>The hops, without copying.</summary>
|
||||
public ReadOnlySpan<Guid> AsSpan() => hops;
|
||||
|
||||
/// <summary>Contents equality, tolerating nulls on either side.</summary>
|
||||
[SuppressMessage(
|
||||
"Usage",
|
||||
"CA2225:Operator overloads have named alternates",
|
||||
Justification = "Equals(JumpChain) is the named alternate.")]
|
||||
public static bool operator ==(JumpChain? left, JumpChain? right) =>
|
||||
left is null ? right is null : left.Equals(right);
|
||||
|
||||
/// <summary>Contents inequality.</summary>
|
||||
public static bool operator !=(JumpChain? left, JumpChain? right) => !(left == right);
|
||||
|
||||
private static int ComputeHash(Guid[] hops)
|
||||
{
|
||||
// Order-sensitive, because reordering a route changes which machine is reached through which.
|
||||
var accumulator = new HashCode();
|
||||
accumulator.Add(hops.Length);
|
||||
|
||||
foreach (var hop in hops)
|
||||
{
|
||||
accumulator.Add(hop);
|
||||
}
|
||||
|
||||
return accumulator.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>Which of the two diverging replicas a value came from.</summary>
|
||||
public enum MergeSide
|
||||
{
|
||||
/// <summary>The edit made on this machine.</summary>
|
||||
Local = 0,
|
||||
|
||||
/// <summary>The edit that arrived from the server.</summary>
|
||||
Remote = 1,
|
||||
}
|
||||
|
||||
/// <summary>How a single field was resolved.</summary>
|
||||
public enum MergeDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Both sides hold the same value — either neither changed it, or both made the identical
|
||||
/// change. Distinguishing those two is not useful: the outcome is the same and no one is
|
||||
/// surprised.
|
||||
/// </summary>
|
||||
Agreed = 0,
|
||||
|
||||
/// <summary>Only this machine changed it.</summary>
|
||||
TookLocal = 1,
|
||||
|
||||
/// <summary>Only the server side changed it.</summary>
|
||||
TookRemote = 2,
|
||||
|
||||
/// <summary>Both changed it, differently. One value survives and the other is reported.</summary>
|
||||
Conflicted = 3,
|
||||
}
|
||||
|
||||
/// <summary>The outcome of merging one field.</summary>
|
||||
/// <typeparam name="T">The field's type.</typeparam>
|
||||
/// <param name="Value">The value to keep.</param>
|
||||
/// <param name="Decision">How it was resolved.</param>
|
||||
/// <param name="Discarded">
|
||||
/// The value that lost, meaningful only when <paramref name="Decision"/> is
|
||||
/// <see cref="MergeDecision.Conflicted"/>. Never simply dropped: the caller is expected to record it.
|
||||
/// </param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct FieldMerge<T>(T Value, MergeDecision Decision, T? Discarded)
|
||||
{
|
||||
/// <summary>Whether both sides changed this field to different values.</summary>
|
||||
public bool IsConflicted => Decision == MergeDecision.Conflicted;
|
||||
}
|
||||
|
||||
/// <summary>A key whose value both sides changed, or which one side removed while the other edited.</summary>
|
||||
/// <typeparam name="TKey">Key type.</typeparam>
|
||||
/// <typeparam name="TValue">Value type.</typeparam>
|
||||
/// <param name="Key">The key in question.</param>
|
||||
/// <param name="Kept">The value that survives, or <see langword="default"/> if the key is removed.</param>
|
||||
/// <param name="DiscardedSide">Which replica's intent was overridden.</param>
|
||||
/// <param name="Discarded">
|
||||
/// The value that lost, or <see langword="default"/> when what lost was a removal.
|
||||
/// </param>
|
||||
/// <param name="DiscardedWasRemoval">
|
||||
/// True when the overridden intent was to remove the key rather than to set it to a different value.
|
||||
/// </param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct MapConflict<TKey, TValue>(
|
||||
TKey Key,
|
||||
TValue? Kept,
|
||||
MergeSide DiscardedSide,
|
||||
TValue? Discarded,
|
||||
bool DiscardedWasRemoval);
|
||||
|
||||
/// <summary>The outcome of merging a keyed collection.</summary>
|
||||
/// <typeparam name="TKey">Key type.</typeparam>
|
||||
/// <typeparam name="TValue">Value type.</typeparam>
|
||||
/// <param name="Merged">The resulting collection.</param>
|
||||
/// <param name="Conflicts">Every key where the two sides disagreed.</param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct MapMerge<TKey, TValue>(
|
||||
IReadOnlyDictionary<TKey, TValue> Merged,
|
||||
IReadOnlyList<MapConflict<TKey, TValue>> Conflicts)
|
||||
where TKey : notnull;
|
||||
|
||||
/// <summary>
|
||||
/// The merge primitives: resolve a field, or a keyed collection, from a common ancestor and two
|
||||
/// divergent versions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The server cannot do any of this — it cannot read a payload, so it cannot merge one. That is why
|
||||
/// a conflicting push comes back with the server's current row rather than being resolved for us,
|
||||
/// and why this code is the last line of defence against losing a credential.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why the remote side wins a genuine clash.</b> It has to be one of them, and it has to be the
|
||||
/// same one on every replica. If each client kept its own value, two clients would resolve the same
|
||||
/// triple in opposite directions, each push would conflict with the other's, and they would ping-pong
|
||||
/// forever without converging. Deferring to the value already on the server converges in one round.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The losing value is <em>never</em> discarded silently. Every primitive returns it, the item-level
|
||||
/// merge collects them, and the sync engine writes them to a conflict log the user can act on. This
|
||||
/// is the whole point: a merge that quietly drops the password someone just typed is worse than one
|
||||
/// that refuses to merge at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ThreeWayMerge
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves one field.
|
||||
/// </summary>
|
||||
/// <param name="ancestor">The value both sides started from.</param>
|
||||
/// <param name="local">This machine's value.</param>
|
||||
/// <param name="remote">The server's value.</param>
|
||||
/// <param name="comparer">Value comparison; defaults to <see cref="EqualityComparer{T}.Default"/>.</param>
|
||||
public static FieldMerge<T> Scalar<T>(
|
||||
T ancestor,
|
||||
T local,
|
||||
T remote,
|
||||
IEqualityComparer<T>? comparer = null)
|
||||
{
|
||||
comparer ??= EqualityComparer<T>.Default;
|
||||
|
||||
// Checked first, so two people making the identical edit is agreement rather than a
|
||||
// conflict they have to be bothered about.
|
||||
if (comparer.Equals(local, remote))
|
||||
{
|
||||
return new FieldMerge<T>(local, MergeDecision.Agreed, default);
|
||||
}
|
||||
|
||||
if (comparer.Equals(local, ancestor))
|
||||
{
|
||||
return new FieldMerge<T>(remote, MergeDecision.TookRemote, default);
|
||||
}
|
||||
|
||||
if (comparer.Equals(remote, ancestor))
|
||||
{
|
||||
return new FieldMerge<T>(local, MergeDecision.TookLocal, default);
|
||||
}
|
||||
|
||||
return new FieldMerge<T>(remote, MergeDecision.Conflicted, local);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a keyed collection key by key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per-key rather than whole-collection, which is the difference between two people each adding
|
||||
/// a directive and both keeping it, versus one of them losing theirs to a conflict. That is the
|
||||
/// single most visible benefit of a field-level merge over last-writer-wins.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An edit beats a removal.</b> Where one side deleted a key and the other changed its value,
|
||||
/// the value survives and the removal is reported. The asymmetry is deliberate and it is not a
|
||||
/// preference: re-applying a removal costs one click, while a discarded value may be the only
|
||||
/// copy of something the user cannot reconstruct.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ancestor">The state both sides started from.</param>
|
||||
/// <param name="local">This machine's state.</param>
|
||||
/// <param name="remote">The server's state.</param>
|
||||
/// <param name="keyComparer">Defines key identity.</param>
|
||||
/// <param name="valueComparer">Value comparison; defaults to <see cref="EqualityComparer{T}.Default"/>.</param>
|
||||
public static MapMerge<TKey, TValue> Map<TKey, TValue>(
|
||||
IReadOnlyDictionary<TKey, TValue> ancestor,
|
||||
IReadOnlyDictionary<TKey, TValue> local,
|
||||
IReadOnlyDictionary<TKey, TValue> remote,
|
||||
IEqualityComparer<TKey> keyComparer,
|
||||
IEqualityComparer<TValue>? valueComparer = null)
|
||||
where TKey : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
ArgumentNullException.ThrowIfNull(keyComparer);
|
||||
|
||||
valueComparer ??= EqualityComparer<TValue>.Default;
|
||||
|
||||
var merged = new Dictionary<TKey, TValue>(keyComparer);
|
||||
var conflicts = new List<MapConflict<TKey, TValue>>();
|
||||
|
||||
foreach (var key in UnionOfKeys(ancestor, local, remote, keyComparer))
|
||||
{
|
||||
var a = Slot.For(ancestor, key);
|
||||
var l = Slot.For(local, key);
|
||||
var r = Slot.For(remote, key);
|
||||
|
||||
var resolved = ResolveKey(key, a, l, r, valueComparer, conflicts);
|
||||
|
||||
if (resolved.Present)
|
||||
{
|
||||
merged[key] = resolved.Value!;
|
||||
}
|
||||
}
|
||||
|
||||
return new MapMerge<TKey, TValue>(merged, conflicts);
|
||||
}
|
||||
|
||||
/// <summary>One key's state on one replica: present with a value, or absent.</summary>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private readonly record struct Slot<TValue>(bool Present, TValue? Value)
|
||||
{
|
||||
internal bool Matches(in Slot<TValue> other, IEqualityComparer<TValue> comparer) =>
|
||||
Present == other.Present
|
||||
&& (!Present || comparer.Equals(Value!, other.Value!));
|
||||
}
|
||||
|
||||
private static class Slot
|
||||
{
|
||||
internal static Slot<TValue> For<TKey, TValue>(
|
||||
IReadOnlyDictionary<TKey, TValue> source,
|
||||
TKey key) =>
|
||||
source.TryGetValue(key, out var value)
|
||||
? new Slot<TValue>(true, value)
|
||||
: new Slot<TValue>(false, default);
|
||||
}
|
||||
|
||||
private static Slot<TValue> ResolveKey<TKey, TValue>(
|
||||
TKey key,
|
||||
in Slot<TValue> ancestor,
|
||||
in Slot<TValue> local,
|
||||
in Slot<TValue> remote,
|
||||
IEqualityComparer<TValue> valueComparer,
|
||||
List<MapConflict<TKey, TValue>> conflicts)
|
||||
{
|
||||
if (local.Matches(remote, valueComparer))
|
||||
{
|
||||
return local;
|
||||
}
|
||||
|
||||
if (local.Matches(ancestor, valueComparer))
|
||||
{
|
||||
return remote;
|
||||
}
|
||||
|
||||
if (remote.Matches(ancestor, valueComparer))
|
||||
{
|
||||
return local;
|
||||
}
|
||||
|
||||
// Both sides moved. Prefer whichever still holds a value, so an edit outlives a removal;
|
||||
// where both hold one, defer to the server so every replica converges the same way.
|
||||
var winner = remote.Present ? remote : local;
|
||||
var loserSide = remote.Present ? MergeSide.Local : MergeSide.Remote;
|
||||
var loser = remote.Present ? local : remote;
|
||||
|
||||
conflicts.Add(new MapConflict<TKey, TValue>(
|
||||
key,
|
||||
winner.Value,
|
||||
loserSide,
|
||||
loser.Present ? loser.Value : default,
|
||||
DiscardedWasRemoval: !loser.Present));
|
||||
|
||||
return winner;
|
||||
}
|
||||
|
||||
private static IEnumerable<TKey> UnionOfKeys<TKey, TValue>(
|
||||
IReadOnlyDictionary<TKey, TValue> ancestor,
|
||||
IReadOnlyDictionary<TKey, TValue> local,
|
||||
IReadOnlyDictionary<TKey, TValue> remote,
|
||||
IEqualityComparer<TKey> keyComparer)
|
||||
where TKey : notnull
|
||||
{
|
||||
var seen = new HashSet<TKey>(keyComparer);
|
||||
|
||||
foreach (var key in ancestor.Keys.Concat(local.Keys).Concat(remote.Keys))
|
||||
{
|
||||
if (seen.Add(key))
|
||||
{
|
||||
yield return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Translates between the payload columns and <see cref="EncryptedPayload"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The columns are nullable because a tombstone has no payload, but the three of them are all-or-
|
||||
/// nothing: an envelope without its wrapped data key is a row no client can ever open. Reconstructing
|
||||
/// through here rather than at each call site means that pairing is checked in one place.
|
||||
/// </remarks>
|
||||
internal static class CacheMapping
|
||||
{
|
||||
internal static EncryptedPayload? ToPayload(
|
||||
byte[]? envelope,
|
||||
byte[]? wrappedDataKey,
|
||||
Guid? dataKeyId,
|
||||
uint keyGeneration,
|
||||
byte aadVersion) =>
|
||||
envelope is null || wrappedDataKey is null || dataKeyId is null
|
||||
? null
|
||||
: new EncryptedPayload(envelope, wrappedDataKey, dataKeyId.Value, keyGeneration, aadVersion);
|
||||
|
||||
internal static EncryptedPayload? ToAncestorPayload(OutboxRow row) =>
|
||||
ToPayload(
|
||||
row.AncestorPayload,
|
||||
row.AncestorWrappedDataKey,
|
||||
row.AncestorDataKeyId,
|
||||
row.AncestorKeyGeneration ?? 0,
|
||||
row.AncestorAadVersion ?? 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the plaintext columns so they can be sealed as one unit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The whole record is sealed together rather than split into columns. Nothing queries these yet — the
|
||||
/// M1 interface lists every host in a vault — and the moment one field needs an index it gets its own
|
||||
/// column, at which point the duplication is deliberate and visible rather than pre-emptive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Goes through the Contracts serialiser rather than a hand-rolled encoding, so the local
|
||||
/// representation cannot drift from the wire one. That matters when re-pushing a change: what the
|
||||
/// server receives must be what the server sent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class PlaintextFieldsCodec
|
||||
{
|
||||
internal static byte[] Encode(SyncPlaintextFields fields) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(
|
||||
fields, DodoSshJsonContext.Default.SyncPlaintextFields);
|
||||
|
||||
/// <returns>
|
||||
/// The fields, or <see langword="null"/> if the bytes are not a record this build understands. A
|
||||
/// null must degrade to "treat the row as stale and re-pull", never to an exception inside a sync
|
||||
/// pass.
|
||||
/// </returns>
|
||||
internal static SyncPlaintextFields? TryDecode(ReadOnlySpan<byte> utf8)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(
|
||||
utf8, DodoSshJsonContext.Default.SyncPlaintextFields);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// What unlock needs, and nothing else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A single row: <see cref="Id"/> is always <see cref="SingletonId"/>. One cache database holds one
|
||||
/// server and one user. Multiple accounts are a real feature and they deserve their own design —
|
||||
/// which server a vault came from, which identity signed a grant, which profile a window belongs to
|
||||
/// — rather than a half-provision now that would have to be undone.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is the row that makes an offline launch work. The KDF salt and the wrapped bundle are cached
|
||||
/// here precisely so that unlock needs no network: fetching a salt at unlock time would mean the
|
||||
/// vault cannot be opened on a plane, which is the most common moment a user needs it. Neither is a
|
||||
/// secret — the salt is public by design and the bundle is ciphertext.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class UnlockMaterialRow
|
||||
{
|
||||
/// <summary>The only legal primary key.</summary>
|
||||
internal const int SingletonId = 1;
|
||||
|
||||
public int Id { get; set; } = SingletonId;
|
||||
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
|
||||
public string? Email { get; set; }
|
||||
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
public uint KeyGeneration { get; set; }
|
||||
|
||||
/// <summary>The secret bundle, wrapped under the passphrase-derived key. Ciphertext.</summary>
|
||||
public byte[] WrappedPrivateKey { get; set; } = [];
|
||||
|
||||
public string KdfAlgorithm { get; set; } = string.Empty;
|
||||
|
||||
public byte[] KdfSalt { get; set; } = [];
|
||||
|
||||
/// <summary>Kibibytes, matching both libsodium and the storage column on the server.</summary>
|
||||
public int KdfMemoryKibibytes { get; set; }
|
||||
|
||||
public int KdfPasses { get; set; }
|
||||
|
||||
public int KdfParallelism { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
||||
/// <remarks>
|
||||
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
||||
/// plaintext here for the same reason it is plaintext on the server: a user has to pick a vault
|
||||
/// before anything has been decrypted.
|
||||
/// </remarks>
|
||||
internal sealed class CachedVaultRow
|
||||
{
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public bool IsPersonal { get; set; }
|
||||
|
||||
public Guid? TeamId { get; set; }
|
||||
|
||||
public uint KeyGeneration { get; set; }
|
||||
|
||||
public int Permissions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The vault key sealed to this user's X25519 key. Null while a grant awaits re-wrap after a
|
||||
/// rekey, in which case the vault is temporarily unreadable.
|
||||
/// </summary>
|
||||
public byte[]? WrappedVaultKey { get; set; }
|
||||
|
||||
public bool RekeyRequired { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last state of an item that the server confirmed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Strictly a mirror: this row is what the server said, never what the user has typed but not yet
|
||||
/// pushed. Local edits live in <see cref="OutboxRow"/>, which also retains the ancestor they branched
|
||||
/// from. Keeping the two apart is what makes a three-way merge possible at all — a single row that
|
||||
/// held "current local state" would have overwritten the common ancestor and left only a two-way
|
||||
/// diff, which cannot tell an edit from a revert.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Payload"/> is the server's ciphertext byte for byte, so its AAD still verifies. Storing
|
||||
/// a re-encrypted copy would work but would throw away the ability to detect that the server handed
|
||||
/// back something it should not have.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class CachedItemRow
|
||||
{
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
public SyncEntityType EntityType { get; set; }
|
||||
|
||||
public Guid EntityId { get; set; }
|
||||
|
||||
/// <summary>The server-assigned item version, and the value a push must expect.</summary>
|
||||
public int Version { get; set; }
|
||||
|
||||
public long ChangeSequence { get; set; }
|
||||
|
||||
public byte[]? Payload { get; set; }
|
||||
|
||||
public byte[]? WrappedDataKey { get; set; }
|
||||
|
||||
public Guid? DataKeyId { get; set; }
|
||||
|
||||
public uint KeyGeneration { get; set; }
|
||||
|
||||
public byte AadVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The plaintext columns the server needs, sealed under the LocalCacheKey.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sealed rather than stored as columns because the cache can do better than the server here for
|
||||
/// free. The server must hold a relay-enabled host's address in the clear — it has to resolve it
|
||||
/// — but this machine already holds the key that decrypts the payload, so nothing is gained by
|
||||
/// leaving the address readable in a file that ends up in backups. No query needs these yet; when
|
||||
/// one does, the field it needs gets its own column and this comment gets revisited.
|
||||
/// </remarks>
|
||||
public byte[]? ProtectedFields { get; set; }
|
||||
|
||||
/// <summary>A tombstone. Deletes are never hard, or an offline client could not learn of them.</summary>
|
||||
public bool IsDeleted { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A local change that the server has not yet accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// At most one row per item, and it carries the ancestor it branched from. That ancestor is the
|
||||
/// entire reason a conflict can be merged rather than arbitrated: with it, the client can tell which
|
||||
/// side changed which field.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Attempts"/> and <see cref="LastError"/> exist so a permanently rejected operation can be
|
||||
/// parked and shown rather than retried forever. An operation the server calls
|
||||
/// <c>Invalid</c> will never succeed on retry, and spinning on it would block every change queued
|
||||
/// behind it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class OutboxRow
|
||||
{
|
||||
/// <summary>Local, monotonic. Defines the order changes are pushed in.</summary>
|
||||
public long Sequence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The server's idempotency key for this operation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Re-minted whenever the payload changes — see <c>OutboxStore.QueueAsync</c>. Keeping the old id
|
||||
/// across an edit would let the server answer <c>Duplicate</c> for an operation whose contents
|
||||
/// have since changed, silently discarding the newer edit.
|
||||
/// </remarks>
|
||||
public Guid OperationId { get; set; }
|
||||
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
public SyncEntityType EntityType { get; set; }
|
||||
|
||||
public Guid EntityId { get; set; }
|
||||
|
||||
public SyncOperation Operation { get; set; }
|
||||
|
||||
/// <summary>The version the client believes the server holds. Null means create.</summary>
|
||||
public int? ExpectedVersion { get; set; }
|
||||
|
||||
public byte[]? Payload { get; set; }
|
||||
|
||||
public byte[]? WrappedDataKey { get; set; }
|
||||
|
||||
public Guid? DataKeyId { get; set; }
|
||||
|
||||
public uint KeyGeneration { get; set; }
|
||||
|
||||
public byte AadVersion { get; set; }
|
||||
|
||||
public byte[]? ProtectedFields { get; set; }
|
||||
|
||||
// ---- The ancestor this edit branched from ----
|
||||
// Kept verbatim, including the fields the AAD binds, because without the generation, the data
|
||||
// key id and the version, the ancestor cannot be decrypted and the merge has no base.
|
||||
|
||||
public int? AncestorVersion { get; set; }
|
||||
|
||||
public byte[]? AncestorPayload { get; set; }
|
||||
|
||||
public byte[]? AncestorWrappedDataKey { get; set; }
|
||||
|
||||
public Guid? AncestorDataKeyId { get; set; }
|
||||
|
||||
public uint? AncestorKeyGeneration { get; set; }
|
||||
|
||||
public byte? AncestorAadVersion { get; set; }
|
||||
|
||||
public byte[]? AncestorProtectedFields { get; set; }
|
||||
|
||||
public DateTimeOffset QueuedAtUtc { get; set; }
|
||||
|
||||
public int Attempts { get; set; }
|
||||
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>Set when the server rejected this outright, so it stops being retried.</summary>
|
||||
public bool IsParked { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Where a vault's pull has reached.</summary>
|
||||
internal sealed class SyncStateRow
|
||||
{
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The last cursor the server issued. Opaque and integrity-tagged: a client must never
|
||||
/// construct or edit one, which is why this is stored verbatim and never parsed.
|
||||
/// </summary>
|
||||
public string? Cursor { get; set; }
|
||||
|
||||
public uint KeyGeneration { get; set; }
|
||||
|
||||
public DateTimeOffset? LastPulledAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? LastPushedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Observed difference between the server's clock and this machine's, from the last pull.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recorded rather than corrected. Local timestamps are display metadata, never a merge input —
|
||||
/// the merge uses versions and the retained ancestor — so a skewed clock must not be able to
|
||||
/// decide which edit wins.
|
||||
/// </remarks>
|
||||
public long ServerTimeSkewMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
|
||||
internal sealed class ConflictRow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
public SyncEntityType EntityType { get; set; }
|
||||
|
||||
public Guid EntityId { get; set; }
|
||||
|
||||
public ConflictKind Kind { get; set; }
|
||||
|
||||
/// <summary>The discarded values, sealed under the LocalCacheKey.</summary>
|
||||
/// <remarks>
|
||||
/// Sealed because this is the one place the cache deliberately holds decrypted vault content: the
|
||||
/// value a merge overrode. It has to be readable to be useful and it is exactly as sensitive as
|
||||
/// the item it came from.
|
||||
/// </remarks>
|
||||
public byte[] Detail { get; set; } = [];
|
||||
|
||||
public DateTimeOffset DetectedAtUtc { get; set; }
|
||||
|
||||
public bool Acknowledged { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using DodoSSH.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Stores a timestamp as Unix milliseconds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not a preference. SQLite has no date type, and EF's default mapping for
|
||||
/// <see cref="DateTimeOffset"/> is a text form that it then <b>refuses to order or compare</b> — any
|
||||
/// query with <c>ORDER BY</c> or a range filter on such a column throws
|
||||
/// <see cref="NotSupportedException"/> at execution time, not at model build. Collecting tombstones
|
||||
/// older than a cutoff and listing conflicts newest-first are both exactly that shape, so this was a
|
||||
/// crash waiting for the first user with a deleted host. Found by the tests that do both.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An integer also sorts and compares correctly by construction, which the text form does not once
|
||||
/// two rows carry different UTC offsets. The cost is losing sub-millisecond precision and normalising
|
||||
/// to UTC — neither of which matters here, and both of which docs/crypto.md §7 already does to every
|
||||
/// timestamp it signs over.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class UnixMillisecondsConverter : ValueConverter<DateTimeOffset, long>
|
||||
{
|
||||
/// <remarks>Public because EF instantiates this reflectively and needs a public constructor.</remarks>
|
||||
public UnixMillisecondsConverter()
|
||||
: base(
|
||||
value => value.ToUnixTimeMilliseconds(),
|
||||
value => DateTimeOffset.FromUnixTimeMilliseconds(value))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The local cache database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Public only because the migrations tooling needs to reach it. The row types stay internal and
|
||||
/// there are no <see cref="DbSet{TEntity}"/> properties: callers go through the stores, which is what
|
||||
/// keeps the sealing of protected columns from being something a call site can forget. Entities are
|
||||
/// registered explicitly in <see cref="OnModelCreating"/> and reached with
|
||||
/// <see cref="DbContext.Set{TEntity}()"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Migrations rather than <c>EnsureCreated</c>, even for a cache. The item rows are indeed disposable
|
||||
/// — worst case they re-pull from a null cursor — but <see cref="UnlockMaterialRow"/> is not: dropping
|
||||
/// it would mean a user who upgrades while offline cannot open their vault until they are back on the
|
||||
/// network, which is exactly the situation the offline unlock exists for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Applied as a convention rather than per property, so a timestamp added later cannot be the one
|
||||
/// that is left un-converted — which would fail only when something eventually sorted by it.
|
||||
/// </remarks>
|
||||
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configurationBuilder);
|
||||
|
||||
configurationBuilder.Properties<DateTimeOffset>().HaveConversion<UnixMillisecondsConverter>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(modelBuilder);
|
||||
|
||||
ConfigureUnlockMaterial(modelBuilder);
|
||||
ConfigureVaults(modelBuilder);
|
||||
ConfigureItems(modelBuilder);
|
||||
ConfigureOutbox(modelBuilder);
|
||||
ConfigureSyncState(modelBuilder);
|
||||
ConfigureConflicts(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureUnlockMaterial(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<UnlockMaterialRow>(entity =>
|
||||
{
|
||||
entity.ToTable(
|
||||
"unlock_material",
|
||||
// One server and one user per cache file. The constraint is here rather than only in
|
||||
// code so that a second row cannot appear through any path at all — including a
|
||||
// future migration written by someone who has not read this comment.
|
||||
table => table.HasCheckConstraint(
|
||||
"ck_unlock_material_singleton",
|
||||
$"id = {UnlockMaterialRow.SingletonId}"));
|
||||
|
||||
entity.HasKey(row => row.Id);
|
||||
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||
entity.Property(row => row.ServerUrl).IsRequired();
|
||||
entity.Property(row => row.Issuer).IsRequired();
|
||||
entity.Property(row => row.Subject).IsRequired();
|
||||
entity.Property(row => row.WrappedPrivateKey).IsRequired();
|
||||
entity.Property(row => row.KdfAlgorithm).IsRequired();
|
||||
entity.Property(row => row.KdfSalt).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||
{
|
||||
entity.ToTable("vault");
|
||||
entity.HasKey(row => row.VaultId);
|
||||
entity.Property(row => row.VaultId).ValueGeneratedNever();
|
||||
entity.Property(row => row.Name).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureItems(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<CachedItemRow>(entity =>
|
||||
{
|
||||
entity.ToTable("item");
|
||||
|
||||
// Composite rather than the entity id alone. Ids are UUIDv7 and globally unique in
|
||||
// practice, but making the vault part of the identity means a row can never be read out
|
||||
// of the wrong vault by a query that forgot to filter.
|
||||
entity.HasKey(row => new { row.VaultId, row.EntityType, row.EntityId });
|
||||
|
||||
entity.HasIndex(row => new { row.VaultId, row.EntityType });
|
||||
entity.HasIndex(row => new { row.VaultId, row.ChangeSequence });
|
||||
});
|
||||
|
||||
private static void ConfigureOutbox(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<OutboxRow>(entity =>
|
||||
{
|
||||
entity.ToTable("outbox");
|
||||
entity.HasKey(row => row.Sequence);
|
||||
entity.Property(row => row.Sequence).ValueGeneratedOnAdd();
|
||||
|
||||
// At most one pending operation per item, enforced by the database rather than by
|
||||
// convention. Two queued edits to one item would have to be pushed in order, and the
|
||||
// second would need the version the first produced — which is not known when it is
|
||||
// queued. Coalescing into this single row avoids the problem instead of managing it.
|
||||
entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId }).IsUnique();
|
||||
|
||||
// The drain order.
|
||||
entity.HasIndex(row => new { row.VaultId, row.IsParked, row.Sequence });
|
||||
|
||||
entity.HasIndex(row => row.OperationId).IsUnique();
|
||||
});
|
||||
|
||||
private static void ConfigureSyncState(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<SyncStateRow>(entity =>
|
||||
{
|
||||
entity.ToTable("sync_state");
|
||||
entity.HasKey(row => row.VaultId);
|
||||
entity.Property(row => row.VaultId).ValueGeneratedNever();
|
||||
});
|
||||
|
||||
private static void ConfigureConflicts(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<ConflictRow>(entity =>
|
||||
{
|
||||
entity.ToTable("conflict");
|
||||
entity.HasKey(row => row.Id);
|
||||
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||
entity.Property(row => row.Detail).IsRequired();
|
||||
entity.HasIndex(row => new { row.VaultId, row.Acknowledged });
|
||||
entity.HasIndex(row => new { row.VaultId, row.EntityType, row.EntityId });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the local cache and hands out short-lived contexts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A factory rather than one long-lived context, because a sync pass runs on a background task while
|
||||
/// the interface reads the same tables, and a <see cref="DbContext"/> is not thread-safe. Each store
|
||||
/// operation takes a context, does one unit of work and disposes it; SQLite serialises the writes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The alternative — a single context guarded by a lock — would work and would also silently
|
||||
/// accumulate a change tracker for the life of the process, which for a vault of thousands of items
|
||||
/// is both a leak and a source of stale reads.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>, IDisposable
|
||||
{
|
||||
private readonly DbContextOptions<ClientCacheContext> options;
|
||||
|
||||
/// <remarks>
|
||||
/// An in-memory SQLite database exists only while at least one connection to it is open, so the
|
||||
/// memory-backed factory holds one for its lifetime. Null for a file-backed one.
|
||||
/// </remarks>
|
||||
private readonly SqliteConnection? keepAlive;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
private ClientCacheFactory(string connectionString, SqliteConnection? keepAlive)
|
||||
{
|
||||
this.keepAlive = keepAlive;
|
||||
|
||||
options = new DbContextOptionsBuilder<ClientCacheContext>()
|
||||
.UseSqlite(connectionString)
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
}
|
||||
|
||||
/// <summary>Opens, or creates, a cache file.</summary>
|
||||
/// <param name="databasePath">Full path to the SQLite file.</param>
|
||||
public static ClientCacheFactory ForFile(string databasePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
|
||||
|
||||
var builder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = databasePath,
|
||||
// The cache is written by one process. WAL would buy concurrent readers we do not have
|
||||
// and would leave two extra files beside the database for a user to wonder about.
|
||||
Pooling = true,
|
||||
};
|
||||
|
||||
return new ClientCacheFactory(builder.ConnectionString, keepAlive: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a private in-memory cache, for tests and for a session that must leave no trace.
|
||||
/// </summary>
|
||||
/// <param name="name">
|
||||
/// Distinguishes one in-memory database from another. Two factories given the same name share
|
||||
/// storage, which is how a test can prove that data survives a context being disposed.
|
||||
/// </param>
|
||||
public static ClientCacheFactory ForMemory(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
var builder = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = name,
|
||||
Mode = SqliteOpenMode.Memory,
|
||||
Cache = SqliteCacheMode.Shared,
|
||||
};
|
||||
|
||||
var connection = new SqliteConnection(builder.ConnectionString);
|
||||
connection.Open();
|
||||
|
||||
return new ClientCacheFactory(builder.ConnectionString, connection);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ClientCacheContext CreateDbContext()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return new ClientCacheContext(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the schema up to date.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called by the client at startup, before unlock — it touches no encrypted content, only the
|
||||
/// shape of the tables. It must therefore never need a key, which is also why the schema is
|
||||
/// migrated rather than recreated.
|
||||
/// </remarks>
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
keepAlive?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Supplies a context to <c>dotnet ef</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exists only for the migrations tooling, which needs to build a model without running the
|
||||
/// application. The path is a throwaway: the tool reads the model, not the data.
|
||||
/// </remarks>
|
||||
public sealed class ClientCacheDesignTimeFactory : IDesignTimeDbContextFactory<ClientCacheContext>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ClientCacheContext CreateDbContext(string[] args) =>
|
||||
ClientCacheFactory.ForFile("dodossh-design-time.db").CreateDbContext();
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using DodoSSH.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// What the merge had to override, and what it could not process.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This table is what makes automatic merging defensible. The merge picks a winner field by field,
|
||||
/// which is only acceptable because the loser lands here verbatim and gets shown. Without it, a
|
||||
/// field-level merge is last-writer-wins with a longer explanation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The detail is sealed under the LocalCacheKey, because it is the one place the cache deliberately
|
||||
/// holds decrypted vault content — a password someone typed that another edit displaced. It is exactly
|
||||
/// as sensitive as the item it came from and is treated that way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ConflictStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a conflict.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The record's own id is generated here and the detail is bound to it, so one conflict's discarded
|
||||
/// values can never be read back against another's row.
|
||||
/// </remarks>
|
||||
public async Task<Guid> RecordAsync(
|
||||
Guid vaultId,
|
||||
SyncEntityType entityType,
|
||||
Guid entityId,
|
||||
ConflictKind kind,
|
||||
ReadOnlyMemory<byte> detail,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (kind == ConflictKind.Unspecified)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(kind), kind, "A conflict kind is required.");
|
||||
}
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var id = Guid.CreateVersion7();
|
||||
|
||||
context.Add(new ConflictRow
|
||||
{
|
||||
Id = id,
|
||||
VaultId = vaultId,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Kind = kind,
|
||||
Detail = protector.Protect(AadResourceTypes.For(entityType), id, detail.Span),
|
||||
DetectedAtUtc = clock.GetUtcNow(),
|
||||
Acknowledged = false,
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Reads conflicts for a vault, newest first.</summary>
|
||||
public async Task<IReadOnlyList<StoredConflict>> ListAsync(
|
||||
Guid vaultId,
|
||||
bool includeAcknowledged,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var query = context.Set<ConflictRow>()
|
||||
.AsNoTracking()
|
||||
.Where(row => row.VaultId == vaultId);
|
||||
|
||||
if (!includeAcknowledged)
|
||||
{
|
||||
query = query.Where(row => !row.Acknowledged);
|
||||
}
|
||||
|
||||
var rows = await query
|
||||
.OrderByDescending(row => row.DetectedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. rows.Select(ToStored)];
|
||||
}
|
||||
|
||||
/// <summary>Marks a conflict as dealt with.</summary>
|
||||
/// <remarks>
|
||||
/// Acknowledged rather than deleted, so the discarded value stays recoverable after the user has
|
||||
/// dismissed the notification. Someone who clicks past a warning and realises a minute later that
|
||||
/// they wanted the other value should still be able to get it.
|
||||
/// </remarks>
|
||||
public async Task<bool> AcknowledgeAsync(Guid conflictId, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var updated = await context.Set<ConflictRow>()
|
||||
.Where(row => row.Id == conflictId)
|
||||
.ExecuteUpdateAsync(row => row.SetProperty(r => r.Acknowledged, true), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
/// <summary>Removes an acknowledged conflict for good.</summary>
|
||||
public async Task<bool> DiscardAsync(Guid conflictId, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var removed = await context.Set<ConflictRow>()
|
||||
.Where(row => row.Id == conflictId && row.Acknowledged)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return removed > 0;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A detail that will not open surfaces as empty rather than as a failure. The conflict itself — its
|
||||
/// kind, its item, its timestamp — is still worth showing even when the discarded value has become
|
||||
/// unreadable, for instance after a passphrase change re-derived the cache key.
|
||||
/// </remarks>
|
||||
private StoredConflict ToStored(ConflictRow row) =>
|
||||
new(
|
||||
row.Id,
|
||||
row.VaultId,
|
||||
row.EntityType,
|
||||
row.EntityId,
|
||||
row.Kind,
|
||||
protector.TryUnprotect(AadResourceTypes.For(row.EntityType), row.Id, row.Detail) ?? [],
|
||||
row.DetectedAtUtc,
|
||||
row.Acknowledged);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The client's local cache: ciphertext exactly as the server returned it, the outbox of local
|
||||
changes not yet accepted, and the material an offline unlock needs.
|
||||
|
||||
SQLite through EF Core, and deliberately *not* SQLCipher. Item payloads arrive already
|
||||
encrypted under keys the server has never seen, so an encrypted database file would protect
|
||||
bytes that are protected already, at the cost of a native dependency and a licence obligation.
|
||||
The one thing that would genuinely be plaintext — a search index — is kept in memory and
|
||||
rebuilt on unlock. SQLitePCLRaw deprecated bundle_e_sqlcipher in 3.0 in any case.
|
||||
|
||||
Everything this project does store in the clear is either not a secret (a version number, a
|
||||
change sequence) or is sealed under the LocalCacheKey first.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="EFCore.NamingConventions" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Storage.Tests" />
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Sync" />
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Sync.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,192 @@
|
||||
using DodoSSH.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// The mirror of what the server holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every row here is server-confirmed state. Nothing the user has typed but not yet pushed appears in
|
||||
/// this table — that lives in <see cref="OutboxStore"/>, together with the ancestor it branched from.
|
||||
/// Keeping the two apart is what makes a three-way merge possible: a single table holding "the current
|
||||
/// local view" would have overwritten the ancestor and left only a two-way diff, which cannot tell an
|
||||
/// edit from a revert.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Requires an unlocked <see cref="LocalCacheProtector"/>, which is deliberate. The protected columns
|
||||
/// have to be sealed on every write and opened on every read, and a store that could be constructed
|
||||
/// without a key would be a store that could write one of them in the clear.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ItemStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector)
|
||||
{
|
||||
/// <summary>Reads one item, tombstones included.</summary>
|
||||
public async Task<StoredItem?> FindAsync(
|
||||
Guid vaultId,
|
||||
SyncEntityType entityType,
|
||||
Guid entityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<CachedItemRow>()
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return row is null ? null : ToStored(row);
|
||||
}
|
||||
|
||||
/// <summary>Reads every item of one kind in a vault.</summary>
|
||||
/// <param name="vaultId">The vault.</param>
|
||||
/// <param name="entityType">Kind of item.</param>
|
||||
/// <param name="includeDeleted">
|
||||
/// Whether to return tombstones. The interface wants them excluded; the sync engine wants them,
|
||||
/// because a tombstone is the only record that an item it once knew about has gone.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<IReadOnlyList<StoredItem>> ListAsync(
|
||||
Guid vaultId,
|
||||
SyncEntityType entityType,
|
||||
bool includeDeleted,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var query = context.Set<CachedItemRow>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.VaultId == vaultId && r.EntityType == entityType);
|
||||
|
||||
if (!includeDeleted)
|
||||
{
|
||||
query = query.Where(r => !r.IsDeleted);
|
||||
}
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(r => r.ChangeSequence)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. rows.Select(ToStored)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the server's version of an item, creating or replacing the row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately a blind overwrite. This is a mirror, and the server's answer is the truth about
|
||||
/// what the server holds; a local edit that must survive is in the outbox, and it is the sync
|
||||
/// engine's job to have merged it before calling this.
|
||||
/// </remarks>
|
||||
public async Task SaveAsync(StoredItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<CachedItemRow>()
|
||||
.SingleOrDefaultAsync(
|
||||
r => r.VaultId == item.VaultId
|
||||
&& r.EntityType == item.EntityType
|
||||
&& r.EntityId == item.EntityId,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new CachedItemRow
|
||||
{
|
||||
VaultId = item.VaultId,
|
||||
EntityType = item.EntityType,
|
||||
EntityId = item.EntityId,
|
||||
};
|
||||
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
Apply(row, item);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
|
||||
/// <remarks>
|
||||
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
|
||||
/// it indistinguishable from one this client has never seen, and it would silently reappear on the
|
||||
/// next full pull.
|
||||
/// </remarks>
|
||||
public async Task<int> CollectTombstonesAsync(
|
||||
Guid vaultId,
|
||||
DateTimeOffset olderThan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
return await context.Set<CachedItemRow>()
|
||||
.Where(r => r.VaultId == vaultId && r.IsDeleted && r.UpdatedAtUtc < olderThan)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void Apply(CachedItemRow row, StoredItem item)
|
||||
{
|
||||
row.Version = item.Version;
|
||||
row.ChangeSequence = item.ChangeSequence;
|
||||
row.IsDeleted = item.IsDeleted;
|
||||
row.UpdatedAtUtc = item.UpdatedAt;
|
||||
|
||||
row.Payload = item.Payload?.Envelope;
|
||||
row.WrappedDataKey = item.Payload?.WrappedDataKey;
|
||||
row.DataKeyId = item.Payload?.DataKeyId;
|
||||
row.KeyGeneration = item.Payload?.KeyGeneration ?? 0;
|
||||
row.AadVersion = item.Payload?.AadVersion ?? 0;
|
||||
|
||||
row.ProtectedFields = item.Fields is null
|
||||
? null
|
||||
: protector.Protect(
|
||||
AadResourceTypes.For(item.EntityType),
|
||||
item.EntityId,
|
||||
PlaintextFieldsCodec.Encode(item.Fields));
|
||||
}
|
||||
|
||||
private StoredItem ToStored(CachedItemRow row) =>
|
||||
new(
|
||||
row.VaultId,
|
||||
row.EntityType,
|
||||
row.EntityId,
|
||||
row.Version,
|
||||
row.ChangeSequence,
|
||||
CacheMapping.ToPayload(
|
||||
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
|
||||
OpenFields(row.EntityType, row.EntityId, row.ProtectedFields),
|
||||
row.IsDeleted,
|
||||
row.UpdatedAtUtc);
|
||||
|
||||
/// <remarks>
|
||||
/// A record that will not open is treated as absent rather than fatal. The cache is not the
|
||||
/// authority — a re-pull restores it — and the alternative is one stale row aborting a sync pass
|
||||
/// and stranding every change behind it.
|
||||
/// </remarks>
|
||||
private SyncPlaintextFields? OpenFields(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
|
||||
{
|
||||
if (sealedFields is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plaintext = protector.TryUnprotect(
|
||||
AadResourceTypes.For(entityType), entityId, sealedFields);
|
||||
|
||||
return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Seals the few things the local cache holds that are not already ciphertext.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The cache stores item payloads exactly as the server sent them, so they need no further
|
||||
/// protection. Two things do: the plaintext columns the server needs — a relay-enabled host's
|
||||
/// address, chiefly — and the values a merge overrode, which are decrypted vault content by
|
||||
/// definition. Both go through here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What this is and is not worth.</b> The key derives from the master key, so it exists only while
|
||||
/// the vault is unlocked and is never written anywhere. That makes a stolen laptop, a stray backup or
|
||||
/// a synced-to-cloud application folder yield nothing — which is the threat this addresses. It does
|
||||
/// <em>not</em> defend against a process running as the same user: that process can read this
|
||||
/// process's memory, and no on-disk measure changes it. docs/crypto.md §10 says the same about a
|
||||
/// compromised endpoint, and this layer does not pretend otherwise.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every record is bound to its own row, so a record cannot be moved to a different row of the same
|
||||
/// cache. For a relay address that is not academic: two swapped rows would aim one host's connection
|
||||
/// at another host's address.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LocalCacheProtector : IDisposable
|
||||
{
|
||||
private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
|
||||
private bool disposed;
|
||||
|
||||
private LocalCacheProtector(MasterKey master) => master.DeriveLocalCacheKey(key);
|
||||
|
||||
/// <summary>
|
||||
/// Derives the cache key from an unlocked master key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The master key is not retained. Only the subkey is, and it is domain-separated by its HKDF
|
||||
/// label from the key that wraps the secret bundle — the two live in very different threat models
|
||||
/// and must not be the same bytes.
|
||||
/// </remarks>
|
||||
public static LocalCacheProtector From(MasterKey master)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(master);
|
||||
|
||||
return new LocalCacheProtector(master);
|
||||
}
|
||||
|
||||
/// <summary>Seals a cache record, binding it to the row that will hold it.</summary>
|
||||
public byte[] Protect(
|
||||
CryptoSpec.AadResourceType resourceType,
|
||||
Guid recordId,
|
||||
ReadOnlySpan<byte> plaintext)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return DshCrypto.Seal(key, plaintext, DshAad.LocalCache(resourceType, recordId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a sealed cache record.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The plaintext, or <see langword="null"/> if the record does not belong to this row or this
|
||||
/// user. Null rather than an exception because a stale cache file is an ordinary situation — a
|
||||
/// changed passphrase re-derives a different key — and the caller's answer is to discard the row
|
||||
/// and re-pull, not to fail.
|
||||
/// </returns>
|
||||
public byte[]? TryUnprotect(
|
||||
CryptoSpec.AadResourceType resourceType,
|
||||
Guid recordId,
|
||||
ReadOnlySpan<byte> envelope)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return DshCrypto.Open(key, envelope, DshAad.LocalCache(resourceType, recordId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a syncable entity type onto the resource type its AAD binds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
|
||||
/// same list: <see cref="CryptoSpec.AadResourceType"/> also covers users, devices and vaults, so the
|
||||
/// numbers do not line up, and a cast would bind an item's ciphertext to the wrong resource type
|
||||
/// without failing anywhere a test would notice.
|
||||
/// </remarks>
|
||||
internal static class AadResourceTypes
|
||||
{
|
||||
internal static CryptoSpec.AadResourceType For(SyncEntityType entityType) => entityType switch
|
||||
{
|
||||
SyncEntityType.Host => CryptoSpec.AadResourceType.Host,
|
||||
SyncEntityType.Credential => CryptoSpec.AadResourceType.Credential,
|
||||
SyncEntityType.SshKey => CryptoSpec.AadResourceType.SshKey,
|
||||
SyncEntityType.HostGroup => CryptoSpec.AadResourceType.HostGroup,
|
||||
SyncEntityType.Tag => CryptoSpec.AadResourceType.Tag,
|
||||
SyncEntityType.HostTag => CryptoSpec.AadResourceType.HostTag,
|
||||
SyncEntityType.HostCredential => CryptoSpec.AadResourceType.HostCredential,
|
||||
SyncEntityType.Snippet => CryptoSpec.AadResourceType.Snippet,
|
||||
SyncEntityType.PortForward => CryptoSpec.AadResourceType.PortForward,
|
||||
SyncEntityType.KnownHostKey => CryptoSpec.AadResourceType.KnownHostKey,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
|
||||
};
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
// <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("20260729080003_InitialCache")]
|
||||
partial class InitialCache
|
||||
{
|
||||
/// <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.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
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.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<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<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,211 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCache : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "conflict",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
kind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
detail = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||
detected_at_utc = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
acknowledged = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_conflict", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "item",
|
||||
columns: table => new
|
||||
{
|
||||
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
version = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
change_sequence = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
payload = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||
aad_version = table.Column<byte>(type: "INTEGER", nullable: false),
|
||||
protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
is_deleted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_item", x => new { x.vault_id, x.entity_type, x.entity_id });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox",
|
||||
columns: table => new
|
||||
{
|
||||
sequence = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
operation_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
entity_type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
entity_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
operation = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
expected_version = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
payload = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||
aad_version = table.Column<byte>(type: "INTEGER", nullable: false),
|
||||
protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
ancestor_version = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
ancestor_payload = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
ancestor_wrapped_data_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
ancestor_data_key_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
ancestor_key_generation = table.Column<uint>(type: "INTEGER", nullable: true),
|
||||
ancestor_aad_version = table.Column<byte>(type: "INTEGER", nullable: true),
|
||||
ancestor_protected_fields = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
queued_at_utc = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
attempts = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
last_error = table.Column<string>(type: "TEXT", nullable: true),
|
||||
is_parked = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox", x => x.sequence);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "sync_state",
|
||||
columns: table => new
|
||||
{
|
||||
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
cursor = table.Column<string>(type: "TEXT", nullable: true),
|
||||
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||
last_pulled_at_utc = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
last_pushed_at_utc = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
server_time_skew_ms = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_sync_state", x => x.vault_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "unlock_material",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
server_url = table.Column<string>(type: "TEXT", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
issuer = table.Column<string>(type: "TEXT", nullable: false),
|
||||
subject = table.Column<string>(type: "TEXT", nullable: false),
|
||||
email = table.Column<string>(type: "TEXT", nullable: true),
|
||||
display_name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||
wrapped_private_key = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||
kdf_algorithm = table.Column<string>(type: "TEXT", nullable: false),
|
||||
kdf_salt = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||
kdf_memory_kibibytes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
kdf_passes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
kdf_parallelism = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_unlock_material", x => x.id);
|
||||
table.CheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "vault",
|
||||
columns: table => new
|
||||
{
|
||||
vault_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
is_personal = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
team_id = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
key_generation = table.Column<uint>(type: "INTEGER", nullable: false),
|
||||
permissions = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
wrapped_vault_key = table.Column<byte[]>(type: "BLOB", nullable: true),
|
||||
rekey_required = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_vault", x => x.vault_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_conflict_vault_id_acknowledged",
|
||||
table: "conflict",
|
||||
columns: new[] { "vault_id", "acknowledged" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_conflict_vault_id_entity_type_entity_id",
|
||||
table: "conflict",
|
||||
columns: new[] { "vault_id", "entity_type", "entity_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_item_vault_id_change_sequence",
|
||||
table: "item",
|
||||
columns: new[] { "vault_id", "change_sequence" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_item_vault_id_entity_type",
|
||||
table: "item",
|
||||
columns: new[] { "vault_id", "entity_type" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_operation_id",
|
||||
table: "outbox",
|
||||
column: "operation_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_vault_id_entity_type_entity_id",
|
||||
table: "outbox",
|
||||
columns: new[] { "vault_id", "entity_type", "entity_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_vault_id_is_parked_sequence",
|
||||
table: "outbox",
|
||||
columns: new[] { "vault_id", "is_parked", "sequence" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "conflict");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "item");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "sync_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "unlock_material");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "vault");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DodoSSH.Client.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClientCacheContext))]
|
||||
partial class ClientCacheContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(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.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
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.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<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<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,403 @@
|
||||
using DodoSSH.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>A local change to queue.</summary>
|
||||
/// <param name="VaultId">Owning vault.</param>
|
||||
/// <param name="EntityType">Kind of item.</param>
|
||||
/// <param name="EntityId">The item. Client-generated UUIDv7, so items can be made offline.</param>
|
||||
/// <param name="Operation">Upsert or delete.</param>
|
||||
/// <param name="ExpectedVersion">The version the client believes the server holds; null to create.</param>
|
||||
/// <param name="Payload">Ciphertext. Required for an upsert.</param>
|
||||
/// <param name="Fields">Plaintext columns the server needs.</param>
|
||||
/// <param name="Ancestor">
|
||||
/// The version this edit branched from, so a conflict can be merged rather than arbitrated. Null when
|
||||
/// creating, where there is nothing to have branched from.
|
||||
/// </param>
|
||||
public sealed record QueuedChange(
|
||||
Guid VaultId,
|
||||
SyncEntityType EntityType,
|
||||
Guid EntityId,
|
||||
SyncOperation Operation,
|
||||
int? ExpectedVersion,
|
||||
EncryptedPayload? Payload,
|
||||
SyncPlaintextFields? Fields,
|
||||
StoredAncestor? Ancestor);
|
||||
|
||||
/// <summary>
|
||||
/// Changes made here that the server has not yet accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One row per item, and that is a database constraint rather than a convention. Two queued edits to
|
||||
/// one item would have to be pushed in order, and the second's <c>expectedVersion</c> is the version
|
||||
/// the first will produce — which is not known when it is queued. Coalescing sidesteps that instead of
|
||||
/// managing it, and the row holds a desired end state rather than a delta, so coalescing loses nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a coalesced row gets a new operation id.</b> The id is the server's exactly-once key. If a
|
||||
/// push has already gone out and the user edits again, keeping the id would let the server answer
|
||||
/// <c>Duplicate</c> for an operation whose contents have since changed — silently discarding the newer
|
||||
/// edit. A fresh id means the newer state is offered on its own terms: if the earlier push did land,
|
||||
/// the version has moved on, the push comes back <c>Conflict</c>, and the merge resolves it against an
|
||||
/// 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>
|
||||
/// </remarks>
|
||||
public sealed class OutboxStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>
|
||||
/// Queues a change the user just made, coalescing into any row already pending for the item.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A coalesced row keeps the ancestor and <c>expectedVersion</c> of the row it replaces, because
|
||||
/// the new state is still a descendant of that same base. Taking the caller's values instead would
|
||||
/// throw away the common ancestor after the first edit, and with it the ability to merge.
|
||||
/// </remarks>
|
||||
public async Task<PendingOperation> QueueAsync(
|
||||
QueuedChange change,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
Validate(change);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await FindRowAsync(context, change.VaultId, change.EntityType, change.EntityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new OutboxRow
|
||||
{
|
||||
VaultId = change.VaultId,
|
||||
EntityType = change.EntityType,
|
||||
EntityId = change.EntityId,
|
||||
ExpectedVersion = change.ExpectedVersion,
|
||||
};
|
||||
|
||||
SetAncestor(row, change.EntityType, change.EntityId, change.Ancestor);
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
row.OperationId = Guid.CreateVersion7();
|
||||
row.Operation = change.Operation;
|
||||
row.QueuedAtUtc = clock.GetUtcNow();
|
||||
row.Attempts = 0;
|
||||
row.LastError = null;
|
||||
row.IsParked = false;
|
||||
|
||||
SetPayload(row, change.EntityType, change.EntityId, change.Payload, change.Fields);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ToPending(row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a pending operation with the outcome of a merge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Distinct from <see cref="QueueAsync"/> because the intent is opposite: this <em>does</em> move
|
||||
/// the ancestor forward, to the server version the merge was performed against. Without that the
|
||||
/// re-push would conflict against the same base for ever.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It also <b>keeps the attempt count</b>, where queueing resets it. That difference is what makes
|
||||
/// the retry bound real: a row that has conflicted five times needs a person to look at it whether
|
||||
/// or not each attempt carried a freshly merged payload, whereas a user making a new edit has
|
||||
/// genuinely started over.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<PendingOperation?> ReviseAsync(
|
||||
long sequence,
|
||||
SyncOperation operation,
|
||||
int? expectedVersion,
|
||||
EncryptedPayload? payload,
|
||||
SyncPlaintextFields? fields,
|
||||
StoredAncestor? ancestor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<OutboxRow>()
|
||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
row.OperationId = Guid.CreateVersion7();
|
||||
row.Operation = operation;
|
||||
row.ExpectedVersion = expectedVersion;
|
||||
row.LastError = null;
|
||||
row.IsParked = false;
|
||||
|
||||
SetPayload(row, row.EntityType, row.EntityId, payload, fields);
|
||||
SetAncestor(row, row.EntityType, row.EntityId, ancestor);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ToPending(row);
|
||||
}
|
||||
|
||||
/// <summary>Reads the next operations to push, oldest first, skipping parked ones.</summary>
|
||||
public async Task<IReadOnlyList<PendingOperation>> TakeAsync(
|
||||
Guid vaultId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<OutboxRow>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.VaultId == vaultId && !r.IsParked)
|
||||
.OrderBy(r => r.Sequence)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. rows.Select(ToPending)];
|
||||
}
|
||||
|
||||
/// <summary>Reads the operation pending for one item, if any.</summary>
|
||||
public async Task<PendingOperation?> FindAsync(
|
||||
Guid vaultId,
|
||||
SyncEntityType entityType,
|
||||
Guid entityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await FindRowAsync(context, vaultId, entityType, entityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return row is null ? null : ToPending(row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every pending operation for a vault, parked ones included.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What the interface needs, as opposed to what the pusher needs. A parked change is still the
|
||||
/// user's current intent for that item and must be what they see; hiding it because the server
|
||||
/// refused it would show them the old values and look like their edit was lost.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<PendingOperation>> ListAllAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<OutboxRow>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.VaultId == vaultId)
|
||||
.OrderBy(r => r.Sequence)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. rows.Select(ToPending)];
|
||||
}
|
||||
|
||||
/// <summary>Reads operations the server refused, which need a person.</summary>
|
||||
public async Task<IReadOnlyList<PendingOperation>> ListParkedAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<OutboxRow>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.VaultId == vaultId && r.IsParked)
|
||||
.OrderBy(r => r.Sequence)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. rows.Select(ToPending)];
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <summary>Removes an operation the server accepted.</summary>
|
||||
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var removed = await context.Set<OutboxRow>()
|
||||
.Where(r => r.Sequence == sequence)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return removed > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops retrying an operation and records why.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For the answers that will not change on retry — the server calling an operation structurally
|
||||
/// 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) =>
|
||||
UpdateAsync(
|
||||
sequence,
|
||||
row =>
|
||||
{
|
||||
row.IsParked = true;
|
||||
row.LastError = reason;
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
/// <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);
|
||||
|
||||
private static Task<OutboxRow?> FindRowAsync(
|
||||
ClientCacheContext context,
|
||||
Guid vaultId,
|
||||
SyncEntityType entityType,
|
||||
Guid entityId,
|
||||
CancellationToken cancellationToken) =>
|
||||
context.Set<OutboxRow>()
|
||||
.SingleOrDefaultAsync(
|
||||
r => r.VaultId == vaultId && r.EntityType == entityType && r.EntityId == entityId,
|
||||
cancellationToken);
|
||||
|
||||
private static void Validate(QueuedChange change)
|
||||
{
|
||||
if (change.Operation == SyncOperation.Upsert && change.Payload is null)
|
||||
{
|
||||
throw new ArgumentException("An upsert requires a payload.", nameof(change));
|
||||
}
|
||||
|
||||
if (change.Operation == SyncOperation.Unspecified)
|
||||
{
|
||||
throw new ArgumentException("An operation is required.", nameof(change));
|
||||
}
|
||||
|
||||
if (change.EntityId == Guid.Empty)
|
||||
{
|
||||
throw new ArgumentException("An entity id is required.", nameof(change));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateAsync(
|
||||
long sequence,
|
||||
Action<OutboxRow> mutate,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<OutboxRow>()
|
||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(row);
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void SetPayload(
|
||||
OutboxRow row,
|
||||
SyncEntityType entityType,
|
||||
Guid entityId,
|
||||
EncryptedPayload? payload,
|
||||
SyncPlaintextFields? fields)
|
||||
{
|
||||
row.Payload = payload?.Envelope;
|
||||
row.WrappedDataKey = payload?.WrappedDataKey;
|
||||
row.DataKeyId = payload?.DataKeyId;
|
||||
row.KeyGeneration = payload?.KeyGeneration ?? 0;
|
||||
row.AadVersion = payload?.AadVersion ?? 0;
|
||||
|
||||
row.ProtectedFields = Seal(entityType, entityId, fields);
|
||||
}
|
||||
|
||||
private void SetAncestor(
|
||||
OutboxRow row,
|
||||
SyncEntityType entityType,
|
||||
Guid entityId,
|
||||
StoredAncestor? ancestor)
|
||||
{
|
||||
row.AncestorVersion = ancestor?.Version;
|
||||
row.AncestorPayload = ancestor?.Payload.Envelope;
|
||||
row.AncestorWrappedDataKey = ancestor?.Payload.WrappedDataKey;
|
||||
row.AncestorDataKeyId = ancestor?.Payload.DataKeyId;
|
||||
row.AncestorKeyGeneration = ancestor?.Payload.KeyGeneration;
|
||||
row.AncestorAadVersion = ancestor?.Payload.AadVersion;
|
||||
|
||||
row.AncestorProtectedFields = Seal(entityType, entityId, ancestor?.Fields);
|
||||
}
|
||||
|
||||
private byte[]? Seal(SyncEntityType entityType, Guid entityId, SyncPlaintextFields? fields) =>
|
||||
fields is null
|
||||
? null
|
||||
: protector.Protect(
|
||||
AadResourceTypes.For(entityType), entityId, PlaintextFieldsCodec.Encode(fields));
|
||||
|
||||
private SyncPlaintextFields? Open(SyncEntityType entityType, Guid entityId, byte[]? sealedFields)
|
||||
{
|
||||
if (sealedFields is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plaintext = protector.TryUnprotect(
|
||||
AadResourceTypes.For(entityType), entityId, sealedFields);
|
||||
|
||||
return plaintext is null ? null : PlaintextFieldsCodec.TryDecode(plaintext);
|
||||
}
|
||||
|
||||
private PendingOperation ToPending(OutboxRow row)
|
||||
{
|
||||
var ancestorPayload = CacheMapping.ToAncestorPayload(row);
|
||||
|
||||
var ancestor = ancestorPayload is null || row.AncestorVersion is null
|
||||
? null
|
||||
: new StoredAncestor(
|
||||
row.AncestorVersion.Value,
|
||||
ancestorPayload,
|
||||
Open(row.EntityType, row.EntityId, row.AncestorProtectedFields));
|
||||
|
||||
return new PendingOperation(
|
||||
row.Sequence,
|
||||
row.OperationId,
|
||||
row.VaultId,
|
||||
row.EntityType,
|
||||
row.EntityId,
|
||||
row.Operation,
|
||||
row.ExpectedVersion,
|
||||
CacheMapping.ToPayload(
|
||||
row.Payload, row.WrappedDataKey, row.DataKeyId, row.KeyGeneration, row.AadVersion),
|
||||
Open(row.EntityType, row.EntityId, row.ProtectedFields),
|
||||
ancestor,
|
||||
row.QueuedAtUtc,
|
||||
row.Attempts,
|
||||
row.LastError,
|
||||
row.IsParked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>Why a conflict record exists.</summary>
|
||||
/// <remarks>
|
||||
/// Persisted, so append only. These are the vocabulary the UI reasons about: each one implies a
|
||||
/// different remedy, which is why they are distinguished rather than collapsed into "conflict".
|
||||
/// </remarks>
|
||||
public enum ConflictKind
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>Both sides changed a field. One value survived; the other is in the detail.</summary>
|
||||
FieldOverridden = 1,
|
||||
|
||||
/// <summary>This machine deleted an item that someone else edited. The edit won.</summary>
|
||||
LocalDeleteOverridden = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Someone else deleted an item this machine had edited. The local content was preserved under a
|
||||
/// new id rather than being lost to the tombstone.
|
||||
/// </summary>
|
||||
RemoteDeleteResurrected = 3,
|
||||
|
||||
/// <summary>
|
||||
/// A payload failed its authentication tag or its schema. Either a client bug or a server that
|
||||
/// handed back the wrong bytes; both need a human.
|
||||
/// </summary>
|
||||
Undecryptable = 4,
|
||||
|
||||
/// <summary>Written by a newer client than this one, so it is readable but not editable.</summary>
|
||||
TooNewToEdit = 5,
|
||||
|
||||
/// <summary>The server refused the operation outright. Retrying will not help.</summary>
|
||||
Rejected = 6,
|
||||
}
|
||||
|
||||
/// <summary>What an offline unlock needs.</summary>
|
||||
/// <param name="ServerUrl">The server this cache belongs to.</param>
|
||||
/// <param name="UserId">The user, which every AAD in the bundle wrap binds to.</param>
|
||||
/// <param name="Issuer">OIDC issuer.</param>
|
||||
/// <param name="Subject">OIDC subject.</param>
|
||||
/// <param name="Email">Email, for display.</param>
|
||||
/// <param name="DisplayName">Display name, for display.</param>
|
||||
/// <param name="KeyGeneration">Identity key generation.</param>
|
||||
/// <param name="WrappedPrivateKey">The secret bundle, wrapped under the passphrase-derived key.</param>
|
||||
/// <param name="KdfParameters">Parameters needed to re-derive that key.</param>
|
||||
/// <param name="UpdatedAt">When this was last refreshed from the server.</param>
|
||||
public sealed record StoredUnlockMaterial(
|
||||
string ServerUrl,
|
||||
Guid UserId,
|
||||
string Issuer,
|
||||
string Subject,
|
||||
string? Email,
|
||||
string? DisplayName,
|
||||
uint KeyGeneration,
|
||||
byte[] WrappedPrivateKey,
|
||||
KdfParameters KdfParameters,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>A cached vault and the grant that opens it.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Display name.</param>
|
||||
/// <param name="IsPersonal">Whether this is the user's personal vault.</param>
|
||||
/// <param name="TeamId">Owning team, for a team vault.</param>
|
||||
/// <param name="KeyGeneration">Current key generation.</param>
|
||||
/// <param name="Permissions">Effective permissions, as a flags value.</param>
|
||||
/// <param name="WrappedVaultKey">The vault key sealed to this user. Null while awaiting re-wrap.</param>
|
||||
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
|
||||
public sealed record StoredVault(
|
||||
Guid VaultId,
|
||||
string Name,
|
||||
bool IsPersonal,
|
||||
Guid? TeamId,
|
||||
uint KeyGeneration,
|
||||
int Permissions,
|
||||
byte[]? WrappedVaultKey,
|
||||
bool RekeyRequired);
|
||||
|
||||
/// <summary>The last item state the server confirmed.</summary>
|
||||
/// <param name="VaultId">Owning vault.</param>
|
||||
/// <param name="EntityType">Kind of item.</param>
|
||||
/// <param name="EntityId">The item.</param>
|
||||
/// <param name="Version">Server-assigned version — what a push must expect.</param>
|
||||
/// <param name="ChangeSequence">Position in the vault's change log.</param>
|
||||
/// <param name="Payload">Ciphertext as the server returned it. Null for a tombstone.</param>
|
||||
/// <param name="Fields">The plaintext columns. Null for a tombstone.</param>
|
||||
/// <param name="IsDeleted">Whether this is a tombstone.</param>
|
||||
/// <param name="UpdatedAt">When the change was recorded.</param>
|
||||
public sealed record StoredItem(
|
||||
Guid VaultId,
|
||||
SyncEntityType EntityType,
|
||||
Guid EntityId,
|
||||
int Version,
|
||||
long ChangeSequence,
|
||||
EncryptedPayload? Payload,
|
||||
SyncPlaintextFields? Fields,
|
||||
bool IsDeleted,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>The item version a pending local edit branched from.</summary>
|
||||
/// <remarks>
|
||||
/// Without this a conflict can only be arbitrated, not merged. It is retained verbatim, including the
|
||||
/// key generation and data key id, because those are part of the payload's AAD and the ancestor cannot
|
||||
/// be decrypted without them.
|
||||
/// </remarks>
|
||||
/// <param name="Version">The version this edit was made against.</param>
|
||||
/// <param name="Payload">That version's ciphertext.</param>
|
||||
/// <param name="Fields">That version's plaintext columns.</param>
|
||||
public sealed record StoredAncestor(
|
||||
int Version,
|
||||
EncryptedPayload Payload,
|
||||
SyncPlaintextFields? Fields);
|
||||
|
||||
/// <summary>A local change waiting to be pushed.</summary>
|
||||
/// <param name="Sequence">Local ordering. Assigned by the store; ignored on queue.</param>
|
||||
/// <param name="OperationId">The server's idempotency key for this operation.</param>
|
||||
/// <param name="VaultId">Owning vault.</param>
|
||||
/// <param name="EntityType">Kind of item.</param>
|
||||
/// <param name="EntityId">The item.</param>
|
||||
/// <param name="Operation">Upsert or delete.</param>
|
||||
/// <param name="ExpectedVersion">The version the client believes the server holds; null to create.</param>
|
||||
/// <param name="Payload">Ciphertext to store. Null for a delete.</param>
|
||||
/// <param name="Fields">Plaintext columns. Null for a delete.</param>
|
||||
/// <param name="Ancestor">The version this branched from. Null when creating.</param>
|
||||
/// <param name="QueuedAt">When the user made the change.</param>
|
||||
/// <param name="Attempts">How many times this has been dispatched.</param>
|
||||
/// <param name="LastError">Why it last failed.</param>
|
||||
/// <param name="IsParked">Whether it has been abandoned pending user action.</param>
|
||||
public sealed record PendingOperation(
|
||||
long Sequence,
|
||||
Guid OperationId,
|
||||
Guid VaultId,
|
||||
SyncEntityType EntityType,
|
||||
Guid EntityId,
|
||||
SyncOperation Operation,
|
||||
int? ExpectedVersion,
|
||||
EncryptedPayload? Payload,
|
||||
SyncPlaintextFields? Fields,
|
||||
StoredAncestor? Ancestor,
|
||||
DateTimeOffset QueuedAt,
|
||||
int Attempts = 0,
|
||||
string? LastError = null,
|
||||
bool IsParked = false);
|
||||
|
||||
/// <summary>Where a vault's pull has reached.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Cursor">The last server-issued cursor. Never constructed by a client.</param>
|
||||
/// <param name="KeyGeneration">The generation the server last reported.</param>
|
||||
/// <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>
|
||||
public sealed record StoredSyncState(
|
||||
Guid VaultId,
|
||||
string? Cursor,
|
||||
uint KeyGeneration,
|
||||
DateTimeOffset? LastPulledAt = null,
|
||||
DateTimeOffset? LastPushedAt = null,
|
||||
long ServerTimeSkewMs = 0);
|
||||
|
||||
/// <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>
|
||||
/// <param name="VaultId">Owning vault.</param>
|
||||
/// <param name="EntityType">Kind of item.</param>
|
||||
/// <param name="EntityId">The item.</param>
|
||||
/// <param name="Kind">What happened.</param>
|
||||
/// <param name="Detail">
|
||||
/// The discarded values, in plaintext across this boundary and sealed at rest. Opaque to the store:
|
||||
/// its shape belongs to the sync layer, which owns what a conflict means.
|
||||
/// </param>
|
||||
/// <param name="DetectedAt">When it was noticed.</param>
|
||||
/// <param name="Acknowledged">Whether the user has dealt with it.</param>
|
||||
public sealed record StoredConflict(
|
||||
Guid Id,
|
||||
Guid VaultId,
|
||||
SyncEntityType EntityType,
|
||||
Guid EntityId,
|
||||
ConflictKind Kind,
|
||||
byte[] Detail,
|
||||
DateTimeOffset DetectedAt,
|
||||
bool Acknowledged = false);
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Where each vault's pull has reached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The cursor is stored exactly as the server issued it and is never parsed, constructed or adjusted.
|
||||
/// It is opaque and integrity-tagged for a reason: a client that could synthesise one could ask to
|
||||
/// resume from a position the server never granted, and a tampered cursor is rejected rather than
|
||||
/// silently mis-serving a range.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A null cursor means "from the beginning", which is also the recovery path for a cache that has been
|
||||
/// discarded or that failed to decrypt. Re-pulling from nothing is always safe; guessing a position is
|
||||
/// not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> contexts)
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a vault's position, or a fresh one starting from the beginning.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Never returns null. An unknown vault is not an error — it is a vault this client has not synced
|
||||
/// yet — and a caller forced to handle a null here would most likely handle it by starting from the
|
||||
/// beginning anyway.
|
||||
/// </remarks>
|
||||
public async Task<StoredSyncState> ReadAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<SyncStateRow>()
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return row is null
|
||||
? new StoredSyncState(vaultId, Cursor: null, KeyGeneration: 0)
|
||||
: new StoredSyncState(
|
||||
row.VaultId,
|
||||
row.Cursor,
|
||||
row.KeyGeneration,
|
||||
row.LastPulledAtUtc,
|
||||
row.LastPushedAtUtc,
|
||||
row.ServerTimeSkewMs);
|
||||
}
|
||||
|
||||
/// <summary>Records a vault's position.</summary>
|
||||
public async Task SaveAsync(StoredSyncState state, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<SyncStateRow>()
|
||||
.SingleOrDefaultAsync(r => r.VaultId == state.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new SyncStateRow { VaultId = state.VaultId };
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
row.Cursor = state.Cursor;
|
||||
row.KeyGeneration = state.KeyGeneration;
|
||||
row.LastPulledAtUtc = state.LastPulledAt;
|
||||
row.LastPushedAtUtc = state.LastPushedAt;
|
||||
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets a vault's position so the next pull starts over.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The remedy when the cache cannot be trusted — a key generation the client has no grant for, or
|
||||
/// rows that will not decrypt. A full re-pull is cheap next to the alternative of reasoning about
|
||||
/// which half of the cache is still valid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The outbox is deliberately not cleared.</b> Those rows are the only copy of changes the user
|
||||
/// made and the server has not accepted; discarding them here would turn a recoverable cache
|
||||
/// problem into lost work. They re-push against the re-pulled state, conflicting and merging where
|
||||
/// they must.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ResetAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
await context.Set<SyncStateRow>()
|
||||
.Where(r => r.VaultId == vaultId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.Set<CachedItemRow>()
|
||||
.Where(r => r.VaultId == vaultId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using DodoSSH.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the cache belongs to a different account than the one signing in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Loud on purpose. Silently adopting the cache would mix one user's items into another's vault list
|
||||
/// and, worse, would offer an unlock prompt whose passphrase can never work.
|
||||
/// </remarks>
|
||||
public sealed class CacheIdentityMismatchException : InvalidOperationException
|
||||
{
|
||||
/// <summary>Creates the exception.</summary>
|
||||
public CacheIdentityMismatchException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception.</summary>
|
||||
public CacheIdentityMismatchException()
|
||||
: base("This cache belongs to a different account.")
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates the exception.</summary>
|
||||
public CacheIdentityMismatchException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The material an offline unlock needs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This store is the reason the client works on a plane. The Argon2id salt and the wrapped secret
|
||||
/// bundle are cached the moment the server hands them over, so deriving the master key and opening the
|
||||
/// bundle need no network at all. Fetching either at unlock time would make an offline launch
|
||||
/// impossible, which is the most common moment a user actually needs their vault.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Neither value is a secret. The salt is public by construction and the bundle is ciphertext whose key
|
||||
/// exists only in the user's head. The master key itself is never written here or anywhere else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class UnlockStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
|
||||
{
|
||||
/// <summary>Reads the cached material, or null when this cache has never been enrolled.</summary>
|
||||
public async Task<StoredUnlockMaterial?> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<UnlockMaterialRow>()
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return row is null ? null : ToStored(row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the material, replacing what is there.
|
||||
/// </summary>
|
||||
/// <exception cref="CacheIdentityMismatchException">
|
||||
/// The cache already holds a different user. One cache file is one account; see
|
||||
/// <see cref="UnlockMaterialRow"/> for why multiple accounts are not half-supported here.
|
||||
/// </exception>
|
||||
public async Task SaveAsync(StoredUnlockMaterial material, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(material);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<UnlockMaterialRow>()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new UnlockMaterialRow();
|
||||
context.Add(row);
|
||||
}
|
||||
else if (row.UserId != material.UserId)
|
||||
{
|
||||
throw new CacheIdentityMismatchException(
|
||||
$"This cache holds user {row.UserId}; refusing to overwrite it with {material.UserId}.");
|
||||
}
|
||||
|
||||
Apply(row, material, clock.GetUtcNow());
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void Apply(
|
||||
UnlockMaterialRow row,
|
||||
StoredUnlockMaterial material,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
row.ServerUrl = material.ServerUrl;
|
||||
row.UserId = material.UserId;
|
||||
row.Issuer = material.Issuer;
|
||||
row.Subject = material.Subject;
|
||||
row.Email = material.Email;
|
||||
row.DisplayName = material.DisplayName;
|
||||
row.KeyGeneration = material.KeyGeneration;
|
||||
row.WrappedPrivateKey = material.WrappedPrivateKey;
|
||||
row.KdfAlgorithm = material.KdfParameters.Algorithm;
|
||||
row.KdfSalt = material.KdfParameters.Salt;
|
||||
row.KdfMemoryKibibytes = material.KdfParameters.MemoryKibibytes;
|
||||
row.KdfPasses = material.KdfParameters.Passes;
|
||||
row.KdfParallelism = material.KdfParameters.Parallelism;
|
||||
row.UpdatedAtUtc = now;
|
||||
}
|
||||
|
||||
private static StoredUnlockMaterial ToStored(UnlockMaterialRow row) =>
|
||||
new(
|
||||
row.ServerUrl,
|
||||
row.UserId,
|
||||
row.Issuer,
|
||||
row.Subject,
|
||||
row.Email,
|
||||
row.DisplayName,
|
||||
row.KeyGeneration,
|
||||
row.WrappedPrivateKey,
|
||||
new KdfParameters(
|
||||
row.KdfAlgorithm,
|
||||
row.KdfSalt,
|
||||
row.KdfMemoryKibibytes,
|
||||
row.KdfPasses,
|
||||
row.KdfParallelism),
|
||||
row.UpdatedAtUtc);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// The vaults this user can reach, and the grants that open them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cached for the same reason as the unlock material: without the wrapped vault key on disk, an offline
|
||||
/// launch could unlock the identity bundle and still not decrypt a single item. Every value here is
|
||||
/// either public metadata or ciphertext.
|
||||
/// </remarks>
|
||||
public sealed class VaultStore(IDbContextFactory<ClientCacheContext> contexts, TimeProvider clock)
|
||||
{
|
||||
/// <summary>Reads every known vault.</summary>
|
||||
public async Task<IReadOnlyList<StoredVault>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<CachedVaultRow>()
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(row => row.IsPersonal)
|
||||
.ThenBy(row => row.Name)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. rows.Select(ToStored)];
|
||||
}
|
||||
|
||||
/// <summary>Reads one vault.</summary>
|
||||
public async Task<StoredVault?> FindAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<CachedVaultRow>()
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(r => r.VaultId == vaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return row is null ? null : ToStored(row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the cached vault list with what the server reported.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Vaults absent from the list are removed, because losing access to a vault is exactly what that
|
||||
/// absence means and a stale row would offer the user a vault they can no longer sync.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Their <em>items</em> are a separate matter and are not touched here. Removing a member does not
|
||||
/// retroactively erase what they already hold — that is not achievable, which is why offboarding
|
||||
/// means rotating the SSH credential rather than revoking a grant. Deleting the local rows here
|
||||
/// would only make the client pretend otherwise.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ReplaceAllAsync(
|
||||
IReadOnlyList<StoredVault> vaults,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vaults);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var existing = await context.Set<CachedVaultRow>()
|
||||
.ToDictionaryAsync(row => row.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
foreach (var vault in vaults)
|
||||
{
|
||||
if (!existing.Remove(vault.VaultId, out var row))
|
||||
{
|
||||
row = new CachedVaultRow { VaultId = vault.VaultId };
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
Apply(row, vault, now);
|
||||
}
|
||||
|
||||
context.RemoveRange(existing.Values);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void Apply(CachedVaultRow row, StoredVault vault, DateTimeOffset now)
|
||||
{
|
||||
row.Name = vault.Name;
|
||||
row.IsPersonal = vault.IsPersonal;
|
||||
row.TeamId = vault.TeamId;
|
||||
row.KeyGeneration = vault.KeyGeneration;
|
||||
row.Permissions = vault.Permissions;
|
||||
row.WrappedVaultKey = vault.WrappedVaultKey;
|
||||
row.RekeyRequired = vault.RekeyRequired;
|
||||
row.UpdatedAtUtc = now;
|
||||
}
|
||||
|
||||
private static StoredVault ToStored(CachedVaultRow row) =>
|
||||
new(
|
||||
row.VaultId,
|
||||
row.Name,
|
||||
row.IsPersonal,
|
||||
row.TeamId,
|
||||
row.KeyGeneration,
|
||||
row.Permissions,
|
||||
row.WrappedVaultKey,
|
||||
row.RekeyRequired);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "Direct",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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.EntityFrameworkCore.Design": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "BsvxiKcy8k4/ijAPitmwKG1mlVsdC2lQtFLP28K2N8PlsGYbqPFOyfJ7p2kWil3gM6xXgQGf8Hz/pJB8ej+Dug==",
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Microsoft.Build.Framework": "18.0.2",
|
||||
"Microsoft.CodeAnalysis.CSharp": "5.0.0",
|
||||
"Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0",
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0",
|
||||
"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",
|
||||
"Mono.TextTemplating": "3.0.0",
|
||||
"Newtonsoft.Json": "13.0.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.EntityFrameworkCore.Sqlite": {
|
||||
"type": "Direct",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"Humanizer.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.14.1",
|
||||
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
|
||||
},
|
||||
"Microsoft.Build.Framework": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.0.2",
|
||||
"contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Analyzers": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.11.0",
|
||||
"contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeAnalysis.Analyzers": "3.11.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||
"Microsoft.CodeAnalysis.Common": "[5.0.0]"
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==",
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||
"Microsoft.CodeAnalysis.CSharp": "[5.0.0]",
|
||||
"Microsoft.CodeAnalysis.Common": "[5.0.0]",
|
||||
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
|
||||
"System.Composition": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Workspaces.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==",
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||
"Microsoft.CodeAnalysis.Common": "[5.0.0]",
|
||||
"System.Composition": "9.0.0"
|
||||
}
|
||||
},
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.0.0",
|
||||
"contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==",
|
||||
"dependencies": {
|
||||
"Humanizer.Core": "2.14.1",
|
||||
"Microsoft.Build.Framework": "17.11.31",
|
||||
"Microsoft.CodeAnalysis.Analyzers": "3.11.0",
|
||||
"Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.0",
|
||||
"Microsoft.Extensions.Logging": "9.0.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
|
||||
"Microsoft.Extensions.Options": "9.0.0",
|
||||
"Microsoft.Extensions.Primitives": "9.0.0",
|
||||
"Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"System.Composition": "9.0.0"
|
||||
}
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"Microsoft.VisualStudio.SolutionPersistence": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.0.52",
|
||||
"contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w=="
|
||||
},
|
||||
"Mono.TextTemplating": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.0.0",
|
||||
"contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==",
|
||||
"dependencies": {
|
||||
"System.CodeDom": "6.0.0"
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json": {
|
||||
"type": "Transitive",
|
||||
"resolved": "13.0.3",
|
||||
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
|
||||
},
|
||||
"System.CodeDom": {
|
||||
"type": "Transitive",
|
||||
"resolved": "6.0.0",
|
||||
"contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
|
||||
},
|
||||
"System.Composition": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==",
|
||||
"dependencies": {
|
||||
"System.Composition.AttributedModel": "9.0.0",
|
||||
"System.Composition.Convention": "9.0.0",
|
||||
"System.Composition.Hosting": "9.0.0",
|
||||
"System.Composition.Runtime": "9.0.0",
|
||||
"System.Composition.TypedParts": "9.0.0"
|
||||
}
|
||||
},
|
||||
"System.Composition.AttributedModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA=="
|
||||
},
|
||||
"System.Composition.Convention": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==",
|
||||
"dependencies": {
|
||||
"System.Composition.AttributedModel": "9.0.0"
|
||||
}
|
||||
},
|
||||
"System.Composition.Hosting": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==",
|
||||
"dependencies": {
|
||||
"System.Composition.Runtime": "9.0.0"
|
||||
}
|
||||
},
|
||||
"System.Composition.Runtime": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA=="
|
||||
},
|
||||
"System.Composition.TypedParts": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.0",
|
||||
"contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==",
|
||||
"dependencies": {
|
||||
"System.Composition.AttributedModel": "9.0.0",
|
||||
"System.Composition.Hosting": "9.0.0",
|
||||
"System.Composition.Runtime": "9.0.0"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The pull / apply / push loop, and the conflict policy it enforces.
|
||||
|
||||
This is the layer where data gets lost if it is wrong, so the shape is deliberate: the merge rules
|
||||
live in Client.Domain with no I/O, persistence lives in Client.Storage with no policy, and this
|
||||
project is the only place that decides what to do when two people edited the same host. Everything
|
||||
it talks to is an interface or a store, so the conflict matrix runs against an in-memory server that
|
||||
enforces real version checks rather than against HTTP.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Contracts/DodoSSH.Contracts.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Api/DodoSSH.Client.Api.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Sync.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a host into an item payload and back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every operation binds to the item's identity, its key generation <em>and</em> its version, because
|
||||
/// that is what <c>DshAad.ItemPayload</c> requires. The version part has a consequence worth stating
|
||||
/// plainly: a payload must be sealed at the version the server will assign, not the version it is
|
||||
/// replacing. See <see cref="SyncVersions"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The data key is fresh per call and is zeroed before returning, as is the encoded plaintext. Neither
|
||||
/// is ever handed to a caller: a data key that escaped this class would be a data key some other layer
|
||||
/// could forget to clear.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HostCipher
|
||||
{
|
||||
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a host.
|
||||
/// </summary>
|
||||
/// <param name="host">The host. Must be valid for storage.</param>
|
||||
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
|
||||
/// <param name="entityId">The item id, which the AAD binds.</param>
|
||||
/// <param name="keyGeneration">The vault's current key generation.</param>
|
||||
/// <param name="itemVersion">
|
||||
/// The version this payload will hold once the server accepts it — one more than the version being
|
||||
/// replaced. Sealing at the version being replaced would produce a payload that authenticates
|
||||
/// against a row that no longer exists, and the item would read as corrupt from then on.
|
||||
/// </param>
|
||||
public static EncryptedPayload Seal(
|
||||
HostSecret host,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
uint keyGeneration,
|
||||
int itemVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
|
||||
|
||||
var plaintext = HostSecretCodec.Encode(host);
|
||||
var dataKey = ItemKeys.CreateDataKey();
|
||||
|
||||
try
|
||||
{
|
||||
var dataKeyId = Guid.CreateVersion7();
|
||||
|
||||
var wrappedDataKey = ItemKeys.WrapDataKey(
|
||||
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
|
||||
|
||||
var envelope = ItemKeys.SealPayload(
|
||||
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
|
||||
|
||||
return new EncryptedPayload(
|
||||
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(dataKey);
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a host.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The host and the schema version it was written at, or <see langword="null"/> if the payload does
|
||||
/// not belong to this item, version or generation, or does not parse.
|
||||
/// <para>
|
||||
/// A null is a meaningful outcome, not an error to be thrown past. It is what a server relocating
|
||||
/// ciphertext between rows looks like from here, and it is also what an ordinary rekey looks like
|
||||
/// before new grants arrive. The caller distinguishes them by comparing generations; either way one
|
||||
/// unreadable item must not abort a sync pass and strand every change behind it.
|
||||
/// </para>
|
||||
/// </returns>
|
||||
public static HostSecretDocument? TryOpen(
|
||||
EncryptedPayload payload,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
int itemVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
|
||||
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dataKey = ItemKeys.TryUnwrapDataKey(
|
||||
vaultKey,
|
||||
payload.WrappedDataKey,
|
||||
Resource,
|
||||
entityId,
|
||||
payload.KeyGeneration,
|
||||
(uint)itemVersion);
|
||||
|
||||
if (dataKey is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var plaintext = ItemKeys.TryOpenPayload(
|
||||
dataKey,
|
||||
payload.Envelope,
|
||||
Resource,
|
||||
entityId,
|
||||
payload.DataKeyId,
|
||||
payload.KeyGeneration,
|
||||
(uint)itemVersion);
|
||||
|
||||
if (plaintext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return HostSecretCodec.TryDecode(plaintext, out var document) ? document : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(dataKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one place that decides which item version a payload is sealed at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The payload's AAD binds the item version, so the sealing side has to predict what the server will
|
||||
/// assign. That prediction is safe because it is checked: the server applies an upsert only when
|
||||
/// <c>expectedVersion</c> matches, and then increments by exactly one. A mismatch is a conflict, not a
|
||||
/// silently mis-sealed row. Both the sealing and the opening sides go through here, so they cannot
|
||||
/// drift — the failure if they did would be an item that encrypts fine and never decrypts again.
|
||||
/// </remarks>
|
||||
internal static class SyncVersions
|
||||
{
|
||||
/// <summary>The version an accepted upsert will produce.</summary>
|
||||
/// <param name="expectedVersion">The version being replaced, or null for a create.</param>
|
||||
internal static int NextVersion(int? expectedVersion) => (expectedVersion ?? 0) + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the plaintext columns the server needs from a host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The single point at which a hostname can leave the encrypted payload, which is the whole reason it
|
||||
/// is a function rather than something each call site assembles. The address is emitted only when the
|
||||
/// user has turned the relay on for that host; with relay off, the server learns nothing but that an
|
||||
/// item exists. See ADR 0004 for why the relay cannot work any other way.
|
||||
/// </remarks>
|
||||
internal static class HostFields
|
||||
{
|
||||
internal static SyncPlaintextFields From(HostSecret host) =>
|
||||
host.RelayEnabled
|
||||
? new SyncPlaintextFields(RelayEnabled: true, Hostname: host.Hostname, Port: host.Port)
|
||||
: new SyncPlaintextFields();
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>A host as the interface should show it.</summary>
|
||||
/// <param name="EntityId">The item id.</param>
|
||||
/// <param name="Host">The decrypted host.</param>
|
||||
/// <param name="Version">
|
||||
/// The server version this is based on. Zero for an item that has never been accepted.
|
||||
/// </param>
|
||||
/// <param name="HasUnsyncedChanges">
|
||||
/// Whether this reflects a local edit the server has not accepted yet. Worth showing: it is the
|
||||
/// difference between "saved" and "saved here".
|
||||
/// </param>
|
||||
/// <param name="IsBlocked">
|
||||
/// Whether the pending change was refused and is waiting on a person, so it will not retry on its own.
|
||||
/// </param>
|
||||
/// <param name="IsReadOnly">
|
||||
/// Whether this host was written by a newer client and so must not be edited here, because re-encoding
|
||||
/// it would drop fields this build cannot represent.
|
||||
/// </param>
|
||||
public sealed record VaultHost(
|
||||
Guid EntityId,
|
||||
HostSecret Host,
|
||||
int Version,
|
||||
bool HasUnsyncedChanges,
|
||||
bool IsBlocked,
|
||||
bool IsReadOnly);
|
||||
|
||||
/// <summary>The hosts in a vault, and what could not be read.</summary>
|
||||
/// <param name="Hosts">The readable hosts, newest change last.</param>
|
||||
/// <param name="Unreadable">
|
||||
/// How many items would not decrypt. Surfaced rather than swallowed: a non-zero count here after a
|
||||
/// rekey is the signal that new grants are needed.
|
||||
/// </param>
|
||||
public sealed record HostListing(IReadOnlyList<VaultHost> Hosts, int Unreadable);
|
||||
|
||||
/// <summary>
|
||||
/// Reading and writing hosts, as the interface sees them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The view is the mirror of the server's state with the outbox laid over it, which is what makes the
|
||||
/// application feel local: an edit appears immediately and a delete disappears immediately, whether or
|
||||
/// not the network is there. Nothing here talks to the server; the sync engine reconciles later.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Writes never touch the mirror. That separation is load-bearing — the mirror is the common ancestor a
|
||||
/// three-way merge needs, and a repository that updated it on save would destroy the very state that
|
||||
/// lets a conflict be merged instead of arbitrated.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HostRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring)
|
||||
{
|
||||
/// <summary>Reads every host the user should see in a vault.</summary>
|
||||
public async Task<HostListing> ListAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out _))
|
||||
{
|
||||
throw new VaultUnreadableException(vaultId);
|
||||
}
|
||||
|
||||
var mirrored = await items
|
||||
.ListAsync(vaultId, SyncEntityType.Host, includeDeleted: true, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var pending = await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var pendingByEntity = pending
|
||||
.Where(operation => operation.EntityType == SyncEntityType.Host)
|
||||
.ToDictionary(operation => operation.EntityId);
|
||||
|
||||
var hosts = new List<VaultHost>();
|
||||
var unreadable = 0;
|
||||
|
||||
foreach (var item in mirrored)
|
||||
{
|
||||
if (pendingByEntity.Remove(item.EntityId, out var local))
|
||||
{
|
||||
AddPending(hosts, ref unreadable, vaultKey, local);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.IsDeleted || item.Payload is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var opened = HostCipher.TryOpen(item.Payload, vaultKey.Span, item.EntityId, item.Version);
|
||||
|
||||
if (opened is null)
|
||||
{
|
||||
unreadable++;
|
||||
continue;
|
||||
}
|
||||
|
||||
hosts.Add(new VaultHost(
|
||||
item.EntityId, opened.Host, item.Version, false, false, opened.IsReadOnly));
|
||||
}
|
||||
|
||||
// Whatever is left has no mirror row yet: items created here and not yet accepted.
|
||||
foreach (var local in pendingByEntity.Values)
|
||||
{
|
||||
AddPending(hosts, ref unreadable, vaultKey, local);
|
||||
}
|
||||
|
||||
return new HostListing(hosts, unreadable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a host, returning the id it was given.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The id is generated here, not by the server, which is what lets a host be created with no network
|
||||
/// at all — the point of the whole outbox. UUIDv7 so that ids sort by creation time, which keeps
|
||||
/// index locality reasonable on the server side.
|
||||
/// </remarks>
|
||||
public async Task<Guid> CreateAsync(
|
||||
Guid vaultId,
|
||||
HostSecret host,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
Validate(host);
|
||||
|
||||
var (vaultKey, generation) = Key(vaultId);
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
await outbox.QueueAsync(
|
||||
new QueuedChange(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
SyncOperation.Upsert,
|
||||
ExpectedVersion: null,
|
||||
HostCipher.Seal(host, vaultKey.Span, entityId, generation, itemVersion: 1),
|
||||
HostFields.From(host),
|
||||
Ancestor: null),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return entityId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces a host's contents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The base is taken from the pending operation when there is one, and from the mirror otherwise.
|
||||
/// Reading it the other way round would seal the payload at a version that does not match the
|
||||
/// <c>expectedVersion</c> the coalesced row keeps — and because the AAD binds the item version, the
|
||||
/// result would encrypt cleanly and never decrypt again.
|
||||
/// </remarks>
|
||||
public async Task UpdateAsync(
|
||||
Guid vaultId,
|
||||
Guid entityId,
|
||||
HostSecret host,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
Validate(host);
|
||||
|
||||
var (vaultKey, generation) = Key(vaultId);
|
||||
|
||||
var pending = await outbox
|
||||
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var expectedVersion = pending is not null
|
||||
? pending.ExpectedVersion
|
||||
: await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var ancestor = pending?.Ancestor
|
||||
?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await outbox.QueueAsync(
|
||||
new QueuedChange(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion,
|
||||
HostCipher.Seal(
|
||||
host, vaultKey.Span, entityId, generation, SyncVersions.NextVersion(expectedVersion)),
|
||||
HostFields.From(host),
|
||||
ancestor),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Queued as a tombstone, never a local removal. An offline client that simply forgot the row would
|
||||
/// be unable to tell the server anything, and the item would come back on the next pull.
|
||||
/// </remarks>
|
||||
public async Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = await outbox
|
||||
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var expectedVersion = pending is not null
|
||||
? pending.ExpectedVersion
|
||||
: await MirrorVersionAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var ancestor = pending?.Ancestor
|
||||
?? await MirrorAncestorAsync(vaultId, entityId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await outbox.QueueAsync(
|
||||
new QueuedChange(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
SyncOperation.Delete,
|
||||
expectedVersion,
|
||||
Payload: null,
|
||||
Fields: null,
|
||||
ancestor),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void Validate(HostSecret host)
|
||||
{
|
||||
if (!host.TryValidate(out var error))
|
||||
{
|
||||
throw new ArgumentException(error, nameof(host));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddPending(
|
||||
List<VaultHost> hosts,
|
||||
ref int unreadable,
|
||||
ReadOnlyMemory<byte> vaultKey,
|
||||
PendingOperation local)
|
||||
{
|
||||
if (local.Operation == SyncOperation.Delete)
|
||||
{
|
||||
// Gone as far as this machine is concerned, even before the server agrees.
|
||||
return;
|
||||
}
|
||||
|
||||
if (local.Payload is null)
|
||||
{
|
||||
unreadable++;
|
||||
return;
|
||||
}
|
||||
|
||||
var version = SyncVersions.NextVersion(local.ExpectedVersion);
|
||||
var opened = HostCipher.TryOpen(local.Payload, vaultKey.Span, local.EntityId, version);
|
||||
|
||||
if (opened is null)
|
||||
{
|
||||
unreadable++;
|
||||
return;
|
||||
}
|
||||
|
||||
hosts.Add(new VaultHost(
|
||||
local.EntityId,
|
||||
opened.Host,
|
||||
local.ExpectedVersion ?? 0,
|
||||
HasUnsyncedChanges: true,
|
||||
local.IsParked,
|
||||
opened.IsReadOnly));
|
||||
}
|
||||
|
||||
private (ReadOnlyMemory<byte> VaultKey, uint Generation) Key(Guid vaultId) =>
|
||||
keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||
? (vaultKey, generation)
|
||||
: throw new VaultUnreadableException(vaultId);
|
||||
|
||||
private async Task<int?> MirrorVersionAsync(
|
||||
Guid vaultId,
|
||||
Guid entityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await items
|
||||
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A null means the server has never seen this item, which is exactly what "create" is.
|
||||
return item?.Version;
|
||||
}
|
||||
|
||||
private async Task<StoredAncestor?> MirrorAncestorAsync(
|
||||
Guid vaultId,
|
||||
Guid entityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await items
|
||||
.FindAsync(vaultId, SyncEntityType.Host, entityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return item?.Payload is null
|
||||
? null
|
||||
: new StoredAncestor(item.Version, item.Payload, item.Fields);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Derives the id a resurrected item takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deterministic, from the original id and the version of the tombstone that displaced it. That matters
|
||||
/// because applying a pulled change is at-least-once: the cursor is saved after the changes are applied,
|
||||
/// so a process that dies in between re-applies them on the next start. A random id would resurrect the
|
||||
/// same host twice and leave the user with duplicates to sort out; this way the second attempt produces
|
||||
/// the same id and coalesces into the same outbox row.
|
||||
/// <para>
|
||||
/// Not a UUIDv7, and that is fine — the server treats item ids as opaque, and the time ordering a v7 id
|
||||
/// carries is meaningless for a copy created to rescue content from a deletion.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ResurrectionId
|
||||
{
|
||||
internal static Guid For(Guid entityId, int tombstoneVersion)
|
||||
{
|
||||
Span<byte> input = stackalloc byte[19 + 16 + sizeof(int)];
|
||||
|
||||
"dsh1/resurrect/v1"u8.CopyTo(input);
|
||||
var offset = 17;
|
||||
|
||||
input[offset++] = 0;
|
||||
input[offset++] = 0;
|
||||
|
||||
if (!entityId.TryWriteBytes(input[offset..], bigEndian: true, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to write the entity id.");
|
||||
}
|
||||
|
||||
offset += 16;
|
||||
BinaryPrimitives.WriteInt32BigEndian(input[offset..], tombstoneVersion);
|
||||
|
||||
Span<byte> digest = stackalloc byte[32];
|
||||
SHA256.HashData(input, digest);
|
||||
|
||||
return new Guid(digest[..16], bigEndian: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides what happens when a remote change collides with an unpushed local one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Shared by the pull and the push paths, because both meet the same six situations and must answer them
|
||||
/// identically — a pull that merged one way and a push that merged the other would make the outcome
|
||||
/// depend on which side happened to notice first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The governing rule is that nothing is discarded silently.</b> Where the two sides can be
|
||||
/// reconciled field by field, they are. Where they cannot, one value survives, the other is written to
|
||||
/// the conflict log verbatim, and the user is told. Where a deletion meets an edit, the edit survives:
|
||||
/// re-deleting costs a click, while a discarded edit may be the only copy of something the user cannot
|
||||
/// reconstruct.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ItemReconciler(
|
||||
ItemStore items,
|
||||
OutboxStore outbox,
|
||||
ConflictStore conflicts,
|
||||
VaultKeyring keyring)
|
||||
{
|
||||
/// <summary>Reconciles a remote change against the operation pending for the same item.</summary>
|
||||
internal Task ReconcileAsync(
|
||||
Guid vaultId,
|
||||
SyncChange remote,
|
||||
PendingOperation pending,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (remote.Operation == SyncOperation.Delete)
|
||||
{
|
||||
return pending.Operation == SyncOperation.Delete
|
||||
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
|
||||
? outbox.CompleteAsync(pending.Sequence, cancellationToken)
|
||||
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
|
||||
}
|
||||
|
||||
return pending.Operation == SyncOperation.Delete
|
||||
? AbandonLocalDeleteAsync(vaultId, remote, pending, report, cancellationToken)
|
||||
: MergeAsync(vaultId, remote, pending, report, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles a pending create that the server says already exists.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In practice this means an earlier push of the same create did land and its acknowledgement was
|
||||
/// lost — a timeout, a dropped connection — after which the local row may also have been edited. The
|
||||
/// resolution adopts the server's row as the base and re-offers the local content as an update, so
|
||||
/// the newer local state wins and no duplicate host appears. A genuine id collision between two
|
||||
/// clients is the other reading, and is not achievable with UUIDv7; if it happened, the server's
|
||||
/// values would be in the conflict log rather than gone.
|
||||
/// </remarks>
|
||||
internal async Task AdoptRemoteAsBaseAsync(
|
||||
Guid vaultId,
|
||||
SyncChange remote,
|
||||
PendingOperation pending,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (opened is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (local, remoteHost, vaultKey, generation) = opened.Value;
|
||||
|
||||
if (local == remoteHost)
|
||||
{
|
||||
// Byte-for-byte the same host: 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);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReviseAsUpdateAsync(
|
||||
vaultId, remote, pending, local, vaultKey, generation, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
remote.EntityId,
|
||||
ConflictKind.FieldOverridden,
|
||||
ConflictDetailCodec.Encode(
|
||||
$"An item with this id already existed on the server at version {remote.Version}. "
|
||||
+ "The version from this machine was kept; the server's values are recorded here."),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.Merged++;
|
||||
}
|
||||
|
||||
/// <summary>Merges two divergent edits of the same item.</summary>
|
||||
private async Task MergeAsync(
|
||||
Guid vaultId,
|
||||
SyncChange remote,
|
||||
PendingOperation pending,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (pending.Ancestor is null)
|
||||
{
|
||||
await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var opened = await OpenPairAsync(vaultId, remote, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (opened is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (local, remoteHost, vaultKey, generation) = opened.Value;
|
||||
|
||||
var ancestor = HostCipher.TryOpen(
|
||||
pending.Ancestor.Payload, vaultKey.Span, remote.EntityId, pending.Ancestor.Version);
|
||||
|
||||
if (ancestor is null)
|
||||
{
|
||||
// The base is unreadable, so a three-way merge is not possible. Falling back to a two-way
|
||||
// one would have to guess which side changed what, so the honest move is to keep the local
|
||||
// state as an update over the server's and record what was overridden.
|
||||
await AdoptRemoteAsBaseAsync(vaultId, remote, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var merged = HostSecretMerge.Merge(ancestor.Host, local, remoteHost);
|
||||
|
||||
await ReviseAsUpdateAsync(
|
||||
vaultId, remote, pending, merged.Merged, vaultKey, generation, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (merged.HasConflicts)
|
||||
{
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
remote.EntityId,
|
||||
ConflictKind.FieldOverridden,
|
||||
ConflictDetailCodec.Encode(
|
||||
$"'{merged.Merged.Label}' was edited in two places at once. "
|
||||
+ $"{merged.Conflicts.Count} field(s) could not be reconciled automatically.",
|
||||
merged.Conflicts),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
report.Merged++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps local content that a remote deletion would otherwise take with it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tombstone is accepted — arguing with it would conflict for ever, since a delete beats a late
|
||||
/// upsert on the server — and the local content is re-offered under a fresh id, labelled so the user
|
||||
/// can see what happened. That is the whole of "never silently drop a host": the original goes, the
|
||||
/// work does not.
|
||||
/// </remarks>
|
||||
private async Task ResurrectAsync(
|
||||
Guid vaultId,
|
||||
SyncChange remote,
|
||||
PendingOperation pending,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation))
|
||||
{
|
||||
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var local = pending.Payload is null
|
||||
? null
|
||||
: HostCipher.TryOpen(
|
||||
pending.Payload,
|
||||
vaultKey.Span,
|
||||
remote.EntityId,
|
||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||
|
||||
if (local is null || local.IsReadOnly)
|
||||
{
|
||||
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var restoredId = ResurrectionId.For(remote.EntityId, remote.Version);
|
||||
var restored = local.Host with { Label = $"{local.Host.Label} (restored)" };
|
||||
|
||||
// Queued before the original is cleared, and that order matters. These are two separate
|
||||
// transactions, so a process that dies between them has to fail in the direction that keeps the
|
||||
// work: this way the original stays pending and the next pass resurrects again — landing on the
|
||||
// same deterministic id, which coalesces into the row already queued. The other order would
|
||||
// leave the tombstone accepted and the local content gone.
|
||||
await outbox.QueueAsync(
|
||||
new QueuedChange(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
restoredId,
|
||||
SyncOperation.Upsert,
|
||||
ExpectedVersion: null,
|
||||
HostCipher.Seal(restored, vaultKey.Span, restoredId, generation, itemVersion: 1),
|
||||
HostFields.From(restored),
|
||||
Ancestor: null),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Now the tombstone can stand.
|
||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
remote.EntityId,
|
||||
ConflictKind.RemoteDeleteResurrected,
|
||||
ConflictDetailCodec.Encode(
|
||||
$"'{local.Host.Label}' was deleted elsewhere while this machine had unsaved changes. "
|
||||
+ $"The deletion stands and the local version was kept as '{restored.Label}'."),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.Resurrected++;
|
||||
}
|
||||
|
||||
/// <summary>Drops a local deletion because the other side edited the item instead.</summary>
|
||||
private async Task AbandonLocalDeleteAsync(
|
||||
Guid vaultId,
|
||||
SyncChange remote,
|
||||
PendingOperation pending,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
remote.EntityId,
|
||||
ConflictKind.LocalDeleteOverridden,
|
||||
ConflictDetailCodec.Encode(
|
||||
"This host was edited elsewhere after it was deleted here, so the deletion was not "
|
||||
+ "applied. Delete it again if that is still what you want."),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.DeletesAbandoned++;
|
||||
}
|
||||
|
||||
/// <summary>Re-offers a host as an update against the server's current version.</summary>
|
||||
private async Task ReviseAsUpdateAsync(
|
||||
Guid vaultId,
|
||||
SyncChange remote,
|
||||
PendingOperation pending,
|
||||
HostSecret host,
|
||||
ReadOnlyMemory<byte> vaultKey,
|
||||
uint generation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var nextVersion = SyncVersions.NextVersion(remote.Version);
|
||||
|
||||
await outbox.ReviseAsync(
|
||||
pending.Sequence,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion: remote.Version,
|
||||
HostCipher.Seal(host, vaultKey.Span, remote.EntityId, generation, nextVersion),
|
||||
HostFields.From(host),
|
||||
new StoredAncestor(remote.Version, remote.Payload!, remote.PlaintextFields),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Opens both sides of a collision, parking the operation if either will not open.</summary>
|
||||
private async Task<(HostSecret Local, HostSecret Remote, ReadOnlyMemory<byte> VaultKey, uint Generation)?>
|
||||
OpenPairAsync(
|
||||
Guid vaultId,
|
||||
SyncChange remote,
|
||||
PendingOperation pending,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||
|| pending.Payload is null
|
||||
|| remote.Payload is null)
|
||||
{
|
||||
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
var local = HostCipher.TryOpen(
|
||||
pending.Payload,
|
||||
vaultKey.Span,
|
||||
remote.EntityId,
|
||||
SyncVersions.NextVersion(pending.ExpectedVersion));
|
||||
|
||||
var remoteHost = HostCipher.TryOpen(
|
||||
remote.Payload, vaultKey.Span, remote.EntityId, remote.Version);
|
||||
|
||||
if (local is null || remoteHost is null)
|
||||
{
|
||||
await ParkAsync(vaultId, remote.EntityId, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (local.IsReadOnly || remoteHost.IsReadOnly)
|
||||
{
|
||||
// A newer client wrote fields this build cannot represent. Re-encoding would drop them, so
|
||||
// the item is left alone until this client is updated.
|
||||
await outbox.ParkAsync(
|
||||
pending.Sequence,
|
||||
"Written by a newer version of DodoSSH; update before editing this host.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
remote.EntityId,
|
||||
ConflictKind.TooNewToEdit,
|
||||
ConflictDetailCodec.Encode(
|
||||
"This host was written by a newer version of DodoSSH. It can be read but not "
|
||||
+ "merged here, because saving it would discard fields this version does not know "
|
||||
+ "about."),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.Parked++;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (local.Host, remoteHost.Host, vaultKey, generation);
|
||||
}
|
||||
|
||||
private async Task ParkAsync(
|
||||
Guid vaultId,
|
||||
Guid entityId,
|
||||
PendingOperation pending,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await outbox.ParkAsync(
|
||||
pending.Sequence,
|
||||
"The local or the server copy of this host could not be decrypted.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
SyncEntityType.Host,
|
||||
entityId,
|
||||
ConflictKind.Undecryptable,
|
||||
ConflictDetailCodec.Encode(
|
||||
"This host could not be decrypted, so the change made here could not be merged. "
|
||||
+ "The vault key may have been rotated, or the stored payload may not belong to this "
|
||||
+ "item."),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.Unreadable++;
|
||||
report.Parked++;
|
||||
}
|
||||
|
||||
/// <summary>Writes the server's version of an item into the local mirror.</summary>
|
||||
internal Task MirrorAsync(Guid vaultId, SyncChange change, CancellationToken cancellationToken) =>
|
||||
items.SaveAsync(
|
||||
new StoredItem(
|
||||
vaultId,
|
||||
change.EntityType,
|
||||
change.EntityId,
|
||||
change.Version,
|
||||
change.ChangeSequence,
|
||||
change.Payload,
|
||||
change.PlaintextFields,
|
||||
change.Operation == SyncOperation.Delete,
|
||||
change.UpdatedAt),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// One vault's synchronisation pass: pull, reconcile, push, pull again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Pull first, so a local change is merged against the newest server state before it is offered — which
|
||||
/// turns most would-be conflicts into ordinary merges and keeps the push round count down. Push second.
|
||||
/// Pull once more at the end only if something was pushed, so the mirror reflects the versions the
|
||||
/// server actually assigned.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Pulling does not decrypt.</b> A change with no local work pending is copied into the mirror as
|
||||
/// ciphertext and nothing more. Decryption happens when a merge needs it, or when the interface reads an
|
||||
/// item. For a five-thousand-item first sync that is the difference between plumbing bytes and running
|
||||
/// ten thousand AEAD operations for nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SyncEngine
|
||||
{
|
||||
private readonly ISyncApi api;
|
||||
private readonly ItemStore items;
|
||||
private readonly OutboxStore outbox;
|
||||
private readonly SyncStateStore syncState;
|
||||
private readonly ConflictStore conflicts;
|
||||
private readonly VaultKeyring keyring;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly SyncOptions options;
|
||||
private readonly ItemReconciler reconciler;
|
||||
|
||||
/// <summary>Creates the engine.</summary>
|
||||
public SyncEngine(
|
||||
ISyncApi api,
|
||||
ItemStore items,
|
||||
OutboxStore outbox,
|
||||
SyncStateStore syncState,
|
||||
ConflictStore conflicts,
|
||||
VaultKeyring keyring,
|
||||
TimeProvider clock,
|
||||
SyncOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ArgumentNullException.ThrowIfNull(outbox);
|
||||
ArgumentNullException.ThrowIfNull(syncState);
|
||||
ArgumentNullException.ThrowIfNull(conflicts);
|
||||
ArgumentNullException.ThrowIfNull(keyring);
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
|
||||
this.api = api;
|
||||
this.items = items;
|
||||
this.outbox = outbox;
|
||||
this.syncState = syncState;
|
||||
this.conflicts = conflicts;
|
||||
this.keyring = keyring;
|
||||
this.clock = clock;
|
||||
this.options = options ?? SyncOptions.Default;
|
||||
|
||||
reconciler = new ItemReconciler(items, outbox, conflicts, keyring);
|
||||
}
|
||||
|
||||
/// <summary>Runs a full pass over one vault.</summary>
|
||||
public async Task<SyncReport> SyncAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||
{
|
||||
var report = new SyncReportBuilder(vaultId);
|
||||
|
||||
await PullAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var pushedAnything = false;
|
||||
|
||||
for (var round = 1; ; round++)
|
||||
{
|
||||
var outcome = await DrainAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (outcome.Sent == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
pushedAnything = true;
|
||||
|
||||
if (!outcome.NeedsAnotherRound)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (round >= options.MaxPushRounds)
|
||||
{
|
||||
report.RoundsExhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (pushedAnything)
|
||||
{
|
||||
await PullAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return report.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every change available and applies it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cursor is saved after each page's changes are applied, which makes applying at-least-once
|
||||
/// rather than exactly-once: a process that dies between the two re-reads that page next time. That
|
||||
/// is deliberate and safe, because applying a change is a blind overwrite of a mirror row and a
|
||||
/// resurrection takes a deterministic id. The other ordering — save the cursor first — would lose
|
||||
/// changes outright, which no amount of idempotence can repair.
|
||||
/// </remarks>
|
||||
private async Task PullAsync(
|
||||
Guid vaultId,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
for (var page = 0; page < options.MaxPullPages; page++)
|
||||
{
|
||||
var response = await api.SyncPullAsync(
|
||||
vaultId,
|
||||
new SyncPullRequest(state.Cursor, options.PullPageSize, [SyncEntityType.Host]),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var change in response.Changes)
|
||||
{
|
||||
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
|
||||
report.Pulled++;
|
||||
}
|
||||
|
||||
var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal);
|
||||
|
||||
state = Record(vaultId, state, response, report);
|
||||
await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.HasMore)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// A server that claims more but neither returns a change nor moves the cursor would spin
|
||||
// this loop for ever. Stopping is the only safe reading of that answer.
|
||||
if (!advanced && response.Changes.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StoredSyncState Record(
|
||||
Guid vaultId,
|
||||
StoredSyncState state,
|
||||
SyncPullResponse response,
|
||||
SyncReportBuilder report)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
var skew = (long)(response.ServerTime - now).TotalMilliseconds;
|
||||
|
||||
report.ServerKeyGeneration = response.CurrentKeyGeneration;
|
||||
report.ServerTimeSkewMs = skew;
|
||||
|
||||
// A generation ahead of the key this client holds means the vault was rekeyed and this client's
|
||||
// grant has not been re-wrapped. Items pulled meanwhile are stored but cannot be read.
|
||||
keyring.TryGet(vaultId, out _, out var held);
|
||||
report.RekeyRequired = response.CurrentKeyGeneration > held;
|
||||
|
||||
return state with
|
||||
{
|
||||
Cursor = response.NextCursor,
|
||||
KeyGeneration = response.CurrentKeyGeneration,
|
||||
LastPulledAt = now,
|
||||
ServerTimeSkewMs = skew,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ApplyAsync(
|
||||
Guid vaultId,
|
||||
SyncChange change,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (change.EntityType != SyncEntityType.Host)
|
||||
{
|
||||
// Reserved in the contract but not yet syncable. Ignoring it keeps a newer server's extra
|
||||
// entity types from breaking an older client's pull.
|
||||
return;
|
||||
}
|
||||
|
||||
await reconciler.MirrorAsync(vaultId, change, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var pending = await outbox
|
||||
.FindAsync(vaultId, change.EntityType, change.EntityId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pending is null || pending.IsParked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await reconciler.ReconcileAsync(vaultId, change, pending, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>What one push round achieved.</summary>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private readonly record struct DrainOutcome(int Sent, int Conflicts, bool BatchWasFull)
|
||||
{
|
||||
internal bool NeedsAnotherRound => Conflicts > 0 || BatchWasFull;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends one batch and acts on each per-operation answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The cursor in the push response is deliberately ignored.</b> It sits after this push's own
|
||||
/// changes, so adopting it would skip any change another client committed at a lower sequence
|
||||
/// between this client's last pull and this push — permanently. Continuing from the cursor this
|
||||
/// client already holds re-reads its own writes, which costs one redundant page and is idempotent.
|
||||
/// </remarks>
|
||||
private async Task<DrainOutcome> DrainAsync(
|
||||
Guid vaultId,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = await outbox
|
||||
.TakeAsync(vaultId, options.MaxOperationsPerPush, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var operations = new List<SyncPushOperation>(pending.Count);
|
||||
var byOperationId = new Dictionary<Guid, PendingOperation>(pending.Count);
|
||||
|
||||
foreach (var operation in pending)
|
||||
{
|
||||
operations.Add(new SyncPushOperation(
|
||||
operation.OperationId,
|
||||
operation.EntityType,
|
||||
operation.EntityId,
|
||||
operation.Operation,
|
||||
operation.ExpectedVersion,
|
||||
operation.Payload,
|
||||
operation.Fields));
|
||||
|
||||
byOperationId[operation.OperationId] = operation;
|
||||
|
||||
await outbox.MarkDispatchedAsync(operation.Sequence, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var response = await api
|
||||
.SyncPushAsync(vaultId, new SyncPushRequest(operations), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var conflicted = 0;
|
||||
|
||||
foreach (var result in response.Results)
|
||||
{
|
||||
if (!byOperationId.TryGetValue(result.OperationId, out var operation))
|
||||
{
|
||||
// An id this client did not send. Nothing sane to do with it.
|
||||
continue;
|
||||
}
|
||||
|
||||
// The attempt count was incremented above, so the bound is read from the fresh value.
|
||||
var attempts = operation.Attempts + 1;
|
||||
|
||||
if (await HandleAsync(vaultId, operation with { Attempts = attempts }, result, report, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
conflicted++;
|
||||
}
|
||||
}
|
||||
|
||||
return new DrainOutcome(
|
||||
pending.Count, conflicted, pending.Count == options.MaxOperationsPerPush);
|
||||
}
|
||||
|
||||
/// <returns>Whether this answer warrants another push round.</returns>
|
||||
private async Task<bool> HandleAsync(
|
||||
Guid vaultId,
|
||||
PendingOperation operation,
|
||||
SyncPushResult result,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
switch (result.Status)
|
||||
{
|
||||
case SyncOperationStatus.Applied:
|
||||
case SyncOperationStatus.Duplicate:
|
||||
// Duplicate means an earlier push of this exact operation id already landed, so the
|
||||
// stored state is what this operation intended. Treated as success on purpose: that is
|
||||
// what makes a retry after a timeout exactly-once rather than merely at-least-once.
|
||||
await AcceptAsync(vaultId, operation, result, cancellationToken).ConfigureAwait(false);
|
||||
report.Pushed++;
|
||||
return false;
|
||||
|
||||
case SyncOperationStatus.Conflict:
|
||||
return await ResolveAsync(vaultId, operation, result, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
case SyncOperationStatus.Forbidden:
|
||||
await RejectAsync(
|
||||
vaultId,
|
||||
operation,
|
||||
"You no longer have permission to change this item.",
|
||||
report,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
|
||||
case SyncOperationStatus.Invalid:
|
||||
await RejectAsync(
|
||||
vaultId,
|
||||
operation,
|
||||
result.Detail ?? "The server rejected this change as invalid.",
|
||||
report,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
|
||||
default:
|
||||
await outbox.RecordFailureAsync(
|
||||
operation.Sequence,
|
||||
$"Unexpected push status {result.Status}.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records an accepted operation and clears it from the outbox.</summary>
|
||||
private async Task AcceptAsync(
|
||||
Guid vaultId,
|
||||
PendingOperation operation,
|
||||
SyncPushResult result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var expected = SyncVersions.NextVersion(operation.ExpectedVersion);
|
||||
var version = result.Version ?? expected;
|
||||
var isDelete = operation.Operation == SyncOperation.Delete;
|
||||
|
||||
// The payload was sealed at the version this client predicted, and the AAD binds that version.
|
||||
// If the server assigned a different one — which its own version check should make impossible —
|
||||
// storing the payload would leave a mirror row that never decrypts. Skip the write; the pull at
|
||||
// the end of the pass brings the authoritative row.
|
||||
var canMirror = isDelete || version == expected;
|
||||
|
||||
if (canMirror)
|
||||
{
|
||||
await items.SaveAsync(
|
||||
new StoredItem(
|
||||
vaultId,
|
||||
operation.EntityType,
|
||||
operation.EntityId,
|
||||
version,
|
||||
result.ChangeSequence ?? 0,
|
||||
isDelete ? null : operation.Payload,
|
||||
isDelete ? null : operation.Fields,
|
||||
isDelete,
|
||||
clock.GetUtcNow()),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> ResolveAsync(
|
||||
Guid vaultId,
|
||||
PendingOperation operation,
|
||||
SyncPushResult result,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (operation.Attempts >= options.MaxAttemptsBeforeParking)
|
||||
{
|
||||
await RejectAsync(
|
||||
vaultId,
|
||||
operation,
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Could not be reconciled after {operation.Attempts} attempts."),
|
||||
report,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.ServerEntity is null)
|
||||
{
|
||||
// The version check failed but the server has no such row. Re-offer it as a create.
|
||||
return await RetryAsCreateAsync(vaultId, operation, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await reconciler.MirrorAsync(vaultId, result.ServerEntity, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await reconciler
|
||||
.ReconcileAsync(vaultId, result.ServerEntity, operation, report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> RetryAsCreateAsync(
|
||||
Guid vaultId,
|
||||
PendingOperation operation,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (operation.Operation == SyncOperation.Delete)
|
||||
{
|
||||
// Nothing there to delete, so the intent is already satisfied.
|
||||
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var generation)
|
||||
|| operation.Payload is null)
|
||||
{
|
||||
await RejectAsync(
|
||||
vaultId, operation, "This item has no usable vault key.", report, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var local = HostCipher.TryOpen(
|
||||
operation.Payload,
|
||||
vaultKey.Span,
|
||||
operation.EntityId,
|
||||
SyncVersions.NextVersion(operation.ExpectedVersion));
|
||||
|
||||
if (local is null)
|
||||
{
|
||||
await RejectAsync(
|
||||
vaultId,
|
||||
operation,
|
||||
"The queued change could not be decrypted, so it could not be re-offered.",
|
||||
report,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-sealed at version 1, because that is what the server assigns to a create and the AAD binds
|
||||
// the version.
|
||||
await outbox.ReviseAsync(
|
||||
operation.Sequence,
|
||||
SyncOperation.Upsert,
|
||||
expectedVersion: null,
|
||||
HostCipher.Seal(local.Host, vaultKey.Span, operation.EntityId, generation, itemVersion: 1),
|
||||
HostFields.From(local.Host),
|
||||
ancestor: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Parks an operation the server will never accept, and says why.</summary>
|
||||
private async Task RejectAsync(
|
||||
Guid vaultId,
|
||||
PendingOperation operation,
|
||||
string reason,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await outbox.ParkAsync(operation.Sequence, reason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await conflicts.RecordAsync(
|
||||
vaultId,
|
||||
operation.EntityType,
|
||||
operation.EntityId,
|
||||
ConflictKind.Rejected,
|
||||
ConflictDetailCodec.Encode(reason),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.Parked++;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>Tuning for a sync pass.</summary>
|
||||
/// <remarks>
|
||||
/// The push batch default is well below the server's own cap. A smaller batch means a conflict is
|
||||
/// discovered and merged sooner, and it bounds how much work one rejected batch delays.
|
||||
/// </remarks>
|
||||
public sealed record SyncOptions
|
||||
{
|
||||
/// <summary>Changes requested per pull. The server clamps this.</summary>
|
||||
public int PullPageSize { get; init; } = 500;
|
||||
|
||||
/// <summary>Operations sent per push. Must not exceed the server's advertised maximum.</summary>
|
||||
public int MaxOperationsPerPush { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// How many push rounds one pass may take.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A bound rather than a loop until clear. Each round either applies, parks, or merges and advances
|
||||
/// a version, so it does terminate — but against a vault someone else is writing to continuously it
|
||||
/// could keep finding new conflicts, and a sync pass that never returns is worse than one that stops
|
||||
/// and says so.
|
||||
/// </remarks>
|
||||
public int MaxPushRounds { get; init; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// How many times an operation may be dispatched before it is parked for a person to look at.
|
||||
/// </summary>
|
||||
public int MaxAttemptsBeforeParking { get; init; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// How many pages one pull may read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A backstop against a server that keeps saying there is more. At the default page size this is
|
||||
/// half a million changes, well past any real vault, so reaching it means something is wrong rather
|
||||
/// than merely large.
|
||||
/// </remarks>
|
||||
public int MaxPullPages { get; init; } = 1000;
|
||||
|
||||
/// <summary>The defaults.</summary>
|
||||
public static SyncOptions Default { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>What one sync pass did.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Pulled">Changes received.</param>
|
||||
/// <param name="Pushed">Operations the server accepted.</param>
|
||||
/// <param name="Merged">Items where local and remote edits were reconciled.</param>
|
||||
/// <param name="Resurrected">
|
||||
/// Items someone else deleted while this machine had unpushed edits. The tombstone stands and the local
|
||||
/// content survives under a new id; nothing is discarded.
|
||||
/// </param>
|
||||
/// <param name="DeletesAbandoned">
|
||||
/// Local deletions dropped because the other side edited the item instead. An edit outlives a removal:
|
||||
/// re-deleting costs a click, and a discarded edit may be irrecoverable.
|
||||
/// </param>
|
||||
/// <param name="Parked">Operations the server refused, now waiting on a person.</param>
|
||||
/// <param name="Unreadable">Items whose payload would not decrypt.</param>
|
||||
/// <param name="ServerKeyGeneration">The generation the server reports for this vault.</param>
|
||||
/// <param name="RekeyRequired">
|
||||
/// True when the server's generation is ahead of the key this client holds, so items cannot be read
|
||||
/// until new grants arrive.
|
||||
/// </param>
|
||||
/// <param name="ServerTimeSkewMs">
|
||||
/// Difference between the server's clock and this machine's. Recorded, never acted on — the merge uses
|
||||
/// versions and a retained ancestor, so a skewed clock must not be able to decide which edit wins.
|
||||
/// </param>
|
||||
/// <param name="RoundsExhausted">
|
||||
/// True when the push loop hit <see cref="SyncOptions.MaxPushRounds"/> with work still outstanding. Not
|
||||
/// a failure: the next pass continues from here.
|
||||
/// </param>
|
||||
public sealed record SyncReport(
|
||||
Guid VaultId,
|
||||
int Pulled,
|
||||
int Pushed,
|
||||
int Merged,
|
||||
int Resurrected,
|
||||
int DeletesAbandoned,
|
||||
int Parked,
|
||||
int Unreadable,
|
||||
uint ServerKeyGeneration,
|
||||
bool RekeyRequired,
|
||||
long ServerTimeSkewMs,
|
||||
bool RoundsExhausted)
|
||||
{
|
||||
/// <summary>Whether anything happened that a user should be told about.</summary>
|
||||
public bool NeedsAttention =>
|
||||
Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
|
||||
}
|
||||
|
||||
/// <summary>Accumulates a <see cref="SyncReport"/> while a pass runs.</summary>
|
||||
internal sealed class SyncReportBuilder(Guid vaultId)
|
||||
{
|
||||
internal int Pulled { get; set; }
|
||||
|
||||
internal int Pushed { get; set; }
|
||||
|
||||
internal int Merged { get; set; }
|
||||
|
||||
internal int Resurrected { get; set; }
|
||||
|
||||
internal int DeletesAbandoned { get; set; }
|
||||
|
||||
internal int Parked { get; set; }
|
||||
|
||||
internal int Unreadable { get; set; }
|
||||
|
||||
internal uint ServerKeyGeneration { get; set; }
|
||||
|
||||
internal bool RekeyRequired { get; set; }
|
||||
|
||||
internal long ServerTimeSkewMs { get; set; }
|
||||
|
||||
internal bool RoundsExhausted { get; set; }
|
||||
|
||||
internal SyncReport Build() =>
|
||||
new(
|
||||
vaultId,
|
||||
Pulled,
|
||||
Pushed,
|
||||
Merged,
|
||||
Resurrected,
|
||||
DeletesAbandoned,
|
||||
Parked,
|
||||
Unreadable,
|
||||
ServerKeyGeneration,
|
||||
RekeyRequired,
|
||||
ServerTimeSkewMs,
|
||||
RoundsExhausted);
|
||||
}
|
||||
|
||||
/// <summary>The record written to the conflict log when a merge had to override something.</summary>
|
||||
/// <param name="Field">Which field, as a path.</param>
|
||||
/// <param name="DiscardedSide">Whose intent was overridden: <c>Local</c> or <c>Remote</c>.</param>
|
||||
/// <param name="Kept">The value that survives.</param>
|
||||
/// <param name="Discarded">The value that lost.</param>
|
||||
/// <param name="DiscardedWasRemoval">Whether what lost was a deletion rather than a value.</param>
|
||||
public sealed record ConflictDetailEntry(
|
||||
string Field,
|
||||
string DiscardedSide,
|
||||
string? Kept,
|
||||
string? Discarded,
|
||||
bool DiscardedWasRemoval);
|
||||
|
||||
/// <summary>A conflict log entry.</summary>
|
||||
/// <param name="Summary">One line for a person to read.</param>
|
||||
/// <param name="Fields">Everything the merge overrode.</param>
|
||||
public sealed record ConflictDetail(string Summary, IReadOnlyList<ConflictDetailEntry> Fields);
|
||||
|
||||
/// <summary>
|
||||
/// Serialises what a merge discarded, for the conflict log.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bytes crossing into <c>ConflictStore</c> are plaintext vault content and are sealed there under
|
||||
/// the LocalCacheKey. Deliberately its own format rather than the item payload's: this is local
|
||||
/// bookkeeping and is never pushed, so it has no compatibility obligation to any other client.
|
||||
/// </remarks>
|
||||
internal static class ConflictDetailCodec
|
||||
{
|
||||
internal static byte[] Encode(string summary, IReadOnlyList<HostFieldConflict> conflicts) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(
|
||||
new ConflictDetail(
|
||||
summary,
|
||||
[.. conflicts.Select(c => new ConflictDetailEntry(
|
||||
c.Field,
|
||||
c.DiscardedSide.ToString(),
|
||||
c.Kept,
|
||||
c.Discarded,
|
||||
c.DiscardedWasRemoval))]),
|
||||
ConflictJsonContext.Default.ConflictDetail);
|
||||
|
||||
internal static byte[] Encode(string summary) => Encode(summary, []);
|
||||
|
||||
/// <summary>Reads a detail back, for display.</summary>
|
||||
internal static ConflictDetail? TryDecode(ReadOnlySpan<byte> utf8)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(utf8, ConflictJsonContext.Default.ConflictDetail);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(ConflictDetail))]
|
||||
internal sealed partial class ConflictJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// The vault keys held for the duration of an unlocked session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One place that holds plaintext vault keys, so there is one place that clears them. Every store and
|
||||
/// every cipher call borrows a key from here rather than keeping a copy, which is what makes
|
||||
/// "the keys exist only while unlocked" a property of the code and not of everyone's discipline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A grant that will not open is not an error: it means the vault has been rekeyed and this client's
|
||||
/// grant has not been re-wrapped yet, or the grant was fabricated. Both leave the vault temporarily
|
||||
/// unreadable and both are reported rather than thrown, so one bad grant does not take the other vaults
|
||||
/// down with it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultKeyring : IDisposable
|
||||
{
|
||||
private readonly Dictionary<Guid, byte[]> keys = [];
|
||||
private readonly Dictionary<Guid, uint> generations = [];
|
||||
private bool disposed;
|
||||
|
||||
private VaultKeyring()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Vaults whose grant could not be opened, and which are therefore unreadable.</summary>
|
||||
public IReadOnlyList<Guid> Unopened { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Opens every grant the bundle can.
|
||||
/// </summary>
|
||||
/// <param name="bundle">The unlocked identity keys.</param>
|
||||
/// <param name="vaults">The cached vault list, each with its wrapped key.</param>
|
||||
public static VaultKeyring Open(UserSecretBundle bundle, IReadOnlyList<StoredVault> vaults)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
ArgumentNullException.ThrowIfNull(vaults);
|
||||
|
||||
var keyring = new VaultKeyring();
|
||||
var unopened = new List<Guid>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var vault in vaults)
|
||||
{
|
||||
if (vault.WrappedVaultKey is null)
|
||||
{
|
||||
// The server said so itself: a grant awaiting re-wrap after a rekey.
|
||||
unopened.Add(vault.VaultId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = VaultKeys.TryUnwrap(
|
||||
bundle.EncryptionKey,
|
||||
vault.WrappedVaultKey,
|
||||
vault.VaultId,
|
||||
vault.KeyGeneration);
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
unopened.Add(vault.VaultId);
|
||||
continue;
|
||||
}
|
||||
|
||||
keyring.keys[vault.VaultId] = key;
|
||||
keyring.generations[vault.VaultId] = vault.KeyGeneration;
|
||||
}
|
||||
|
||||
keyring.Unopened = unopened;
|
||||
return keyring;
|
||||
}
|
||||
catch
|
||||
{
|
||||
keyring.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Borrows a vault's key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The returned memory is the keyring's own buffer, not a copy, and is zeroed when the keyring is
|
||||
/// disposed. Callers must not retain it past the operation they borrowed it for.
|
||||
/// </remarks>
|
||||
public bool TryGet(Guid vaultId, out ReadOnlyMemory<byte> vaultKey, out uint keyGeneration)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (keys.TryGetValue(vaultId, out var key))
|
||||
{
|
||||
vaultKey = key;
|
||||
keyGeneration = generations[vaultId];
|
||||
return true;
|
||||
}
|
||||
|
||||
vaultKey = default;
|
||||
keyGeneration = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Whether this vault can be read at all.</summary>
|
||||
public bool CanRead(Guid vaultId) => !disposed && keys.ContainsKey(vaultId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
foreach (var key in keys.Values)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
|
||||
keys.Clear();
|
||||
generations.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when an operation needs a vault key the keyring does not hold.</summary>
|
||||
/// <remarks>
|
||||
/// An exception rather than a silent no-op, because every caller that reaches this point has already
|
||||
/// been given the chance to check <see cref="VaultKeyring.CanRead"/>. Continuing without the key would
|
||||
/// mean writing an item nobody can open.
|
||||
/// </remarks>
|
||||
[SuppressMessage(
|
||||
"Design",
|
||||
"CA1032:Implement standard exception constructors",
|
||||
Justification = "The vault id is required context; a message-only constructor would lose it.")]
|
||||
public sealed class VaultUnreadableException(Guid vaultId)
|
||||
: InvalidOperationException(
|
||||
$"Vault {vaultId} has no usable key. Its grant is missing or awaiting re-wrap after a rekey.")
|
||||
{
|
||||
/// <summary>The vault that cannot be read.</summary>
|
||||
public Guid VaultId { get; } = vaultId;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
{
|
||||
"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.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,9 @@ namespace DodoSSH.Contracts;
|
||||
[JsonSerializable(typeof(SyncPushRequest))]
|
||||
[JsonSerializable(typeof(SyncPushResponse))]
|
||||
[JsonSerializable(typeof(SyncChange))]
|
||||
// Registered in its own right, not only as a member of the sync DTOs: the client's local
|
||||
// cache seals this record under the LocalCacheKey and needs its type info directly.
|
||||
[JsonSerializable(typeof(SyncPlaintextFields))]
|
||||
[JsonSerializable(typeof(RelayTicketRequest))]
|
||||
[JsonSerializable(typeof(RelayTicketResponse))]
|
||||
[JsonSerializable(typeof(RelaySessionSummary))]
|
||||
|
||||
@@ -15,11 +15,32 @@ namespace DodoSSH.Contracts;
|
||||
/// rather than transmitted, and because they are what makes a lazy re-encrypt-on-write
|
||||
/// migration possible later.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Added 2026-07-29:</b> <see cref="WrappedDataKey"/> and <see cref="DataKeyId"/>. The
|
||||
/// specification has required a per-item data key since §3, the database has carried
|
||||
/// <c>data_key_wrap</c> and <c>content_key_id</c> since the first migration, and
|
||||
/// <c>DshAad.ItemPayload</c> binds the data key id — but this record had nowhere to put either,
|
||||
/// so a spec-compliant item could not actually be transmitted. Found by writing the client that
|
||||
/// has to produce one. Safe to add now and not later: no payload has ever been stored, and only
|
||||
/// clients can re-encrypt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Envelope">The complete DSH1 envelope, base64 on the wire.</param>
|
||||
/// <param name="WrappedDataKey">
|
||||
/// The item's data key, wrapped under the vault key. Also a DSH1 envelope, and also opaque. It
|
||||
/// travels with the payload because a data key belongs to one item <em>version</em>: rotating a
|
||||
/// vault key re-wraps 32 bytes per item and never rewrites a content blob.
|
||||
/// </param>
|
||||
/// <param name="DataKeyId">
|
||||
/// Identifies the data key, stored as <c>content_key_id</c>. Part of the payload's AAD, so a
|
||||
/// server cannot pair one item's envelope with another's key wrap. Reserved as the seam for
|
||||
/// per-item grants in M5.
|
||||
/// </param>
|
||||
/// <param name="KeyGeneration">Vault key generation this payload was encrypted under.</param>
|
||||
/// <param name="AadVersion">Version of the AAD derivation rule used.</param>
|
||||
public sealed record EncryptedPayload(
|
||||
byte[] Envelope,
|
||||
byte[] WrappedDataKey,
|
||||
Guid DataKeyId,
|
||||
uint KeyGeneration,
|
||||
byte AadVersion);
|
||||
|
||||
@@ -36,13 +36,17 @@ DodoSSH.Contracts.EncryptedPayload
|
||||
DodoSSH.Contracts.EncryptedPayload.<Clone>$() -> DodoSSH.Contracts.EncryptedPayload!
|
||||
DodoSSH.Contracts.EncryptedPayload.AadVersion.get -> byte
|
||||
DodoSSH.Contracts.EncryptedPayload.AadVersion.init -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.Deconstruct(out byte[]! Envelope, out uint KeyGeneration, out byte AadVersion) -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.EncryptedPayload(byte[]! Envelope, uint KeyGeneration, byte AadVersion) -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.DataKeyId.get -> System.Guid
|
||||
DodoSSH.Contracts.EncryptedPayload.DataKeyId.init -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.Deconstruct(out byte[]! Envelope, out byte[]! WrappedDataKey, out System.Guid DataKeyId, out uint KeyGeneration, out byte AadVersion) -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.EncryptedPayload(byte[]! Envelope, byte[]! WrappedDataKey, System.Guid DataKeyId, uint KeyGeneration, byte AadVersion) -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.Envelope.get -> byte[]!
|
||||
DodoSSH.Contracts.EncryptedPayload.Envelope.init -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.Equals(DodoSSH.Contracts.EncryptedPayload? other) -> bool
|
||||
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.get -> uint
|
||||
DodoSSH.Contracts.EncryptedPayload.KeyGeneration.init -> void
|
||||
DodoSSH.Contracts.EncryptedPayload.WrappedDataKey.get -> byte[]!
|
||||
DodoSSH.Contracts.EncryptedPayload.WrappedDataKey.init -> void
|
||||
DodoSSH.Contracts.EnrollmentRequest
|
||||
DodoSSH.Contracts.EnrollmentRequest.<Clone>$() -> DodoSSH.Contracts.EnrollmentRequest!
|
||||
DodoSSH.Contracts.EnrollmentRequest.Deconstruct(out DodoSSH.Contracts.KeyStatement! Statement, out byte[]! StatementSignature, out string! IdentityProviderToken, out byte[]! WrappedPrivateKey, out DodoSSH.Contracts.KdfParameters! KdfParameters, out byte[]? DevicePublicKey, out byte[]? DeviceWrappedPrivateKey, out byte[]? RecoveryWrappedPrivateKey, out DodoSSH.Contracts.KdfParameters? RecoveryKdfParameters, out DodoSSH.Contracts.PersonalVaultRequest! PersonalVault) -> void
|
||||
|
||||
@@ -91,8 +91,16 @@ public sealed record SyncPushResult(
|
||||
/// <summary>Per-operation outcomes for a push.</summary>
|
||||
/// <param name="Results">One entry per submitted operation, in request order.</param>
|
||||
/// <param name="Cursor">
|
||||
/// A cursor positioned after every change this push produced, so the client can continue
|
||||
/// pulling without re-reading its own writes.
|
||||
/// A cursor positioned after every change this push produced.
|
||||
/// <para>
|
||||
/// <b>Adopting this is only safe if the client had already pulled to the log head.</b> The cursor is
|
||||
/// a sequence position, so if another client committed at sequence 10 while this push took 11,
|
||||
/// jumping to 11 skips 10 permanently. The per-vault advisory lock guarantees that sequence order
|
||||
/// matches commit order; it cannot tell this client about a write it never read. A client that
|
||||
/// keeps its own cursor and re-reads its own writes — which is idempotent, since applying a change
|
||||
/// is a blind overwrite of a local mirror — is strictly safer, and that is what
|
||||
/// <c>DodoSSH.Client.Sync</c> does.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record SyncPushResponse(
|
||||
IReadOnlyList<SyncPushResult> Results,
|
||||
|
||||
@@ -147,6 +147,17 @@ public static class CryptoSpec
|
||||
|
||||
/// <summary>A known SSH host key.</summary>
|
||||
KnownHostKey = 11,
|
||||
|
||||
// 12 and 13 close a hole rather than adding a feature. Contracts.SyncEntityType has carried
|
||||
// HostTag and HostCredential since it was frozen, so those items are syncable — but with no
|
||||
// resource type here, their payloads had nothing to bind an AAD to. Added 2026-07-29, while
|
||||
// the enum is still append-only and no such item has been stored.
|
||||
|
||||
/// <summary>A host-to-tag association.</summary>
|
||||
HostTag = 12,
|
||||
|
||||
/// <summary>A host-to-credential association.</summary>
|
||||
HostCredential = 13,
|
||||
}
|
||||
|
||||
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
||||
|
||||
@@ -124,16 +124,30 @@ public static class DshAad
|
||||
itemVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Binds a record in the client's own on-disk cache.
|
||||
/// Binds a record in the client's own on-disk cache to the row that holds it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separate from every server-side purpose so a cache record can never be accepted as vault
|
||||
/// content, nor the reverse. The cache is local, so the adversary here is another process on
|
||||
/// the same machine rather than the server.
|
||||
/// content, nor the reverse. The threat model differs too: the cache is local, so the adversary
|
||||
/// is a process or a backup with access to the file rather than the server.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Changed 2026-07-29</b> from taking the user id to taking the record's identity. The user
|
||||
/// was already bound by the key — <c>LocalCacheKey</c> is derived from that user's master key, so
|
||||
/// another user's record cannot decrypt at all — which left the AAD binding nothing, and a cache
|
||||
/// record could be moved to a different row of the same user's cache. For plaintext columns like
|
||||
/// a relay address that is not academic: swapping two rows would point one host's connection at
|
||||
/// another host's address. No cache has ever been written, so there is nothing to migrate.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static AadDescriptor LocalCache(Guid userId) =>
|
||||
/// <param name="resourceType">What kind of item the record belongs to.</param>
|
||||
/// <param name="recordId">The row it belongs to — an item id, or a conflict entry's own id.</param>
|
||||
public static AadDescriptor LocalCache(
|
||||
CryptoSpec.AadResourceType resourceType,
|
||||
Guid recordId) =>
|
||||
AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.LocalCache,
|
||||
CryptoSpec.AadResourceType.User,
|
||||
userId);
|
||||
resourceType,
|
||||
recordId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user