Files
jaap-jan 8d2416a602 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.
2026-07-29 10:27:37 +02:00

205 lines
7.7 KiB
C#

using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Sealing and opening a host payload.
/// </summary>
/// <remarks>
/// Mostly negative tests, and deliberately so. docs/crypto.md §4.4 claims a server holding every
/// ciphertext still cannot move a payload between rows, roll one back to an earlier generation, or pair
/// one item's envelope with another's key wrap. Those claims are only worth making if something checks
/// them at the layer that actually assembles the AAD.
/// </remarks>
public sealed class HostCipherTests
{
private static readonly Guid HostA = Guid.Parse("0192f0c8-000a-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid HostB = Guid.Parse("0192f0c8-000b-7c3d-8e4f-5a6b7c8d9e0f");
private readonly byte[] vaultKey = VaultKeys.Create();
private readonly byte[] otherVaultKey = VaultKeys.Create();
[Fact]
public void AHost_RoundTrips()
{
var host = Host();
var payload = HostCipher.Seal(host, vaultKey, HostA, keyGeneration: 1, itemVersion: 1);
var opened = HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1);
opened.ShouldNotBeNull();
opened.Host.ShouldBe(host);
opened.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void EverySeal_UsesAFreshDataKey()
{
// One key per item version, so nonce-collision analysis is moot and a rotation re-wraps 32 bytes
// rather than rewriting content.
var host = Host();
var first = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
var second = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
first.DataKeyId.ShouldNotBe(second.DataKeyId);
first.WrappedDataKey.ShouldNotBe(second.WrappedDataKey);
first.Envelope.ShouldNotBe(second.Envelope);
}
[Fact]
public void APayload_CannotBeReadAsAnotherItem()
{
// The property that stops a server pasting one host's payload onto another row.
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload, vaultKey, HostB, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void APayload_CannotBeReadAtAnotherVersion()
{
// The sharpest edge in this layer. A payload is sealed at the version the server will assign, so
// getting that prediction wrong produces something that encrypts cleanly and never decrypts. The
// binding is what turns a silent corruption into a visible failure.
var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 1, itemVersion: 2);
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 3).ShouldBeNull();
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 2).ShouldNotBeNull();
}
[Fact]
public void APayload_CannotBeRolledBackToAnEarlierKeyGeneration()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, keyGeneration: 2, itemVersion: 1);
// The generation travels with the payload, so a server rewriting the column to 1 changes the AAD
// the client recomputes and the tag fails.
var rolledBack = payload with { KeyGeneration = 1 };
HostCipher.TryOpen(rolledBack, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void APayload_CannotBeReadWithAnotherVaultsKey()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload, otherVaultKey, HostA, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void OneItemsEnvelope_CannotBePairedWithAnothersKeyWrap()
{
// What content_key_id is in the AAD for. Without it the two halves of a payload would be
// interchangeable and a server could mix them.
var first = HostCipher.Seal(Host(label: "one"), vaultKey, HostA, 1, 1);
var second = HostCipher.Seal(Host(label: "two"), vaultKey, HostA, 1, 1);
var mixed = first with { WrappedDataKey = second.WrappedDataKey };
HostCipher.TryOpen(mixed, vaultKey, HostA, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void ATamperedEnvelope_DoesNotOpen()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
var tampered = payload.Envelope.ToArray();
tampered[^1] ^= 0xFF;
HostCipher.TryOpen(payload with { Envelope = tampered }, vaultKey, HostA, 1).ShouldBeNull();
}
[Fact]
public void ASubstitutedDataKeyId_DoesNotOpen()
{
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload with { DataKeyId = Guid.CreateVersion7() }, vaultKey, HostA, 1)
.ShouldBeNull();
}
[Fact]
public void AMissingDataKey_IsRefusedRatherThanThrowing()
{
// What a row written before the data key existed in the contract would look like. It must degrade
// to one unreadable item, not to an exception inside a sync pass.
var payload = HostCipher.Seal(Host(), vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload with { WrappedDataKey = [] }, vaultKey, HostA, 1).ShouldBeNull();
HostCipher.TryOpen(payload, vaultKey, HostA, itemVersion: 0).ShouldBeNull();
}
[Fact]
public void Seal_RefusesAVersionBelowOne()
{
// Versions start at 1, and a zero would silently produce a payload no push could ever match.
Should.Throw<ArgumentOutOfRangeException>(
() => HostCipher.Seal(Host(), vaultKey, HostA, 1, itemVersion: 0));
}
[Fact]
public void Seal_RefusesAHostThatCannotBeStored()
{
Should.Throw<ArgumentException>(
() => HostCipher.Seal(Host(label: " "), vaultKey, HostA, 1, 1));
}
[Fact]
public void TheNextVersion_IsOneMoreThanTheVersionBeingReplaced()
{
// The prediction both the sealing and the opening side depend on. If these two ever disagreed the
// result would be an item that encrypts and never decrypts, so they share one definition.
SyncVersions.NextVersion(null).ShouldBe(1);
SyncVersions.NextVersion(1).ShouldBe(2);
SyncVersions.NextVersion(41).ShouldBe(42);
}
[Fact]
public void ARelayEnabledHost_ExposesItsAddressAndNothingElseDoes()
{
// The single point at which a hostname can leave the payload. With relay off the server learns
// only that an item exists; see ADR 0004.
var off = HostFields.From(Host(relayEnabled: false));
off.RelayEnabled.ShouldBeFalse();
off.Hostname.ShouldBeNull();
off.Port.ShouldBeNull();
var on = HostFields.From(Host(hostname: "bastion.internal", port: 2222, relayEnabled: true));
on.RelayEnabled.ShouldBeTrue();
on.Hostname.ShouldBe("bastion.internal");
on.Port.ShouldBe(2222);
}
[Fact]
public void TheRelayFlagIsInsideThePayload_SoItSurvivesARoundTrip()
{
// It has to be, or two clients could silently disagree about it and one would re-expose an
// address the other had just withdrawn.
var host = Host(relayEnabled: true);
var payload = HostCipher.Seal(host, vaultKey, HostA, 1, 1);
HostCipher.TryOpen(payload, vaultKey, HostA, 1)!.Host.RelayEnabled.ShouldBeTrue();
}
private static HostSecret Host(
string label = "prod-db",
string hostname = "db.internal",
int port = 22,
bool relayEnabled = false) =>
new()
{
Label = label,
Hostname = hostname,
Port = port,
Username = "deploy",
Options = HostOptions.Create([new HostOption("Compression", "yes")]),
RelayEnabled = relayEnabled,
};
}