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.");
}
///
///
/// Opened independently, through the low-level ItemKeys API with the resource type this
/// test names itself. That is the whole point, and the first version of this file got it wrong in an
/// instructive way: it checked only that a key payload does not open as a host and vice versa, which is
/// true however both ciphers are misconfigured. Seal and TryOpen share one constant, so
/// changing it changes both, the round trip still works, and the two ciphers still differ from each
/// other. Sealing every private key as if it were a vault passed all of it.
///
///
/// A test that only compares an implementation against itself cannot catch a self-consistent mistake.
/// This one states the specified value out of band and refuses anything else.
///
///
[Fact]
public void AKeyPayload_OpensUnderTheResourceTypeTheSpecificationNames()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
const uint Generation = 1;
const uint Version = 1;
var payload = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, Generation, (int)Version);
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
CryptoSpec.AadResourceType.SshKey,
entityId,
Generation,
Version);
dataKey.ShouldNotBeNull(
"SshKeyCipher must wrap the data key under AadResourceType.SshKey; if this is null it used "
+ "some other resource type, which round-trips fine and violates docs/crypto.md.");
ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
CryptoSpec.AadResourceType.SshKey,
entityId,
payload.DataKeyId,
Generation,
Version).ShouldNotBeNull("and it must seal the envelope under the same resource type.");
}
/// Pins the host cipher the same way, since the two are now easy to confuse for each other.
[Fact]
public void AHostPayload_OpensUnderTheResourceTypeTheSpecificationNames()
{
var vaultKey = RandomNumberGenerator.GetBytes(32);
var entityId = Guid.CreateVersion7();
const uint Generation = 1;
const uint Version = 1;
var host = new HostSecret { Label = "prod-db", Hostname = "db.internal" };
var payload = HostCipher.Seal(host, vaultKey, entityId, Generation, (int)Version);
ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
CryptoSpec.AadResourceType.Host,
entityId,
Generation,
Version)
.ShouldNotBeNull("HostCipher must wrap the data key under AadResourceType.Host.");
}
[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 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",
};
}