Files
DodoSSH/src/DodoSSH.Crypto/CryptoSpec.cs
T
laurensandClaude Opus 5.5 ef365f462e Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault
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>
2026-10-20 16:26:25 +02:00

223 lines
8.7 KiB
C#

namespace DodoSSH.Crypto;
/// <summary>
/// Constants of the DodoSSH cryptographic specification, version 1.
/// </summary>
/// <remarks>
/// docs/crypto.md is normative; this type must agree with it exactly. The values here are
/// written into stored data, so changing one reinterprets or orphans existing ciphertext.
/// Only clients can re-encrypt, so the server cannot migrate a change here.
/// </remarks>
public static class CryptoSpec
{
/// <summary>Magic prefix identifying a DSH1 envelope.</summary>
public static ReadOnlySpan<byte> EnvelopeMagic => "DSH1"u8;
/// <summary>Magic prefix of the canonical AAD encoding.</summary>
public static ReadOnlySpan<byte> AadMagic => "dsh1\n"u8;
/// <summary>Length of the canonical AAD encoding, before hashing.</summary>
public const int AadEncodedLength = 64;
/// <summary>Version of the AAD derivation rule that payloads are bound to.</summary>
/// <remarks>
/// Stored per row as <c>payload_aad_version</c> so a future change can be applied lazily,
/// re-encrypting on next write rather than in a migration.
/// </remarks>
public const byte CurrentAadVersion = 1;
/// <summary>Version of the relational schema that AAD is bound to.</summary>
public const ushort CurrentSchemaVersion = 1;
/// <summary>Size of a symmetric content or wrapping key.</summary>
public const int SymmetricKeySize = 32;
/// <summary>
/// Size of the passphrase-derived master key, which subkeys are expanded from.
/// </summary>
/// <remarks>
/// 64 bytes, not 32, because it is the PRK of an HKDF-SHA512 expansion and .NET's
/// <c>HKDF.Expand</c> rejects a PRK shorter than the hash output. See docs/crypto.md §3.
/// </remarks>
public const int MasterKeySize = 64;
/// <summary>Size of an X25519 or Ed25519 public key.</summary>
public const int PublicKeySize = 32;
/// <summary>Size of an Ed25519 signature.</summary>
public const int SignatureSize = 64;
/// <summary>Size of a SHA-256 output, used for AAD, fingerprints and digests.</summary>
public const int DigestSize = 32;
/// <summary>Size of an AEAD authentication tag.</summary>
public const int TagSize = 16;
/// <summary>Recommended salt length for password-based derivation.</summary>
public const int SaltSize = 16;
/// <summary>Identifies the construction used by a DSH1 envelope.</summary>
public enum AlgorithmId : byte
{
/// <summary>Reserved; never written, and rejected on read.</summary>
Unspecified = 0,
/// <summary>Symmetric content encryption under a known key.</summary>
XChaCha20Poly1305 = 1,
/// <summary>
/// Symmetric fallback for environments without XChaCha20-Poly1305. Accepted on read,
/// not currently emitted.
/// </summary>
Aes256Gcm = 2,
/// <summary>Anonymous-sender seal to an X25519 public key. See docs/crypto.md §6.</summary>
SealToX25519 = 3,
// 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Claimed now so it cannot be
// reused: store-now-decrypt-later is a real threat for long-lived SSH keys.
}
/// <summary>
/// What a given ciphertext is, so that one kind of blob can never be substituted for
/// another. Part of the AAD; see docs/crypto.md §4.2.
/// </summary>
public enum AadPurpose : byte
{
/// <summary>Not a legal value.</summary>
Unspecified = 0,
/// <summary>A wrap of the user's secret bundle.</summary>
UserSecretBundle = 1,
/// <summary>A vault key sealed to a member's public key.</summary>
VaultKeyGrant = 2,
/// <summary>An item data key wrapped under a vault key.</summary>
ItemDataKey = 3,
/// <summary>Item plaintext under its data key.</summary>
ItemPayload = 4,
/// <summary>Encrypted item metadata under its data key.</summary>
ItemMetadata = 5,
/// <summary>A record in a client's on-disk cache.</summary>
LocalCache = 6,
}
/// <summary>
/// The kind of entity a ciphertext belongs to. Part of the AAD; see docs/crypto.md §4.3.
/// Append only.
/// </summary>
public enum AadResourceType : byte
{
/// <summary>No resource. Legal only where the purpose implies none.</summary>
None = 0,
/// <summary>A user account.</summary>
User = 1,
/// <summary>An enrolled device.</summary>
Device = 2,
/// <summary>A vault.</summary>
Vault = 3,
/// <summary>An SSH host.</summary>
Host = 4,
/// <summary>A credential.</summary>
Credential = 5,
/// <summary>An SSH key pair.</summary>
SshKey = 6,
/// <summary>A host group.</summary>
HostGroup = 7,
/// <summary>A tag.</summary>
Tag = 8,
/// <summary>A snippet.</summary>
Snippet = 9,
/// <summary>A port forward.</summary>
PortForward = 10,
/// <summary>A known SSH host key.</summary>
KnownHostKey = 11,
// 12 and 13 close a hole rather than adding a feature. Contracts.SyncEntityType has carried
// HostTag and HostCredential since it was frozen, so those items are syncable — but with no
// resource type here, their payloads had nothing to bind an AAD to. Added 2026-07-29, while
// the enum is still append-only and no such item has been stored.
/// <summary>A host-to-tag association.</summary>
HostTag = 12,
/// <summary>A host-to-credential association.</summary>
HostCredential = 13,
/// <summary>One connection that was made, and how long it lasted.</summary>
/// <remarks>
/// 14 and 15 are the first members here that are not one-behind their <c>SyncEntityType</c>
/// counterparts by the same constant offset — the two enums drifted apart when 12 and 13 closed a
/// hole. That is why nothing casts between them and why <c>AadResourceTypeTests</c> pins each pairing
/// by name.
/// </remarks>
ConnectionLogEntry = 14,
/// <summary>One create, edit or delete of a vault item.</summary>
ActivityLogEntry = 15,
/// <summary>An S3-compatible bucket, and the credentials that reach it.</summary>
ObjectStore = 16,
// 17 was SpelunkKey, removed with its item type (SyncEntityType 14). Not to be reused, so no payload
// sealed under it can ever open as something else.
/// <summary>This device's sign-in to one Spelunk. Local cache only; never synced.</summary>
SpelunkSignIn = 18,
}
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
public static class DerivationLabels
{
/// <summary>Derives the key-encryption key that wraps the secret bundle.</summary>
public static ReadOnlySpan<byte> PassphraseKek => "dsh1/kek/passphrase/v1"u8;
/// <summary>
/// Derives the key that encrypts the client's on-disk cache.
/// </summary>
/// <remarks>
/// <b>v2 derives from the secret bundle, where v1 derived from the passphrase master key.</b> The
/// cache key has to be reachable through every door that opens the vault — passphrase, device,
/// recovery code, escrow — and only the bundle is common to all of them. Under v1 a device unlock
/// could open the identity and still not read the cache it had written, and a recovery-code unlock
/// would silently derive a different key and orphan the whole cache. Bumping the label rather than
/// reusing it is what makes an old cache fail to open instead of decrypting to nonsense.
/// </remarks>
public static ReadOnlySpan<byte> LocalCache => "dsh1/localcache/v2"u8;
/// <summary>Prefix of the SealTo key-derivation info, concatenated with the AAD.</summary>
public static ReadOnlySpan<byte> SealTo => "dsh1/sealto/v1|"u8;
/// <summary>Prefix of the identity key fingerprint input.</summary>
public static ReadOnlySpan<byte> Fingerprint => "dsh1/fp/v1"u8;
}
/// <summary>Ed25519 signing contexts. Prevents a signature being replayed in another role.</summary>
public static class SigningContexts
{
/// <summary>Signs an enrollment key statement.</summary>
public static ReadOnlySpan<byte> KeyStatement => "dsh1/sig/keystatement/v1"u8;
/// <summary>Signs a vault key grant tuple.</summary>
public static ReadOnlySpan<byte> Grant => "dsh1/sig/grant/v1"u8;
/// <summary>Signs an admin attestation of another user's key statement.</summary>
public static ReadOnlySpan<byte> Attestation => "dsh1/sig/attestation/v1"u8;
}
}