Files
DodoSSH/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs
T
jaap-jan b08f2c58e3
ci / build and test (pull_request) Failing after 3m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 5m24s
Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting.

A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed.

A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for.

The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
2026-09-29 10:34:38 +02:00

147 lines
5.6 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)]
[InlineData(CryptoSpec.AadResourceType.ConnectionLogEntry, 14)]
[InlineData(CryptoSpec.AadResourceType.ActivityLogEntry, 15)]
[InlineData(CryptoSpec.AadResourceType.ObjectStore, 16)]
[InlineData(CryptoSpec.AadResourceType.SpelunkKey, 17)]
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());
// 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/v2"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);
}
}