namespace DodoSSH.Crypto;
///
/// Constants of the DodoSSH cryptographic specification, version 1.
///
///
/// 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.
///
public static class CryptoSpec
{
/// Magic prefix identifying a DSH1 envelope.
public static ReadOnlySpan EnvelopeMagic => "DSH1"u8;
/// Magic prefix of the canonical AAD encoding.
public static ReadOnlySpan AadMagic => "dsh1\n"u8;
/// Length of the canonical AAD encoding, before hashing.
public const int AadEncodedLength = 64;
/// Version of the AAD derivation rule that payloads are bound to.
///
/// Stored per row as payload_aad_version so a future change can be applied lazily,
/// re-encrypting on next write rather than in a migration.
///
public const byte CurrentAadVersion = 1;
/// Version of the relational schema that AAD is bound to.
public const ushort CurrentSchemaVersion = 1;
/// Size of a symmetric content or wrapping key.
public const int SymmetricKeySize = 32;
///
/// Size of the passphrase-derived master key, which subkeys are expanded from.
///
///
/// 64 bytes, not 32, because it is the PRK of an HKDF-SHA512 expansion and .NET's
/// HKDF.Expand rejects a PRK shorter than the hash output. See docs/crypto.md §3.
///
public const int MasterKeySize = 64;
/// Size of an X25519 or Ed25519 public key.
public const int PublicKeySize = 32;
/// Size of an Ed25519 signature.
public const int SignatureSize = 64;
/// Size of a SHA-256 output, used for AAD, fingerprints and digests.
public const int DigestSize = 32;
/// Size of an AEAD authentication tag.
public const int TagSize = 16;
/// Recommended salt length for password-based derivation.
public const int SaltSize = 16;
/// Identifies the construction used by a DSH1 envelope.
public enum AlgorithmId : byte
{
/// Reserved; never written, and rejected on read.
Unspecified = 0,
/// Symmetric content encryption under a known key.
XChaCha20Poly1305 = 1,
///
/// Symmetric fallback for environments without XChaCha20-Poly1305. Accepted on read,
/// not currently emitted.
///
Aes256Gcm = 2,
/// Anonymous-sender seal to an X25519 public key. See docs/crypto.md §6.
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.
}
///
/// 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.
///
public enum AadPurpose : byte
{
/// Not a legal value.
Unspecified = 0,
/// A wrap of the user's secret bundle.
UserSecretBundle = 1,
/// A vault key sealed to a member's public key.
VaultKeyGrant = 2,
/// An item data key wrapped under a vault key.
ItemDataKey = 3,
/// Item plaintext under its data key.
ItemPayload = 4,
/// Encrypted item metadata under its data key.
ItemMetadata = 5,
/// A record in a client's on-disk cache.
LocalCache = 6,
}
///
/// The kind of entity a ciphertext belongs to. Part of the AAD; see docs/crypto.md §4.3.
/// Append only.
///
public enum AadResourceType : byte
{
/// No resource. Legal only where the purpose implies none.
None = 0,
/// A user account.
User = 1,
/// An enrolled device.
Device = 2,
/// A vault.
Vault = 3,
/// An SSH host.
Host = 4,
/// A credential.
Credential = 5,
/// An SSH key pair.
SshKey = 6,
/// A host group.
HostGroup = 7,
/// A tag.
Tag = 8,
/// A snippet.
Snippet = 9,
/// A port forward.
PortForward = 10,
/// A known SSH host key.
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.
/// A host-to-tag association.
HostTag = 12,
/// A host-to-credential association.
HostCredential = 13,
/// One connection that was made, and how long it lasted.
///
/// 14 and 15 are the first members here that are not one-behind their SyncEntityType
/// 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 AadResourceTypeTests pins each pairing
/// by name.
///
ConnectionLogEntry = 14,
/// One create, edit or delete of a vault item.
ActivityLogEntry = 15,
/// An S3-compatible bucket, and the credentials that reach it.
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.
/// This device's sign-in to one Spelunk. Local cache only; never synced.
SpelunkSignIn = 18,
}
/// HKDF info labels. Domain-separated so one subkey cannot stand in for another.
public static class DerivationLabels
{
/// Derives the key-encryption key that wraps the secret bundle.
public static ReadOnlySpan PassphraseKek => "dsh1/kek/passphrase/v1"u8;
///
/// Derives the key that encrypts the client's on-disk cache.
///
///
/// v2 derives from the secret bundle, where v1 derived from the passphrase master key. 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.
///
public static ReadOnlySpan LocalCache => "dsh1/localcache/v2"u8;
/// Prefix of the SealTo key-derivation info, concatenated with the AAD.
public static ReadOnlySpan SealTo => "dsh1/sealto/v1|"u8;
/// Prefix of the identity key fingerprint input.
public static ReadOnlySpan Fingerprint => "dsh1/fp/v1"u8;
}
/// Ed25519 signing contexts. Prevents a signature being replayed in another role.
public static class SigningContexts
{
/// Signs an enrollment key statement.
public static ReadOnlySpan KeyStatement => "dsh1/sig/keystatement/v1"u8;
/// Signs a vault key grant tuple.
public static ReadOnlySpan Grant => "dsh1/sig/grant/v1"u8;
/// Signs an admin attestation of another user's key statement.
public static ReadOnlySpan Attestation => "dsh1/sig/attestation/v1"u8;
}
}