Files
DodoSSH/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs
T
jaap-janandClaude Opus 5 8c04ba60b0 Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing
on either head has changed, which is deliberate — the plan orders these first
because everything the editors will bind to has to exist and be merge-safe
before a screen can offer it.

HostGroupSecret gains a parent and four defaults, and the codec gains the
version rule it never had. It stamped CurrentSchemaVersion unconditionally,
which was harmless with one field and one version and stops being harmless
here: upgrading one machine and renaming any group would have made that group
uneditable on every machine still on the old build. It now emits the lowest
version that loses nothing, so a flat group with no defaults still encodes at
version 1, byte for byte, pinned against a literal.

Tags become a real item over the reserved slot. Secret, codec, merge, cipher,
repository, both registries, the EF entity and a generated AddTagItem
migration. TagCipher names AadResourceType.Tag as a constant rather than
casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum
and 5 there is Credential — a cast would seal every tag under the resource
type for a password, encrypt and decrypt perfectly on the machine that wrote
it, and only fail when another implementation refused the item, by which time
the AAD is frozen into stored ciphertext. HostTag stays reserved and unused:
the one thing the join buys over a set on the host is bought instead by
merging TagIds per id.

HostSecret grows TagIds and Port goes nullable, which is the change with the
widest blast radius and the only one that loses an item rather than locking
one. A host with no port of its own omits the property, an older build reads
int Port as 0, and TryValidate refuses it — unreadable rather than read-only.
That cost is confined to hosts which actually inherit, because the version is
a maximum over the fields present; the alternative, writing 22 into every
host, is the lie inheritance exists to stop telling.

One decision the plan did not specify. "Three states where there were two" is
four — key, credential, typed password, or the group's answer — and two
nullable ids carry three. Naming neither id now means inherit, so
AsksForPassword says "a typed password even under a group that lends a key"
out loud. Only true is ever written and a decoded false folds back to null, so
a host that never touched it encodes as it always did. Nothing already stored
changed meaning: no group could lend a binding before this build, so every
existing host resolves exactly as it did.

HostInheritance is the resolver, and its visited set is load-bearing rather
than defensive. Two clients can each re-parent A under B and B under A while
offline; the merge sees one item against one item and the server sees
ciphertext, so nothing upstream can refuse the pair. With inheritance the
chain is walked at connect time, so an unguarded cycle is not an undrawable
sidebar — it is a shell that never opens. Stopping at the first repeat
degrades it to a group that reads as a root, and clearing the parent is the
repair.

A tag set turns out to be the one field on a host that can never ask the user
anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so
the both-sides-moved-differently branch of the keyed merge is unreachable —
asserted over the whole eight-row matrix. The conflict loop is kept anyway,
because that proof is one edit from ceasing to hold and what it would cause is
a discarded tag nothing records.

Three guard tests failed by design and were fixed rather than relaxed: the
ordered pull filter, the AAD pinning table, and the server's refusal of a
plaintext parent — that last one survives with its reason rewritten, because
the refusal now means "the parent is not the server's to hold" rather than
"there is no such thing as a parent". The prose that said groups are flat is
rewritten in all four places it appeared, not deleted.

The five view-model sites that read Port directly now go through the resolver,
which is a down payment on step 4 rather than the whole of it. HostFields.From
still emits the stored port, and that is the one remaining place where an
unresolved read would be a wrong wire rather than a wrong label.

Verified by the whole suite: 1382 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display, because nothing on a display has
changed yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:21:02 +02:00

394 lines
16 KiB
C#

using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Each item type must be sealed under its own AAD resource type, and the two enums that name item types
/// deliberately do not agree.
/// </summary>
/// <remarks>
/// <para>
/// <c>SyncEntityType</c> lists only syncable items, so <c>Host</c> is 1 and <c>SshKey</c> is 3.
/// <c>CryptoSpec.AadResourceType</c> also covers users, devices and vaults, so the same two are 4 and 6. A
/// cipher written by copying its neighbour and casting the wire type would therefore seal a private key as
/// if it were a vault — encrypting cleanly, decrypting cleanly on the machine that wrote it, and violating
/// docs/crypto.md in a way that surfaces only when another implementation reads the item.
/// </para>
/// <para>
/// These tests are cheap and the alternative is a comment. The payload's AAD is frozen, so getting this
/// wrong is not something a later release can quietly correct: only clients can re-encrypt, and they can
/// only do it if they can still open what is there.
/// </para>
/// </remarks>
public sealed class AadResourceTypeTests
{
/// <remarks>
/// The pairing stated as a table. If <c>AadResourceType</c> is ever renumbered, this is what says so —
/// loudly, and before anything is written under the new numbers.
/// </remarks>
[Theory]
[InlineData(SyncEntityType.Host, CryptoSpec.AadResourceType.Host)]
[InlineData(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential)]
[InlineData(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey)]
[InlineData(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup)]
[InlineData(SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag)]
[InlineData(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet)]
[InlineData(SyncEntityType.PortForward, CryptoSpec.AadResourceType.PortForward)]
[InlineData(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey)]
[InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)]
[InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)]
[InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)]
public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently(
SyncEntityType wire,
CryptoSpec.AadResourceType resource)
{
Enum.GetName(wire).ShouldBe(Enum.GetName(resource));
// The point of the whole file: same name, different number. A test asserting equality here would be
// asserting the bug.
((int)wire).ShouldNotBe(
(int)resource,
$"{wire} happens to share a value with its resource type, which makes a cast look correct. "
+ "Either the enums were renumbered or this pairing needs re-checking by hand.");
}
/// <summary>
/// The specified pairing of wire type to AAD resource type, stated out of band, one row per cipher.
/// </summary>
/// <remarks>
/// The single source for both tests below: what each cipher must use, and which types must have a cipher
/// pinned at all. Adding an item type without adding a row here fails
/// <see cref="EverySynchronisedType_HasItsCipherPinnedHere"/>.
/// </remarks>
private static readonly (SyncEntityType Wire, CryptoSpec.AadResourceType Resource)[] PinnedPairs =
[
(SyncEntityType.Host, CryptoSpec.AadResourceType.Host),
(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey),
(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential),
(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey),
(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup),
(SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag),
(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet),
(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore),
];
public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
{
get
{
var data = new TheoryData<SyncEntityType, CryptoSpec.AadResourceType>();
foreach (var (wire, resource) in PinnedPairs)
{
data.Add(wire, resource);
}
return data;
}
}
/// <remarks>
/// <para>
/// Opened <em>independently</em>, through the low-level <c>ItemKeys</c> API with the resource type this
/// table names rather than the one the cipher holds. That is the whole point, and it is the property two
/// earlier versions of this file lacked: checking that a key payload does not open as a host is true
/// however both ciphers are misconfigured, because <c>Seal</c> and <c>TryOpen</c> share one constant. A
/// test that compares an implementation against itself cannot catch a self-consistent mistake.
/// </para>
/// <para>
/// Written as a table over every cipher, not one test per cipher, because the same hole was found three
/// times — twice by mutation testing after the fact. Pointing <c>CredentialCipher</c> at
/// <c>AadResourceType.Vault</c> passed the entire suite until this existed.
/// </para>
/// </remarks>
[Theory]
[MemberData(nameof(Pinned))]
public void EveryCipher_SealsUnderTheResourceTypeTheSpecificationNames(
SyncEntityType wire,
CryptoSpec.AadResourceType resource)
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
const uint Generation = 1;
const uint Version = 1;
var payload = SealSample(wire, vaultKey, entityId, Generation, (int)Version);
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey, payload.WrappedDataKey, resource, entityId, Generation, Version);
dataKey.ShouldNotBeNull(
$"The {wire} cipher must wrap its data key under AadResourceType.{resource}; a null here means "
+ "it used some other resource type, which round-trips fine and violates docs/crypto.md.");
ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
resource,
entityId,
payload.DataKeyId,
Generation,
Version).ShouldNotBeNull("and it must seal the envelope under the same resource type.");
}
/// <remarks>
/// The guard that makes the table above self-maintaining. A fourth item type would otherwise sync,
/// encrypt and merge correctly while being sealed under any resource type at all, and nothing would say
/// so until another implementation refused the item — by which point the AAD is frozen into stored
/// ciphertext and only clients can re-encrypt it.
/// </remarks>
[Fact]
public void EverySynchronisedType_HasItsCipherPinnedHere()
{
PinnedPairs.Select(pair => pair.Wire)
.ShouldBe(ItemKinds.SyncedTypes, ignoreOrder: true);
}
private static EncryptedPayload SealSample(
SyncEntityType wire,
byte[] vaultKey,
Guid entityId,
uint generation,
int version) => wire switch
{
SyncEntityType.Host => HostCipher.Seal(
new HostSecret { Label = "prod-db", Hostname = "db.internal" },
vaultKey,
entityId,
generation,
version),
SyncEntityType.SshKey => SshKeyCipher.Seal(
NewKey(), vaultKey, entityId, generation, version),
SyncEntityType.Credential => CredentialCipher.Seal(
NewCredential(), vaultKey, entityId, generation, version),
SyncEntityType.KnownHostKey => KnownHostKeyCipher.Seal(
NewKnownHost(), vaultKey, entityId, generation, version),
SyncEntityType.HostGroup => HostGroupCipher.Seal(
NewGroup(), vaultKey, entityId, generation, version),
// Tag is the row this file was written for. It is 5 on the wire and 8 in the crypto enum, and 5
// in the crypto enum is Credential — so a TagCipher written by casting its wire type would seal
// every tag in the vault under the resource type for a password, and only this test would say so.
SyncEntityType.Tag => TagCipher.Seal(
NewTag(), vaultKey, entityId, generation, version),
SyncEntityType.Snippet => SnippetCipher.Seal(
NewSnippet(), vaultKey, entityId, generation, version),
SyncEntityType.ConnectionLogEntry => ConnectionLogCipher.Seal(
NewConnectionEntry(), vaultKey, entityId, generation, version),
SyncEntityType.ActivityLogEntry => ActivityLogCipher.Seal(
NewActivityEntry(), vaultKey, entityId, generation, version),
SyncEntityType.ObjectStore => ObjectStoreCipher.Seal(
NewBucket(), vaultKey, entityId, generation, version),
_ => throw new ArgumentOutOfRangeException(
nameof(wire),
wire,
"No sample exists for this item type. Add one when adding the type, or the pairing above "
+ "cannot be checked."),
};
[Fact]
public void ACredentialPayload_OpensAsNothingElse()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var sealed_ = CredentialCipher.Seal(
NewCredential(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
HostCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
SshKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
CredentialCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
}
[Fact]
public void ACredentialSealedAtOneVersion_DoesNotOpenAtAnother()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var payload = CredentialCipher.Seal(
NewCredential(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);
CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
}
[Fact]
public void AKeyPayload_DoesNotOpenAsAHost()
{
// Weaker than the two above and kept anyway: it is the property a reader expects to see, and it
// covers the case where one cipher is corrected and the other is not.
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var sealedKey = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
HostCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
SshKeyCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
}
[Fact]
public void AHostPayload_DoesNotOpenAsAKey()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var host = new HostSecret { Label = "prod-db", Hostname = "db.internal" };
var sealedHost = HostCipher.Seal(host, vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
SshKeyCipher.TryOpen(sealedHost, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
}
[Fact]
public void AKey_RoundTripsThroughTheCipher()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var key = NewKey();
var payload = SshKeyCipher.Seal(key, vaultKey, entityId, keyGeneration: 1, itemVersion: 3);
var opened = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3);
opened.ShouldNotBeNull();
opened.Key.ShouldBe(key);
opened.SchemaVersion.ShouldBe(SshKeySecretCodec.CurrentSchemaVersion);
opened.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void AKeySealedAtOneVersion_DoesNotOpenAtAnother()
{
// The item version is in the AAD, which is what stops a server rolling a row back to earlier
// ciphertext. Asserted for keys as well as hosts because it is the property most easily lost by
// copying a cipher and adjusting the wrong argument.
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var payload = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);
SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
}
[Fact]
public void AKnownHostPayload_OpensAsNothingElse()
{
// The pairing table above is the load-bearing check; this is the cross-type refusal a reader expects
// to see spelled out, and it is the one that would notice a second cipher being pointed at
// AadResourceType.KnownHostKey by mistake.
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var sealed_ = KnownHostKeyCipher.Seal(
NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
HostCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
SshKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
CredentialCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
KnownHostKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
}
[Fact]
public void AKnownHostSealedAtOneVersion_DoesNotOpenAtAnother()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
var payload = KnownHostKeyCipher.Seal(
NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);
KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
}
private static ConnectionLogSecret NewConnectionEntry() => new()
{
HostLabel = "prod-db",
Address = "deploy@db.internal:22",
HostId = Guid.CreateVersion7(),
StartedAt = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
Duration = TimeSpan.FromMinutes(11),
Outcome = ConnectionOutcome.Closed,
DeviceName = "laptop",
ActorUserId = Guid.CreateVersion7(),
};
private static ActivityLogSecret NewActivityEntry() => new()
{
ItemKind = nameof(SyncEntityType.Host),
ItemId = Guid.CreateVersion7(),
ItemLabel = "prod-db",
Operation = ActivityOperation.Updated,
ChangedFields = "Port, Username",
At = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
DeviceName = "laptop",
ActorUserId = Guid.CreateVersion7(),
};
private static ObjectStoreSecret NewBucket() => new()
{
Label = "backups",
Bucket = "dodossh-backups",
AccessKeyId = "AKIAEXAMPLE",
SecretAccessKey = "an example secret access key",
Region = "eu-west-1",
};
private static HostGroupSecret NewGroup() => new() { Label = "production" };
private static TagSecret NewTag() => new() { Label = "pci" };
private static SnippetSecret NewSnippet() => new()
{
Label = "restart the api",
Command = "sudo systemctl restart dodossh-api",
Notes = "checked with the on-call rota first",
};
private static KnownHostSecret NewKnownHost() => new()
{
Host = "db.internal",
Port = 22,
Algorithm = "ssh-ed25519",
Fingerprint = "SHA256:5cWZ1Zc2ZmEXAMPLEfingerprintvalue0123456789a",
};
private static CredentialSecret NewCredential() => new()
{
Label = "db-login",
Password = "hunter2",
Username = "postgres",
Notes = "used by CI",
};
private static SshKeySecret NewKey() => new()
{
Label = "deploy",
PrivateKeyPem = "-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----",
Passphrase = "a passphrase",
PublicKey = "ssh-ed25519 AAAAC3Nz deploy@example",
Notes = "used by CI",
};
}