Files
DodoSSH/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs
T
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00

140 lines
5.1 KiB
C#

using DodoSSH.Crypto;
namespace DodoSSH.Crypto.Tests;
/// <summary>
/// Pins the specification constants that are written into stored data.
/// </summary>
/// <remarks>
/// These are not busywork. The envelope magic, AAD version and every enum value are persisted
/// in ciphertext rows or in the AAD they are bound to, and only clients can re-encrypt: if one
/// changes without a deliberate migration path, existing vaults stop decrypting and the server
/// cannot help.
/// </remarks>
public sealed class CryptoSpecTests
{
[Fact]
public void EnvelopeMagic_IsStable()
{
CryptoSpec.EnvelopeMagic.ToArray().ShouldBe("DSH1"u8.ToArray());
}
[Fact]
public void AadMagic_IsStable()
{
CryptoSpec.AadMagic.ToArray().ShouldBe("dsh1\n"u8.ToArray());
}
[Fact]
public void CurrentAadVersion_IsStable()
{
// Bumping this requires a lazy re-encrypt-on-write path in the client first.
CryptoSpec.CurrentAadVersion.ShouldBe((byte)1);
}
[Fact]
public void CurrentSchemaVersion_IsStable()
{
CryptoSpec.CurrentSchemaVersion.ShouldBe((ushort)1);
}
[Fact]
public void Sizes_MatchTheSpecification()
{
CryptoSpec.AadEncodedLength.ShouldBe(64);
CryptoSpec.SymmetricKeySize.ShouldBe(32);
CryptoSpec.PublicKeySize.ShouldBe(32);
CryptoSpec.SignatureSize.ShouldBe(64);
CryptoSpec.DigestSize.ShouldBe(32);
CryptoSpec.TagSize.ShouldBe(16);
CryptoSpec.SaltSize.ShouldBe(16);
}
[Theory]
[InlineData(CryptoSpec.AlgorithmId.XChaCha20Poly1305, 1)]
[InlineData(CryptoSpec.AlgorithmId.Aes256Gcm, 2)]
[InlineData(CryptoSpec.AlgorithmId.SealToX25519, 3)]
public void AlgorithmId_HasStableWireValue(CryptoSpec.AlgorithmId algorithm, int expected)
{
((int)algorithm).ShouldBe(expected);
}
[Fact]
public void AlgorithmId_4_IsReservedForHybridPostQuantumSeal()
{
// Reserved for X25519 + ML-KEM-768. Claimed now so the identifier cannot be reused:
// store-now-decrypt-later is a real threat for long-lived SSH keys.
Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), (byte)4).ShouldBeFalse();
}
[Theory]
[InlineData(CryptoSpec.AadPurpose.UserSecretBundle, 1)]
[InlineData(CryptoSpec.AadPurpose.VaultKeyGrant, 2)]
[InlineData(CryptoSpec.AadPurpose.ItemDataKey, 3)]
[InlineData(CryptoSpec.AadPurpose.ItemPayload, 4)]
[InlineData(CryptoSpec.AadPurpose.ItemMetadata, 5)]
[InlineData(CryptoSpec.AadPurpose.LocalCache, 6)]
public void AadPurpose_HasStableWireValue(CryptoSpec.AadPurpose purpose, int expected)
{
((int)purpose).ShouldBe(expected);
}
[Theory]
[InlineData(CryptoSpec.AadResourceType.User, 1)]
[InlineData(CryptoSpec.AadResourceType.Device, 2)]
[InlineData(CryptoSpec.AadResourceType.Vault, 3)]
[InlineData(CryptoSpec.AadResourceType.Host, 4)]
[InlineData(CryptoSpec.AadResourceType.Credential, 5)]
[InlineData(CryptoSpec.AadResourceType.SshKey, 6)]
[InlineData(CryptoSpec.AadResourceType.HostGroup, 7)]
[InlineData(CryptoSpec.AadResourceType.Tag, 8)]
[InlineData(CryptoSpec.AadResourceType.Snippet, 9)]
[InlineData(CryptoSpec.AadResourceType.PortForward, 10)]
[InlineData(CryptoSpec.AadResourceType.KnownHostKey, 11)]
[InlineData(CryptoSpec.AadResourceType.HostTag, 12)]
[InlineData(CryptoSpec.AadResourceType.HostCredential, 13)]
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
{
((int)type).ShouldBe(expected);
}
[Fact]
public void DerivationLabels_AreStable()
{
// These are HKDF info strings; changing one silently derives a different key.
CryptoSpec.DerivationLabels.PassphraseKek.ToArray()
.ShouldBe("dsh1/kek/passphrase/v1"u8.ToArray());
CryptoSpec.DerivationLabels.LocalCache.ToArray()
.ShouldBe("dsh1/localcache/v1"u8.ToArray());
CryptoSpec.DerivationLabels.SealTo.ToArray()
.ShouldBe("dsh1/sealto/v1|"u8.ToArray());
CryptoSpec.DerivationLabels.Fingerprint.ToArray()
.ShouldBe("dsh1/fp/v1"u8.ToArray());
}
[Fact]
public void SigningContexts_AreStable()
{
CryptoSpec.SigningContexts.KeyStatement.ToArray()
.ShouldBe("dsh1/sig/keystatement/v1"u8.ToArray());
CryptoSpec.SigningContexts.Grant.ToArray()
.ShouldBe("dsh1/sig/grant/v1"u8.ToArray());
CryptoSpec.SigningContexts.Attestation.ToArray()
.ShouldBe("dsh1/sig/attestation/v1"u8.ToArray());
}
[Fact]
public void SigningContexts_AreAllDistinct()
{
// A shared context would let a signature in one role be replayed in another.
string[] contexts =
[
System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.KeyStatement),
System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.Grant),
System.Text.Encoding.UTF8.GetString(CryptoSpec.SigningContexts.Attestation),
];
contexts.Distinct(StringComparer.Ordinal).Count().ShouldBe(contexts.Length);
}
}