using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync.Tests;
///
/// Each item type must be sealed under its own AAD resource type, and the two enums that name item types
/// deliberately do not agree.
///
///
///
/// SyncEntityType lists only syncable items, so Host is 1 and SshKey is 3.
/// CryptoSpec.AadResourceType 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.
///
///
/// 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.
///
///
public sealed class AadResourceTypeTests
{
///
/// The pairing stated as a table. If AadResourceType is ever renumbered, this is what says so —
/// loudly, and before anything is written under the new numbers.
///
[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.");
}
///
/// The specified pairing of wire type to AAD resource type, stated out of band, one row per cipher.
///
///
/// 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
/// .
///
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 Pinned
{
get
{
var data = new TheoryData();
foreach (var (wire, resource) in PinnedPairs)
{
data.Add(wire, resource);
}
return data;
}
}
///
///
/// Opened independently, through the low-level ItemKeys 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 Seal and TryOpen share one constant. A
/// test that compares an implementation against itself cannot catch a self-consistent mistake.
///
///
/// 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 CredentialCipher at
/// AadResourceType.Vault passed the entire suite until this existed.
///
///
[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.");
}
///
/// 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.
///
[Fact]
public void EverySynchronisedType_HasItsCipherPinnedHere()
{
PinnedPairs.Select(pair => pair.Wire)
.ShouldBe(ItemKinds.SyncedTypes, ignoreOrder: true);
}
///
/// The same pairing, made a second time in the storage layer, and every type must be in it.
///
///
///
/// AadResourceTypes.For is the cache's copy of the table above: the ciphers seal an item for the
/// server, and this seals the two things the local cache holds in the clear — a relay host's
/// address, and the values a merge overrode. A type missing from it throws rather than mis-seals, which
/// sounds like the safe failure and is not: ConflictStore.RecordAsync calls it unconditionally, so
/// the exception lands on the path that records what a merge discarded.
///
///
/// This is written after finding three types missing from it — ConnectionLogEntry,
/// ActivityLogEntry and ObjectStore went two shipping phases without an arm, because the
/// only unconditional caller is one a test suite reaches solely by causing a real merge conflict. Asserted
/// over the whole wire enum rather than over ItemKinds.SyncedTypes, which is the stronger claim and
/// the one the switch actually makes: the two reserved association types have arms too.
///
///
[Fact]
public void EverySyncableType_HasAnArmInTheStorageMapping()
{
var missing = new List();
foreach (var wire in Enum.GetValues())
{
if (wire == SyncEntityType.Unspecified)
{
continue;
}
CryptoSpec.AadResourceType resource;
try
{
resource = AadResourceTypes.For(wire);
}
catch (ArgumentOutOfRangeException)
{
missing.Add(wire);
continue;
}
// Same name, as the cipher table demands — a wrong-but-present arm is the failure this half
// would otherwise wave through.
Enum.GetName(resource).ShouldBe(
Enum.GetName(wire),
$"AadResourceTypes.For({wire}) returns {resource}, which binds this type's cache records "
+ "to another type's resource.");
}
missing.ShouldBeEmpty(
"every syncable type needs an arm in AadResourceTypes.For, or a conflict recorded against one "
+ "of these throws instead of being written — and the conflict log is what justifies the merge "
+ "picking a winner.");
}
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",
};
}