Add the client key hierarchy: bundle, master key, vault and item keys

Everything crypto.md section 3 describes below the identity key, which is
what the desktop client needs before it can enroll or store anything.

DshAad gives every descriptor in the specification a named constructor. The
AAD binding is the most valuable structural property in the design -- it is
what stops a server holding every ciphertext from pasting one row's bytes
onto another, rolling a row back to a superseded generation, or replaying a
revoked grant -- and all of it depends on callers getting purpose, resource
type and ids right at every single call site. Hand-constructing descriptors
makes that a matter of care; picking a method name makes it a matter of
spelling.

UserSecretBundle holds private keys in libsodium's guarded, mlocked
allocations rather than a byte[], so they are not paged out and do not land
in a core dump. They are created exportable, deliberately: re-wrapping the
same bundle for a passphrase change or a new device needs to re-encode it,
and the alternative -- a long-lived managed array so the keys need not be
exportable -- keeps the identical secret in strictly worse memory. Every
export is into a buffer zeroed before the method returns.

Two spec changes, both found by implementing it, which is the argument for
writing code before calling a spec frozen:

- MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id
  output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's
  HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK
  cannot be expanded with SHA-512 at all. Widening it keeps the specified
  primitive; the alternatives were dropping to SHA-256 or adding an Extract
  step that conditions nothing.
- The bundle encoding is a fixed 92-byte layout rather than canonical CBOR.
  Canonicality is not load-bearing here -- unlike a key statement the bundle
  is never hashed or signed, only encrypted -- so CBOR's one advantage does
  not apply, while its canonicalisation rules are a real source of
  cross-implementation disagreement. It also costs a dependency
  System.Formats.Cbor is not in the shared framework. Safe to change now
  and not later: no bundle has ever been stored.

53 new tests. The encoding is checked against an independent codec written
in the test rather than by round-tripping production code against itself --
a round trip passes just as happily when both directions are wrong the same
way, and this format cannot change after one bundle is stored. The pinned
92-byte hex constant is the golden vector for the layout.

Most of the rest are negative, because a binding is only demonstrated by
the substitutions that fail: a wrap for another user, a grant from a
superseded generation, a payload pasted onto another item, a metadata blob
offered as a payload, a version rolled back.
This commit is contained in:
2026-07-28 21:02:52 +02:00
parent 885fb17bdc
commit e65d738912
9 changed files with 1763 additions and 3 deletions
@@ -0,0 +1,291 @@
using NSec.Cryptography;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// Vault key grants, per-item data keys, and the AAD binding that holds them in place.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<ArgumentException>(() => 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);
}
}