using System.Security.Cryptography;
using DodoSSH.Client.Domain;
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)]
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),
];
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);
}
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),
_ => 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();
}
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",
};
}