Public Access
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings -> Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's /api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at /api/app-sign-in/token for an app session token. That token opens terminals in every organisation its person is in, with the role they have on each server at the time; tunnel tickets send no organisation. The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type 18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of DodoSSH and of every Spelunk on the device. A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows read-only and asks to be updated for, and a payload that still names a key credential decodes with the id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17 are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a Spelunk this device is not signed in to it opens the editor, which offers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
5.6 KiB
C#
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.SpelunkSignIn, 18)]
|
|
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);
|
|
}
|
|
}
|