Files
DodoSSH/tests/DodoSSH.Crypto.Tests/GoldenVectors.cs
jaap-jan 7016ce36f1 Key the local cache to the identity, not to the door it was opened through
Groundwork for a device key, and a spec change rather than a feature. ADR 0007
records the decision it clears the way for: a Windows Hello gesture guarding a
protected blob, with the passphrase kept as a permanent fallback.

The reason that decision needed this first is that a device key cannot open a
session on its own. SessionOpener derived two things from the passphrase master
key — the bundle, and the local cache key — and a device wrap is
SealTo(device_x25519_pk), which yields the bundle and never computes a master key
at all. A device unlock could therefore have opened the identity and still not
read the cache it had itself written.

So LocalCacheKey now derives from the bundle: dsh1/localcache/v1 → v2, specified
in crypto.md §3.2. Every wrap that opens a vault ends up holding the bundle, so
every door reaches the same cache.

Extract-and-expand, not expand alone. Everything derived from the master key uses
HKDF-Expand directly, which is sound because an Argon2id output is uniformly
random over its whole length. The bundle's encoding is not — it opens with a
fixed 14-byte label and carries a version, a generation and a timestamp before
reaching any key material — so it needs the extract step to become a pseudorandom
key first.

Two consequences fell out, both improvements and neither the point:

- A passphrase change no longer discards the local cache. The bundle is unchanged
  by a re-wrap, so the cache key is too. Under v1 changing a passphrase silently
  orphaned every cached row and the next launch re-pulled the whole vault.
- Recovery-code unlock is fixed before it ships. It derives a different master key
  from a different secret and a different salt, so under v1 it would have had the
  same defect as the device path, and nobody would have noticed until it landed.

The cache becomes unreadable exactly when the identity is rotated, which is the
correct moment to discard it. Existing caches are discarded and re-pulled on
upgrade — already the specified behaviour for a stale cache, and the reason the
label is versioned rather than reused: a v1 cache must fail to open rather than
decrypt to nonsense.

One stated guarantee got weaker and now says so. crypto.md §10 claimed locking
meant "nothing on disk can be read again without the passphrase." Where a device
wrap exists that is no longer true, and it would have been untrue under either
candidate design — the alternative was storing a copy of the cache key in the
device blob, which is the same door with an extra key lying next to it. The
wording now points at ADR 0007, because what guards the device key is a platform
decision and not a property of this specification.

A golden vector was quietly lying, which is the part worth reading twice. The
"local-cache" entry pinned HKDF-SHA512-Expand over a fixed PRK — a construction
the cache key no longer uses. Regenerating it would have produced a green suite
describing a derivation this code does not perform. It is replaced by a vector
over a bundle whose every byte is pinned: the label, version 1, generation 1, a
fixed timestamp and two recognisable key scalars, all visible in the fixture so a
second implementation can check itself against it. UserSecretBundle.TryDecode is
internal for this, because Create draws fresh randomness and so can never produce
a reproducible input.

Mutation tested, and this one earns its keep: dropping the extract step now fails
CommittedVectors_MatchCurrentImplementation. The vector it replaced could not
have caught that, because it never touched the bundle at all.

One test became false and says so. ARecordSealedUnderAnotherPassphrase is now
ARecordSealedByAnotherIdentity: a different passphrase deliberately no longer
changes the cache key, and TheLocalCacheKey_SurvivesAPassphraseChange pins that.
What must still be unreadable is another user's cache. CacheHarness therefore
generates an identity rather than deriving from a passphrase, and has no
passphrase parameter left — the cache key is not a question about passphrases any
more.

SyncHarness's two simulated machines now derive the same cache key, which is what
keying on the bundle means: they are the same user holding the same identity. They
still have separate cache databases, so nothing is shared between them but the key
that would open either. Both harnesses lost a MasterKey field that existed only to
make a protector.

858 tests green. Zero warnings, dotnet format clean.

Not done: the device key itself. Three pieces remain, and the middle one was a
discovery rather than a plan — EnrollmentService.AddDevice runs only during
enrollment, so every already-enrolled account, which is all of them, needs an
endpoint to add a device wrap while unlocked. The client proves possession by
producing the wrap, so that shape falls out of the crypto. After that: the
protector seam with the wrap cached locally for offline unlock, then the Hello
implementation and the unlock-screen UI, which is where the Windows TFM lands and
where automated testing stops.
2026-07-30 12:46:55 +02:00

481 lines
20 KiB
C#

using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using DodoSSH.Crypto;
using NSec.Cryptography;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// Produces the deterministic byte-level results of the DSH1 specification.
/// </summary>
/// <remarks>
/// Only deterministic operations belong here. <c>SealTo</c> and signing draw fresh randomness,
/// so they are covered by round-trip and negative tests in <see cref="DshCryptoTests"/> instead.
/// </remarks>
internal static class GoldenVectors
{
private static readonly Guid ResourceA = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private static readonly Guid ResourceB = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
private static readonly Guid KeyIdA = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");
internal static string Generate()
{
var root = new JsonObject
{
["_comment"] = "Generated by DodoSSH.Crypto.Tests.GoldenVectors. Normative source: docs/crypto.md.",
["_warning"] = "A failing assertion here is a regression or an intentional versioned format change. Do not regenerate to make it pass.",
["specVersion"] = 1,
["aad"] = BuildAadVectors(),
["envelope"] = BuildEnvelopeVectors(),
["aead"] = BuildAeadVectors(),
["hkdf"] = BuildHkdfVectors(),
["localCacheKey"] = BuildLocalCacheKeyVectors(),
["argon2id"] = BuildArgon2Vectors(),
["fingerprint"] = BuildFingerprintVectors(),
["keyStatement"] = BuildKeyStatementVectors(),
["keyLog"] = BuildKeyLogVectors(),
};
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n";
}
private static JsonArray BuildAadVectors()
{
(string Name, AadDescriptor Descriptor)[] cases =
[
("item-payload", AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemPayload,
CryptoSpec.AadResourceType.Credential,
ResourceA,
KeyIdA,
keyGeneration: 7,
itemVersion: 3)),
("item-payload-other-resource", AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemPayload,
CryptoSpec.AadResourceType.Credential,
ResourceB,
KeyIdA,
keyGeneration: 7,
itemVersion: 3)),
("item-metadata", AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemMetadata,
CryptoSpec.AadResourceType.Host,
ResourceA,
keyGeneration: 1,
itemVersion: 1)),
("vault-key-grant", AadDescriptor.Create(
CryptoSpec.AadPurpose.VaultKeyGrant,
CryptoSpec.AadResourceType.Vault,
ResourceA,
keyGeneration: 2)),
("user-secret-bundle", AadDescriptor.Create(
CryptoSpec.AadPurpose.UserSecretBundle,
CryptoSpec.AadResourceType.User,
ResourceA)),
("all-zero-ids", AadDescriptor.Create(
CryptoSpec.AadPurpose.LocalCache,
CryptoSpec.AadResourceType.None,
Guid.Empty,
keyGeneration: 0)),
];
var array = new JsonArray();
foreach (var (name, descriptor) in cases)
{
array.Add(new JsonObject
{
["name"] = name,
["purpose"] = (int)descriptor.Purpose,
["resourceType"] = (int)descriptor.ResourceType,
["resourceId"] = descriptor.ResourceId.ToString(),
["keyId"] = descriptor.KeyId.ToString(),
["keyGeneration"] = descriptor.KeyGeneration,
["itemVersion"] = descriptor.ItemVersion,
["aadVersion"] = descriptor.AadVersion,
["schemaVersion"] = descriptor.SchemaVersion,
["canonicalEncoding"] = Hex(descriptor.ToCanonicalEncoding()),
["aad"] = Hex(descriptor.ComputeAad()),
});
}
return array;
}
private static JsonArray BuildEnvelopeVectors()
{
// Framing only, with fixed inputs, so the byte layout of the header is pinned
// independently of any AEAD behaviour.
var ciphertext = Enumerable.Range(0, 20).Select(i => (byte)i).ToArray();
var xchachaNonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0xA0 + i)).ToArray();
var gcmNonce = Enumerable.Range(0, DshEnvelope.AesGcmNonceSize).Select(i => (byte)(0xB0 + i)).ToArray();
var ephemeral = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xC0 + i)).ToArray();
return
[
new JsonObject
{
["name"] = "xchacha20poly1305",
["algId"] = (int)CryptoSpec.AlgorithmId.XChaCha20Poly1305,
["nonce"] = Hex(xchachaNonce),
["ciphertext"] = Hex(ciphertext),
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.XChaCha20Poly1305),
["envelope"] = Hex(DshEnvelope.Write(
CryptoSpec.AlgorithmId.XChaCha20Poly1305, xchachaNonce, ciphertext)),
},
new JsonObject
{
["name"] = "aes256gcm",
["algId"] = (int)CryptoSpec.AlgorithmId.Aes256Gcm,
["nonce"] = Hex(gcmNonce),
["ciphertext"] = Hex(ciphertext),
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.Aes256Gcm),
["envelope"] = Hex(DshEnvelope.Write(
CryptoSpec.AlgorithmId.Aes256Gcm, gcmNonce, ciphertext)),
},
new JsonObject
{
["name"] = "sealto-x25519",
["algId"] = (int)CryptoSpec.AlgorithmId.SealToX25519,
["nonce"] = Hex(xchachaNonce),
["ephemeralPublicKey"] = Hex(ephemeral),
["ciphertext"] = Hex(ciphertext),
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.SealToX25519),
["envelope"] = Hex(DshEnvelope.Write(
CryptoSpec.AlgorithmId.SealToX25519, xchachaNonce, ciphertext, ephemeral)),
},
];
}
private static JsonArray BuildAeadVectors()
{
// Fixed key and nonce, so the AEAD itself is pinned. DshCrypto.Seal draws a random
// nonce by design, so the primitive is exercised directly here.
var key = Enumerable.Range(0, CryptoSpec.SymmetricKeySize).Select(i => (byte)i).ToArray();
var nonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0x10 + i)).ToArray();
var plaintext = "correct horse battery staple"u8.ToArray();
var descriptor = AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemPayload,
CryptoSpec.AadResourceType.Credential,
ResourceA,
KeyIdA,
keyGeneration: 7,
itemVersion: 3);
var aad = descriptor.ComputeAad();
using var aeadKey = Key.Import(
AeadAlgorithm.XChaCha20Poly1305, key, KeyBlobFormat.RawSymmetricKey);
var ciphertext = AeadAlgorithm.XChaCha20Poly1305.Encrypt(aeadKey, nonce, aad, plaintext);
return
[
new JsonObject
{
["name"] = "xchacha20poly1305-with-canonical-aad",
["key"] = Hex(key),
["nonce"] = Hex(nonce),
["aad"] = Hex(aad),
["plaintext"] = Hex(plaintext),
["ciphertext"] = Hex(ciphertext),
["envelope"] = Hex(DshEnvelope.Write(
CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext)),
},
];
}
/// <remarks>
/// Only the labels that really are HKDF-Expand over a master key belong here. The local cache key used
/// to be one of them and is not any more — it extracts and expands over the bundle's encoding
/// instead — so it has its own section rather than an entry here that would describe a derivation this
/// implementation no longer performs. See <see cref="BuildLocalCacheKeyVectors"/>.
/// </remarks>
private static JsonArray BuildHkdfVectors()
{
var prk = Enumerable.Range(0, 64).Select(i => (byte)i).ToArray();
(string Name, byte[] Info)[] cases =
[
("passphrase-kek", CryptoSpec.DerivationLabels.PassphraseKek.ToArray()),
];
var array = new JsonArray();
foreach (var (name, info) in cases)
{
array.Add(new JsonObject
{
["name"] = name,
["algorithm"] = "HKDF-SHA512-Expand",
["prk"] = Hex(prk),
["info"] = Encoding.UTF8.GetString(info),
["outputLength"] = CryptoSpec.SymmetricKeySize,
["output"] = Hex(HKDF.Expand(
HashAlgorithmName.SHA512, prk, CryptoSpec.SymmetricKeySize, info)),
});
}
return array;
}
/// <summary>
/// The local cache key, over a bundle whose every byte is pinned. docs/crypto.md §3.2.
/// </summary>
/// <remarks>
/// Built from a fixed encoding rather than from <c>UserSecretBundle.Create</c>, which draws fresh
/// randomness and so could never produce a reproducible vector. This is the one that matters for a
/// second implementation: it pins the extract-and-expand construction, the info label, and the fact
/// that the input is the bundle's canonical encoding rather than any key inside it.
/// </remarks>
private static JsonArray BuildLocalCacheKeyVectors()
{
var encoded = FixedBundleEncoding();
using var bundle = UserSecretBundle.TryDecode(encoded)
?? throw new InvalidOperationException("The fixed bundle encoding is not well-formed.");
var cacheKey = new byte[CryptoSpec.SymmetricKeySize];
bundle.DeriveLocalCacheKey(cacheKey);
return
[
new JsonObject
{
["name"] = "local-cache-key-from-bundle",
["algorithm"] = "HKDF-SHA512 extract-and-expand, no salt",
["bundle"] = Hex(encoded),
["info"] = Encoding.UTF8.GetString(CryptoSpec.DerivationLabels.LocalCache),
["outputLength"] = CryptoSpec.SymmetricKeySize,
["output"] = Hex(cacheKey),
},
];
}
/// <remarks>
/// The §3.1 layout with every field a constant: label, version 1, generation 1, a fixed timestamp, and
/// two key scalars of recognisable byte patterns. Any 32 bytes is a valid X25519 scalar and a valid
/// Ed25519 seed, so nothing here needs to be a real generated key.
/// </remarks>
private static byte[] FixedBundleEncoding()
{
var encoded = new byte[UserSecretBundle.EncodedLength];
var span = encoded.AsSpan();
UserSecretBundle.Label.CopyTo(span);
BinaryPrimitives.WriteUInt16BigEndian(span[14..], UserSecretBundle.CurrentVersion);
BinaryPrimitives.WriteUInt32BigEndian(span[16..], 1u);
BinaryPrimitives.WriteInt64BigEndian(
span[20..],
new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeMilliseconds());
for (var i = 0; i < CryptoSpec.SymmetricKeySize; i++)
{
span[28 + i] = (byte)(0x40 + i);
span[60 + i] = (byte)(0x60 + i);
}
return encoded;
}
private static JsonArray BuildArgon2Vectors()
{
var salt = Enumerable.Range(0, CryptoSpec.SaltSize).Select(i => (byte)(0x20 + i)).ToArray();
const string Passphrase = "correct horse battery staple";
var array = new JsonArray();
// Parameters of every profile are pinned cheaply. Only the smallest profile's output is
// computed, to keep the suite fast; the KDF itself is libsodium's, not ours.
(string Name, Argon2Profile Profile)[] profiles =
[
("passphrase-default", Argon2Profile.PassphraseDefault),
("passphrase-reduced", Argon2Profile.PassphraseReduced),
("passphrase-high", Argon2Profile.PassphraseHigh),
("random-secret", Argon2Profile.RandomSecret),
];
foreach (var (name, profile) in profiles)
{
array.Add(new JsonObject
{
["name"] = name,
["memoryMebibytes"] = profile.MemoryMebibytes,
["memoryKibibytes"] = profile.MemoryKibibytes,
["passes"] = profile.Passes,
["parallelism"] = Argon2Profile.Parallelism,
});
}
array.Add(new JsonObject
{
["name"] = "random-secret-output",
["memoryMebibytes"] = Argon2Profile.RandomSecret.MemoryMebibytes,
["memoryKibibytes"] = Argon2Profile.RandomSecret.MemoryKibibytes,
["passes"] = Argon2Profile.RandomSecret.Passes,
["parallelism"] = Argon2Profile.Parallelism,
["passphrase"] = Passphrase,
["salt"] = Hex(salt),
["outputLength"] = CryptoSpec.SymmetricKeySize,
["output"] = Hex(Argon2Profile.RandomSecret
.CreateAlgorithm()
.DeriveBytes(Passphrase, salt, CryptoSpec.SymmetricKeySize)),
});
return array;
}
private static JsonArray BuildFingerprintVectors()
{
var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray();
var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray();
return
[
new JsonObject
{
["name"] = "identity-fingerprint",
["x25519PublicKey"] = Hex(x25519),
["ed25519PublicKey"] = Hex(ed25519),
["fingerprint"] = Hex(DshCrypto.ComputeFingerprint(x25519, ed25519)),
},
];
}
/// <summary>
/// Pins the key statement encoding and the nonce derived from it. See docs/crypto.md §7.1.
/// </summary>
/// <remarks>
/// The highest-value vectors after the AAD ones. The nonce is what an identity provider signs
/// over, so a client and a server that disagree by one byte here produce enrollments that can
/// never be verified — and unlike a decryption failure, that only shows up against a real
/// provider.
/// <para>
/// The absent-email case is present specifically to pin the presence byte, which is what stops
/// an absent email and an empty one sharing a binding.
/// </para>
/// </remarks>
private static JsonArray BuildKeyStatementVectors()
{
var array = new JsonArray();
foreach (var (name, statement) in KeyStatementCases())
{
var encoding = KeyStatementCodec.Encode(statement);
array.Add(new JsonObject
{
["name"] = name,
["version"] = statement.Version,
["issuer"] = statement.Issuer,
["subject"] = statement.Subject,
["email"] = statement.Email,
["keyGeneration"] = statement.KeyGeneration,
["createdAtUnixMilliseconds"] = statement.CreatedAt.ToUnixTimeMilliseconds(),
["deviceName"] = statement.DeviceName,
["x25519PublicKey"] = Hex(statement.EncryptionPublicKey),
["ed25519PublicKey"] = Hex(statement.SigningPublicKey),
["canonicalEncoding"] = Hex(encoding),
["binding"] = Hex(KeyStatementCodec.ComputeBinding(encoding)),
["nonce"] = KeyStatementCodec.ComputeNonce(statement),
});
}
return array;
}
private static (string Name, KeyStatementFields Statement)[] KeyStatementCases()
{
var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray();
var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray();
// A fixed instant, so the vectors do not depend on when they were generated.
var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
return
[
("with-email", new KeyStatementFields(
1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
x25519, ed25519, 1, createdAt, "alice-laptop")),
// Same statement with the email absent, not empty.
("without-email", new KeyStatementFields(
1, "https://idp.example/realms/dodossh", "alice-subject", null,
x25519, ed25519, 1, createdAt, "alice-laptop")),
// ...and empty rather than absent. These three must all differ.
("empty-email", new KeyStatementFields(
1, "https://idp.example/realms/dodossh", "alice-subject", string.Empty,
x25519, ed25519, 1, createdAt, "alice-laptop")),
// Multi-byte UTF-8, so a length prefix counting characters rather than bytes fails here.
("unicode-device-name", new KeyStatementFields(
1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
x25519, ed25519, 1, createdAt, "alice's ThinkPad — büro")),
// A sub-millisecond offset-bearing timestamp must encode identically to the UTC one,
// because the encoding normalises and truncates.
("offset-and-sub-millisecond-timestamp", new KeyStatementFields(
1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
x25519, ed25519, 1,
createdAt.ToOffset(TimeSpan.FromHours(2)).AddTicks(7777),
"alice-laptop")),
("later-generation", new KeyStatementFields(
1, "https://idp.example/realms/dodossh", "alice-subject", "alice@example.com",
x25519, ed25519, 4, createdAt, "alice-laptop")),
];
}
/// <summary>Pins the key log chain hash, including the genesis link. See docs/crypto.md §7.2.</summary>
private static JsonArray BuildKeyLogVectors()
{
var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray();
var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray();
var signature = Enumerable.Range(0, CryptoSpec.SignatureSize).Select(i => (byte)(0x80 + i)).ToArray();
var createdAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123);
var genesisPrevious = KeyLogChain.CreateGenesisPreviousHash();
var genesis = KeyLogChain.ComputeEntryHash(
genesisPrevious, ResourceA, 1, x25519, ed25519, signature, createdAt);
// The second entry links to the first, so a broken link shows up as a changed hash here
// rather than only in a running deployment.
var second = KeyLogChain.ComputeEntryHash(
genesis, ResourceB, 1, x25519, ed25519, signature, createdAt);
return
[
new JsonObject
{
["name"] = "genesis",
["previousHash"] = Hex(genesisPrevious),
["userId"] = ResourceA.ToString(),
["generation"] = 1,
["x25519PublicKey"] = Hex(x25519),
["ed25519PublicKey"] = Hex(ed25519),
["statementSignature"] = Hex(signature),
["createdAtUnixMilliseconds"] = createdAt.ToUnixTimeMilliseconds(),
["hash"] = Hex(genesis),
},
new JsonObject
{
["name"] = "second-entry",
["previousHash"] = Hex(genesis),
["userId"] = ResourceB.ToString(),
["generation"] = 1,
["x25519PublicKey"] = Hex(x25519),
["ed25519PublicKey"] = Hex(ed25519),
["statementSignature"] = Hex(signature),
["createdAtUnixMilliseconds"] = createdAt.ToUnixTimeMilliseconds(),
["hash"] = Hex(second),
},
];
}
private static string Hex(ReadOnlySpan<byte> value) =>
Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture);
}