using NSec.Cryptography;
namespace DodoSSH.Crypto.Tests;
///
/// Vault key grants, per-item data keys, and the AAD binding that holds them in place.
///
///
/// Most of these are negative. The AAD binding is the most valuable structural property in the
/// design — it is what stops a server that holds every ciphertext from moving one between rows,
/// rolling a row back, or replaying a revoked grant — and a property like that is only demonstrated
/// by the substitutions that fail.
///
public sealed class VaultAndItemKeyTests
{
private static readonly Guid VaultA = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid VaultB = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid ItemA = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid ItemB = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid DataKeyId = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e0f");
// ---- Vault keys ----
[Fact]
public void AVaultKey_IsThirtyTwoRandomBytes()
{
var first = VaultKeys.Create();
var second = VaultKeys.Create();
first.Length.ShouldBe(CryptoSpec.SymmetricKeySize);
first.ShouldNotBe(second);
}
[Fact]
public void AGrant_OpensForItsRecipient()
{
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
var vaultKey = VaultKeys.Create();
var grant = VaultKeys.WrapTo(
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
VaultKeys.TryUnwrap(member, grant, VaultA, 1).ShouldBe(vaultKey);
}
[Fact]
public void AGrantForAnotherVault_DoesNotOpen()
{
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
var vaultKey = VaultKeys.Create();
var grant = VaultKeys.WrapTo(
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
VaultKeys.TryUnwrap(member, grant, VaultB, 1).ShouldBeNull();
}
[Fact]
public void AGrantFromASupersededGeneration_DoesNotOpen()
{
// This is what makes a rekey stick against a malicious server: it cannot re-serve the old
// generation's grant to a removed member. Bounded to future reads only — whatever they
// already downloaded is already gone, which is why offboarding means rotating the SSH
// credentials themselves.
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
var vaultKey = VaultKeys.Create();
var grant = VaultKeys.WrapTo(
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
VaultKeys.TryUnwrap(member, grant, VaultA, 2).ShouldBeNull();
}
[Fact]
public void AGrantForAnotherMember_DoesNotOpen()
{
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
using var intruder = Key.Create(KeyAgreementAlgorithm.X25519);
var vaultKey = VaultKeys.Create();
var grant = VaultKeys.WrapTo(
vaultKey, member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1);
VaultKeys.TryUnwrap(intruder, grant, VaultA, 1).ShouldBeNull();
}
[Fact]
public void AGrantContainingSomethingOtherThanAKey_IsRejected()
{
// A malicious granter can seal anything; the recipient finds out here rather than later, when
// an unexplained item decryption failure would look like data corruption.
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
var bogus = DshCrypto.SealTo(
member.PublicKey.Export(KeyBlobFormat.RawPublicKey),
new byte[16],
DshAad.VaultKeyGrant(VaultA, 1));
VaultKeys.TryUnwrap(member, bogus, VaultA, 1).ShouldBeNull();
}
[Fact]
public void WrappingAKeyOfTheWrongLength_Throws()
{
using var member = Key.Create(KeyAgreementAlgorithm.X25519);
Should.Throw(() => VaultKeys.WrapTo(
new byte[16], member.PublicKey.Export(KeyBlobFormat.RawPublicKey), VaultA, 1));
}
// ---- Item data keys ----
[Fact]
public void ADataKey_RoundTripsUnderTheVaultKey()
{
var vaultKey = VaultKeys.Create();
var dataKey = ItemKeys.CreateDataKey();
var wrapped = ItemKeys.WrapDataKey(
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
ItemKeys.TryUnwrapDataKey(
vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemA, 1, 1).ShouldBe(dataKey);
}
[Fact]
public void ADataKeyFromAnotherItem_DoesNotOpen()
{
var vaultKey = VaultKeys.Create();
var dataKey = ItemKeys.CreateDataKey();
var wrapped = ItemKeys.WrapDataKey(
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
ItemKeys.TryUnwrapDataKey(
vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemB, 1, 1).ShouldBeNull();
}
[Fact]
public void ADataKeyFromAnotherItemVersion_DoesNotOpen()
{
var vaultKey = VaultKeys.Create();
var dataKey = ItemKeys.CreateDataKey();
var wrapped = ItemKeys.WrapDataKey(
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 2);
ItemKeys.TryUnwrapDataKey(
vaultKey, wrapped, CryptoSpec.AadResourceType.Host, ItemA, 1, 1).ShouldBeNull();
}
[Fact]
public void ADataKeyForAnotherResourceType_DoesNotOpen()
{
// An id collision across tables would otherwise let a credential's key open a host's.
var vaultKey = VaultKeys.Create();
var dataKey = ItemKeys.CreateDataKey();
var wrapped = ItemKeys.WrapDataKey(
dataKey, vaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
ItemKeys.TryUnwrapDataKey(
vaultKey, wrapped, CryptoSpec.AadResourceType.Credential, ItemA, 1, 1).ShouldBeNull();
}
// ---- Item payloads ----
[Fact]
public void APayload_RoundTripsUnderItsDataKey()
{
var dataKey = ItemKeys.CreateDataKey();
var plaintext = "ssh -p 2222 dodo@bastion.internal"u8.ToArray();
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
ItemKeys.TryOpenPayload(
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
.ShouldBe(plaintext);
}
[Fact]
public void APayloadPastedOntoAnotherItem_DoesNotOpen()
{
// The headline property. A server holding every ciphertext cannot move one row's bytes onto
// another row, which is a guarantee no amount of access control provides.
var dataKey = ItemKeys.CreateDataKey();
var plaintext = "secret"u8.ToArray();
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
ItemKeys.TryOpenPayload(
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemB, DataKeyId, 1, 1)
.ShouldBeNull();
}
[Fact]
public void APayloadRolledBackToAnEarlierVersion_DoesNotOpen()
{
var dataKey = ItemKeys.CreateDataKey();
var envelope = ItemKeys.SealPayload(
dataKey, "v2"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 2);
ItemKeys.TryOpenPayload(
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
.ShouldBeNull();
}
[Fact]
public void APayloadFromASupersededKeyGeneration_DoesNotOpen()
{
var dataKey = ItemKeys.CreateDataKey();
var envelope = ItemKeys.SealPayload(
dataKey, "old"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
ItemKeys.TryOpenPayload(
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 2, 1)
.ShouldBeNull();
}
[Fact]
public void AMetadataBlob_DoesNotOpenAsAPayload()
{
// Distinct purposes on the same item, so a server cannot swap the two — a substitution that
// would leave a client decrypting a password where it expected a display name.
var dataKey = ItemKeys.CreateDataKey();
var metadata = DshCrypto.Seal(
dataKey,
"display name"u8,
DshAad.ItemMetadata(CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1));
ItemKeys.TryOpenPayload(
dataKey, metadata, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
.ShouldBeNull();
}
[Fact]
public void APayloadUnderAnotherDataKeyId_DoesNotOpen()
{
// content_key_id is part of the binding, which is what lets M5 add per-item grants without
// reworking the AAD.
var dataKey = ItemKeys.CreateDataKey();
var envelope = ItemKeys.SealPayload(
dataKey, "x"u8.ToArray(), CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
ItemKeys.TryOpenPayload(
dataKey, envelope, CryptoSpec.AadResourceType.Host, ItemA, Guid.Empty, 1, 1)
.ShouldBeNull();
}
[Fact]
public void TheFullChain_WorksEndToEnd()
{
// Enrollment through to a stored item: identity key, vault key sealed to it, data key wrapped
// under the vault key, payload under the data key. Every link uses only what the layer above
// it hands over, which is the shape the client actually follows.
using var bundle = UserSecretBundle.Create(
DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_000));
var vaultKey = VaultKeys.Create();
var grant = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, VaultA, 1);
var recoveredVaultKey = VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, VaultA, 1);
recoveredVaultKey.ShouldNotBeNull();
var dataKey = ItemKeys.CreateDataKey();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, recoveredVaultKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
var plaintext = """{"hostname":"bastion.internal","username":"dodo"}"""u8.ToArray();
var payload = ItemKeys.SealPayload(
dataKey, plaintext, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1);
// Now the read path, holding nothing but the identity key and the stored ciphertexts.
var readVaultKey = VaultKeys.TryUnwrap(bundle.EncryptionKey, grant, VaultA, 1);
readVaultKey.ShouldNotBeNull();
var readDataKey = ItemKeys.TryUnwrapDataKey(
readVaultKey, wrappedDataKey, CryptoSpec.AadResourceType.Host, ItemA, 1, 1);
readDataKey.ShouldNotBeNull();
ItemKeys.TryOpenPayload(
readDataKey, payload, CryptoSpec.AadResourceType.Host, ItemA, DataKeyId, 1, 1)
.ShouldBe(plaintext);
}
}