Public Access
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.
This commit is contained in:
@@ -15,15 +15,16 @@ namespace DodoSSH.Client.Storage.Tests;
|
||||
/// </remarks>
|
||||
internal sealed class CacheHarness : IDisposable
|
||||
{
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
/// <remarks>
|
||||
/// Fixed rather than "now", because a bundle's encoding includes its creation timestamp and the cache
|
||||
/// key derives from that encoding. Nothing here depends on the value; it only has to be a constant.
|
||||
/// </remarks>
|
||||
private static readonly DateTimeOffset IdentityCreatedAt =
|
||||
new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly MasterKey master;
|
||||
|
||||
private CacheHarness(ClientCacheFactory factory, MasterKey master, LocalCacheProtector protector)
|
||||
private CacheHarness(ClientCacheFactory factory, LocalCacheProtector protector)
|
||||
{
|
||||
Factory = factory;
|
||||
this.master = master;
|
||||
Protector = protector;
|
||||
|
||||
Items = new ItemStore(factory, protector);
|
||||
@@ -54,8 +55,12 @@ internal sealed class CacheHarness : IDisposable
|
||||
|
||||
internal ConflictStore Conflicts { get; }
|
||||
|
||||
internal static async Task<CacheHarness> CreateAsync(
|
||||
string passphrase = "correct horse battery staple")
|
||||
/// <remarks>
|
||||
/// Each harness gets a freshly generated identity, so two of them are two different users and their
|
||||
/// cache keys differ. There is no passphrase here at all any more: the cache key derives from the
|
||||
/// bundle, so a passphrase is not part of the question this harness sets up.
|
||||
/// </remarks>
|
||||
internal static async Task<CacheHarness> CreateAsync()
|
||||
{
|
||||
var factory = ClientCacheFactory.ForMemory($"cache-{Guid.CreateVersion7():N}");
|
||||
|
||||
@@ -63,10 +68,11 @@ internal sealed class CacheHarness : IDisposable
|
||||
{
|
||||
await factory.MigrateAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var salt = new byte[CryptoSpec.SaltSize];
|
||||
var derived = MasterKey.Derive(passphrase, salt, CheapProfile);
|
||||
// Disposed immediately: the protector copies the derived key, so the identity itself is not
|
||||
// needed once it has answered.
|
||||
using var identity = UserSecretBundle.Create(IdentityCreatedAt);
|
||||
|
||||
return new CacheHarness(factory, derived, LocalCacheProtector.From(derived));
|
||||
return new CacheHarness(factory, LocalCacheProtector.From(identity));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -79,7 +85,6 @@ internal sealed class CacheHarness : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
Protector.Dispose();
|
||||
master.Dispose();
|
||||
Factory.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -97,11 +97,15 @@ public sealed class CacheStoreTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARecordSealedUnderAnotherPassphrase_DoesNotOpen()
|
||||
public async Task ARecordSealedByAnotherIdentity_DoesNotOpen()
|
||||
{
|
||||
// Another *identity*, not another passphrase, and the distinction is the point. The cache key now
|
||||
// derives from the secret bundle, so changing a passphrase deliberately keeps the cache readable —
|
||||
// UserSecretBundleTests.TheLocalCacheKey_SurvivesAPassphraseChange pins that. What must still be
|
||||
// unreadable is another user's cache, and that is what a second harness is.
|
||||
var entityId = Guid.CreateVersion7();
|
||||
|
||||
using var stranger = await CreateAsync(passphrase: "a completely different passphrase");
|
||||
using var stranger = await CreateAsync();
|
||||
|
||||
var sealedFields = stranger.Protector.Protect(
|
||||
CryptoSpec.AadResourceType.Host, entityId, [1, 2, 3]);
|
||||
|
||||
@@ -14,13 +14,11 @@ namespace DodoSSH.Client.Sync.Tests;
|
||||
internal sealed class SyncDevice : IDisposable
|
||||
{
|
||||
private readonly ClientCacheFactory factory;
|
||||
private readonly MasterKey master;
|
||||
private readonly LocalCacheProtector protector;
|
||||
|
||||
private SyncDevice(
|
||||
string name,
|
||||
ClientCacheFactory factory,
|
||||
MasterKey master,
|
||||
LocalCacheProtector protector,
|
||||
VaultKeyring keyring,
|
||||
FakeVaultServer server,
|
||||
@@ -28,7 +26,6 @@ internal sealed class SyncDevice : IDisposable
|
||||
{
|
||||
Name = name;
|
||||
this.factory = factory;
|
||||
this.master = master;
|
||||
this.protector = protector;
|
||||
Keyring = keyring;
|
||||
|
||||
@@ -80,14 +77,14 @@ internal sealed class SyncDevice : IDisposable
|
||||
{
|
||||
await cache.MigrateAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var derived = MasterKey.Derive(
|
||||
$"passphrase-{name}", new byte[CryptoSpec.SaltSize], SyncHarness.CheapProfile);
|
||||
|
||||
// Opened through the real grant, so the keyring, the wrap and the AAD are all exercised.
|
||||
var keyring = VaultKeyring.Open(bundle, [vault]);
|
||||
|
||||
// Both simulated machines derive the same cache key, because they are the same user holding the
|
||||
// same identity — which is what keying the cache on the bundle means. They still have separate
|
||||
// cache databases, so nothing is shared between them but the key that would open either.
|
||||
return new SyncDevice(
|
||||
name, cache, derived, LocalCacheProtector.From(derived), keyring, server, options);
|
||||
name, cache, LocalCacheProtector.From(bundle), keyring, server, options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -199,7 +196,6 @@ internal sealed class SyncDevice : IDisposable
|
||||
{
|
||||
Keyring.Dispose();
|
||||
protector.Dispose();
|
||||
master.Dispose();
|
||||
factory.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +104,11 @@ public sealed class CryptoSpecTests
|
||||
// These are HKDF info strings; changing one silently derives a different key.
|
||||
CryptoSpec.DerivationLabels.PassphraseKek.ToArray()
|
||||
.ShouldBe("dsh1/kek/passphrase/v1"u8.ToArray());
|
||||
// v2 since 2026-07-30: the cache key derives from the bundle rather than the master key, so that a
|
||||
// device or recovery unlock reaches the same cache. docs/crypto.md §3.2. Bumping the label is what
|
||||
// makes a v1 cache fail to open rather than decrypt to nonsense.
|
||||
CryptoSpec.DerivationLabels.LocalCache.ToArray()
|
||||
.ShouldBe("dsh1/localcache/v1"u8.ToArray());
|
||||
.ShouldBe("dsh1/localcache/v2"u8.ToArray());
|
||||
CryptoSpec.DerivationLabels.SealTo.ToArray()
|
||||
.ShouldBe("dsh1/sealto/v1|"u8.ToArray());
|
||||
CryptoSpec.DerivationLabels.Fingerprint.ToArray()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -32,6 +33,7 @@ internal static class GoldenVectors
|
||||
["envelope"] = BuildEnvelopeVectors(),
|
||||
["aead"] = BuildAeadVectors(),
|
||||
["hkdf"] = BuildHkdfVectors(),
|
||||
["localCacheKey"] = BuildLocalCacheKeyVectors(),
|
||||
["argon2id"] = BuildArgon2Vectors(),
|
||||
["fingerprint"] = BuildFingerprintVectors(),
|
||||
["keyStatement"] = BuildKeyStatementVectors(),
|
||||
@@ -186,6 +188,12 @@ internal static class GoldenVectors
|
||||
];
|
||||
}
|
||||
|
||||
/// <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();
|
||||
@@ -193,7 +201,6 @@ internal static class GoldenVectors
|
||||
(string Name, byte[] Info)[] cases =
|
||||
[
|
||||
("passphrase-kek", CryptoSpec.DerivationLabels.PassphraseKek.ToArray()),
|
||||
("local-cache", CryptoSpec.DerivationLabels.LocalCache.ToArray()),
|
||||
];
|
||||
|
||||
var array = new JsonArray();
|
||||
@@ -214,6 +221,65 @@ internal static class GoldenVectors
|
||||
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();
|
||||
|
||||
@@ -473,30 +473,82 @@ public sealed class MasterKeyTests
|
||||
var wrap = master.WrapBundle(bundle, descriptor);
|
||||
|
||||
Span<byte> cacheKey = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
master.DeriveLocalCacheKey(cacheKey);
|
||||
bundle.DeriveLocalCacheKey(cacheKey);
|
||||
|
||||
UserSecretBundle.TryOpenUnder(cacheKey, wrap, descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheLocalCacheKey_IsStableForTheSamePassphraseAndSalt()
|
||||
public void TheLocalCacheKey_IsStableAcrossAWrapAndUnwrap()
|
||||
{
|
||||
// The same identity has to produce the same cache key after a round trip through a wrap, or every
|
||||
// unlock would derive a different key and find its own cache unreadable.
|
||||
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||
|
||||
using var master = Derive();
|
||||
using var original = UserSecretBundle.Create(CreatedAt);
|
||||
using var reopened = master.TryOpenBundle(master.WrapBundle(original, descriptor), descriptor)!;
|
||||
|
||||
Span<byte> first = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
Span<byte> second = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
|
||||
using (var master = Derive())
|
||||
{
|
||||
master.DeriveLocalCacheKey(first);
|
||||
}
|
||||
|
||||
using (var master = Derive())
|
||||
{
|
||||
master.DeriveLocalCacheKey(second);
|
||||
}
|
||||
original.DeriveLocalCacheKey(first);
|
||||
reopened.DeriveLocalCacheKey(second);
|
||||
|
||||
first.SequenceEqual(second).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheLocalCacheKey_SurvivesAPassphraseChange()
|
||||
{
|
||||
// What keying on the bundle rather than the master key actually buys, and the reason the derivation
|
||||
// label went to v2. Under v1 this was false: a new passphrase derived a new master key, so changing
|
||||
// it silently orphaned every cached row and the next launch re-pulled the whole vault.
|
||||
var descriptor = DshAad.UserSecretBundle(Alice);
|
||||
|
||||
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||
|
||||
Span<byte> before = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
bundle.DeriveLocalCacheKey(before);
|
||||
|
||||
using var changed = MasterKey.Derive(
|
||||
"an entirely different passphrase", Salt, Argon2Profile.RandomSecret);
|
||||
|
||||
using var reopened = changed.TryOpenBundle(changed.WrapBundle(bundle, descriptor), descriptor)!;
|
||||
|
||||
Span<byte> after = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
reopened.DeriveLocalCacheKey(after);
|
||||
|
||||
before.SequenceEqual(after).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheLocalCacheKey_DiffersForADifferentIdentity()
|
||||
{
|
||||
// The other half of the property: the cache follows the identity, so a rotated one cannot read the
|
||||
// cache the previous one wrote. That is the correct moment to discard it.
|
||||
using var first = UserSecretBundle.Create(CreatedAt);
|
||||
using var second = UserSecretBundle.Create(CreatedAt);
|
||||
|
||||
Span<byte> one = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
Span<byte> two = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
|
||||
first.DeriveLocalCacheKey(one);
|
||||
second.DeriveLocalCacheKey(two);
|
||||
|
||||
one.SequenceEqual(two).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DerivingACacheKeyFromADisposedBundle_Throws()
|
||||
{
|
||||
var bundle = UserSecretBundle.Create(CreatedAt);
|
||||
bundle.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
bundle.DeriveLocalCacheKey(new byte[CryptoSpec.SymmetricKeySize]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASaltShorterThanTheSpecifiedMinimum_IsRejected()
|
||||
{
|
||||
@@ -519,19 +571,18 @@ public sealed class MasterKeyTests
|
||||
var master = Derive();
|
||||
master.Dispose();
|
||||
|
||||
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
{
|
||||
var buffer = new byte[CryptoSpec.SymmetricKeySize];
|
||||
master.DeriveLocalCacheKey(buffer);
|
||||
});
|
||||
master.WrapBundle(bundle, DshAad.UserSecretBundle(Alice)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASubkeyBufferOfTheWrongSize_IsRejected()
|
||||
public void ACacheKeyBufferOfTheWrongSize_IsRejected()
|
||||
{
|
||||
using var master = Derive();
|
||||
using var bundle = UserSecretBundle.Create(CreatedAt);
|
||||
|
||||
Should.Throw<ArgumentException>(() => master.DeriveLocalCacheKey(new byte[16]));
|
||||
Should.Throw<ArgumentException>(() => bundle.DeriveLocalCacheKey(new byte[16]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Vendored
+8
-6
@@ -128,14 +128,16 @@
|
||||
"info": "dsh1/kek/passphrase/v1",
|
||||
"outputLength": 32,
|
||||
"output": "652b3a4a3ce03b235095ad32f1eed2cfdae915b5b0a98cc9f96face30853f4c7"
|
||||
},
|
||||
}
|
||||
],
|
||||
"localCacheKey": [
|
||||
{
|
||||
"name": "local-cache",
|
||||
"algorithm": "HKDF-SHA512-Expand",
|
||||
"prk": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
|
||||
"info": "dsh1/localcache/v1",
|
||||
"name": "local-cache-key-from-bundle",
|
||||
"algorithm": "HKDF-SHA512 extract-and-expand, no salt",
|
||||
"bundle": "647368312f62756e646c652f76310001000000010000019b76daa800404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"info": "dsh1/localcache/v2",
|
||||
"outputLength": 32,
|
||||
"output": "5b69ed9266ff5f297f11667ca693b0049b805365ee34d54d6e60b843e414b1f5"
|
||||
"output": "65bcac61e28f526f6aabfa91dd4ff3e519eaa0724a5729e890a1ef71d69062a0"
|
||||
}
|
||||
],
|
||||
"argon2id": [
|
||||
|
||||
Reference in New Issue
Block a user