Public Access
Freeze DSH1 crypto specification and implement the core (M1)
docs/crypto.md is now the normative, frozen specification. This had to land before anything else in M1: the server holds ciphertext and no keys, so it can never re-encrypt, and a format change after users hold data is a coordinated client rewrite with no rollback. Specification: - DSH1 envelope layout, canonical 64-byte AAD encoding, SealTo construction, key hierarchy, Argon2id profiles, fingerprints, and the change rules for each version field. - AAD encoding is fixed-width binary rather than delimited string concatenation, so no field value can forge a field boundary. This supersedes the illustrative form sketched in ADR 0001, which now points here. - UUIDs are RFC 4122 big-endian. Guid.ToByteArray() emits the first three groups little-endian and would have made our ciphertext unreadable by any other implementation of this spec, failing only at a cross-implementation boundary. Verified rather than assumed: - PrimitiveAvailabilityTests proves X25519, Ed25519, XChaCha20-Poly1305, Argon2id and HKDF-SHA512 all function on net10.0. NSec 26.4.0 targets net9.0 and is consumed by forward compatibility; this closes one of the two package questions the plan flagged. - Argon2Profile exists because NSec's MemorySize is in KIBIBYTES, not bytes. Passing bytes gives either a 256 GiB allocation or a 256 KiB KDF that cracks instantly. The type takes mebibytes so the unit cannot be got wrong at a call site. Found by benchmarking: the first measurements were ~1000x too slow, which turned out to be 19 GiB of work. - Parameters measured, not guessed: 256 MiB/t=4 is 323 ms on this machine; the table of candidates is in the spec. Implementation and tests (83 total, up from 17): - AadDescriptor, DshEnvelope, DshCrypto (Seal/Open/SealTo/OpenSealed/fingerprints). - Decryption returns null rather than throwing: ciphertext comes from a server that is explicitly not trusted, so a failed tag is an expected outcome. - Envelope readers reject unknown algorithms and any non-zero flag bit, so an envelope that is not fully understood fails closed. - Executable form of the spec's substitution claims: a server cannot move ciphertext between resources, roll back a key generation or item version, repurpose a payload as metadata, or confuse the two constructions. - Golden vectors in tests/fixtures/crypto/vectors.json guard the format. Mutation-checked: a one-byte schema version change trips four tests including the guard. Two build-infrastructure bugs found and fixed along the way: - .editorconfig forced camelCase on const and static readonly fields. PascalCase is the .NET convention for both; the config was wrong, not the code. - The golden fixture was resolved with [CallerFilePath], which ContinuousIntegrationBuild rewrites to /_/... under deterministic source paths. It passed locally and would have failed only in CI. Now copied to the output directory and read from there.
This commit is contained in:
1 parent
ce43f397a6
commit
b15af836a3
21 files changed
+2589
-30
No files matched your search
@@ -1,47 +1,171 @@
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Constants of the DodoSSH cryptographic specification.
|
||||
/// Constants of the DodoSSH cryptographic specification, version 1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// docs/crypto.md is the normative specification; this type must agree with it exactly.
|
||||
/// The implementation of the envelope, AAD derivation and key wrapping lands in M1, once
|
||||
/// the specification and its test vectors are frozen. Nothing else may be built on top of
|
||||
/// an unfrozen AAD: only clients can re-encrypt, so a change after users hold data cannot
|
||||
/// be migrated server-side.
|
||||
/// 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 const string EnvelopeMagic = "DSH1";
|
||||
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.
|
||||
/// 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 short CurrentAadVersion = 1;
|
||||
public const byte CurrentAadVersion = 1;
|
||||
|
||||
/// <summary>Domain-separation prefix for every AAD computation.</summary>
|
||||
public const string AadDomainPrefix = "dsh1\n";
|
||||
/// <summary>Version of the relational schema that AAD is bound to.</summary>
|
||||
public const ushort CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Identifiers for the algorithms an envelope may declare.</summary>
|
||||
/// <summary>Size of a symmetric content or wrapping key.</summary>
|
||||
public const int SymmetricKeySize = 32;
|
||||
|
||||
/// <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.</summary>
|
||||
/// <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 where XChaCha20 is unavailable.</summary>
|
||||
/// <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.</summary>
|
||||
/// <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. Store-now-decrypt-later is
|
||||
// a genuine threat for long-lived SSH keys, so the identifier is claimed now even
|
||||
// though the construction ships later.
|
||||
// 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,
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public static ReadOnlySpan<byte> LocalCache => "dsh1/localcache/v1"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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user