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:
@@ -62,6 +62,26 @@ dotnet_naming_symbols.any_interface.applicable_kinds = interface
|
||||
dotnet_naming_style.starts_with_i.required_prefix = I
|
||||
dotnet_naming_style.starts_with_i.capitalization = pascal_case
|
||||
|
||||
# Constants and static readonly fields are PascalCase, per .NET convention. These rules must
|
||||
# come before the camelCase rule below: the first matching rule wins, and a rule matching all
|
||||
# private fields would otherwise force `const int Foo` to be named `foo`.
|
||||
dotnet_naming_rule.constants_are_pascal_case.severity = warning
|
||||
dotnet_naming_rule.constants_are_pascal_case.symbols = any_const_field
|
||||
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case_style
|
||||
dotnet_naming_symbols.any_const_field.applicable_kinds = field
|
||||
dotnet_naming_symbols.any_const_field.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.any_const_field.required_modifiers = const
|
||||
|
||||
dotnet_naming_rule.static_readonly_fields_are_pascal_case.severity = warning
|
||||
dotnet_naming_rule.static_readonly_fields_are_pascal_case.symbols = static_readonly_field
|
||||
dotnet_naming_rule.static_readonly_fields_are_pascal_case.style = pascal_case_style
|
||||
dotnet_naming_symbols.static_readonly_field.applicable_kinds = field
|
||||
dotnet_naming_symbols.static_readonly_field.applicable_accessibilities = *
|
||||
dotnet_naming_symbols.static_readonly_field.required_modifiers = static, readonly
|
||||
|
||||
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
|
||||
|
||||
# Private instance fields are camelCase.
|
||||
dotnet_naming_rule.private_fields_are_camel_case.severity = warning
|
||||
dotnet_naming_rule.private_fields_are_camel_case.symbols = private_field
|
||||
dotnet_naming_rule.private_fields_are_camel_case.style = camel_case_style
|
||||
|
||||
@@ -26,6 +26,23 @@
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Cryptography">
|
||||
<!--
|
||||
NSec wraps libsodium. Chosen over the BCL because .NET has no X25519 or Ed25519, and
|
||||
because ChaCha20Poly1305.IsSupported is false on macOS, which rules out the in-box
|
||||
AEAD for a cross-platform client. NSec also holds key material in libsodium's
|
||||
guarded, non-swappable memory, which a byte[] cannot do. See docs/crypto.md.
|
||||
|
||||
26.4.0 targets net9.0; net10.0 consumes it by forward compatibility. Native binaries
|
||||
arrive via the libsodium package, pinned here because central transitive pinning
|
||||
requires it to be declared.
|
||||
-->
|
||||
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Analyzers">
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="5.6.0" />
|
||||
|
||||
@@ -29,8 +29,12 @@ we cannot make.
|
||||
key; each item has its own data key wrapped under the vault key. Rotating a vault key
|
||||
re-wraps N × 32-byte data keys and never touches content blobs.
|
||||
4. **AAD bound to row identity.** Every ciphertext's AAD is recomputed from the row's
|
||||
plaintext columns rather than stored:
|
||||
`SHA-256("dsh1\n" + purpose + resourceType + resourceId + keyId + keyGeneration + schemaVersion)`.
|
||||
plaintext columns rather than stored, over `purpose`, `resourceType`, `resourceId`,
|
||||
`keyId`, `keyGeneration`, `itemVersion` and `schemaVersion`.
|
||||
|
||||
The normative byte encoding is [`docs/crypto.md` §4](../crypto.md). It is fixed-width
|
||||
binary rather than delimited string concatenation, so that no field value can forge a
|
||||
field boundary.
|
||||
5. **Layered public-key trust** — see Consequences.
|
||||
|
||||
## Consequences
|
||||
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
# DodoSSH cryptographic specification (DSH1)
|
||||
|
||||
- Status: **frozen** as of 2026-07-28. Version `1`.
|
||||
- Normative. `DodoSSH.Crypto` must agree with this document exactly, and
|
||||
`tests/fixtures/crypto/vectors.json` pins the byte-level results.
|
||||
|
||||
> **Why this is frozen before anything is built on it.** The server holds ciphertext and no
|
||||
> keys, so it cannot re-encrypt anything, ever. Only clients can. A change to the envelope
|
||||
> layout or to AAD derivation after users hold data is therefore not a server migration — it
|
||||
> is a coordinated rewrite of every client's local store, with no rollback. Additive change
|
||||
> is possible through the version fields in §8; changing the meaning of an existing field is
|
||||
> not.
|
||||
|
||||
## 1. Primitives
|
||||
|
||||
| Purpose | Algorithm | Source |
|
||||
| --- | --- | --- |
|
||||
| Passphrase KDF | Argon2id | NSec (libsodium) |
|
||||
| Subkey derivation | HKDF-SHA512 | BCL `System.Security.Cryptography.HKDF` |
|
||||
| Content AEAD | XChaCha20-Poly1305 | NSec (libsodium) |
|
||||
| Key wrapping | X25519 + HKDF-SHA256 + XChaCha20-Poly1305 | NSec (libsodium) |
|
||||
| Signatures | Ed25519 | NSec (libsodium) |
|
||||
| Hashing, fingerprints | SHA-256 | BCL |
|
||||
|
||||
### Why not the BCL for everything
|
||||
|
||||
- The BCL has **no X25519 and no Ed25519** as of .NET 10. `ECDiffieHellman` is NIST curves
|
||||
only. We do not substitute P-256 to avoid a native dependency: point validation, cofactor
|
||||
and encoding are all footguns that X25519 does not have.
|
||||
- **`ChaCha20Poly1305.IsSupported` is false on macOS**, and on Windows builds before
|
||||
10.0.20142. That disqualifies the in-box AEAD for a cross-platform client.
|
||||
- NSec holds key material in libsodium's guarded, `mlock`ed allocations with
|
||||
`KeyExportPolicies.None`, which is real protection against heap scraping and core dumps.
|
||||
A `byte[]` cannot offer that.
|
||||
|
||||
`AES-256-GCM` (`alg_id = 2`) is specified as a fallback for environments without
|
||||
XChaCha20-Poly1305. It is **not currently emitted**; readers must accept it.
|
||||
|
||||
### Verified availability
|
||||
|
||||
`tests/DodoSSH.Crypto.Tests/PrimitiveAvailabilityTests.cs` proves each primitive functions on
|
||||
the host running the suite. It is not ceremony: it is the guard that catches a platform where
|
||||
this specification is not implementable.
|
||||
|
||||
## 2. Argon2id parameters
|
||||
|
||||
| Purpose | Memory | Passes | Parallelism | Output |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Passphrase → master key | 256 MiB | 4 | 1 | 32 B |
|
||||
| Recovery code → KEK | 64 MiB | 3 | 1 | 32 B |
|
||||
| Invite secret → KEK | 64 MiB | 3 | 1 | 32 B |
|
||||
|
||||
Salt is 16 bytes from a CSPRNG, fresh on every passphrase change.
|
||||
|
||||
> ### `MemorySize` is in kibibytes
|
||||
>
|
||||
> `NSec.Cryptography.Argon2Parameters.MemorySize` is **KiB, not bytes**. Passing bytes gives
|
||||
> either a catastrophically weak KDF or an absurd allocation:
|
||||
>
|
||||
> | Intent | Correct | If bytes were assumed |
|
||||
> | --- | --- | --- |
|
||||
> | 256 MiB | `262144` | `268435456` → 256 GiB, allocation failure |
|
||||
> | | | `262144` bytes → 256 KiB, ~1 ms, trivially crackable |
|
||||
>
|
||||
> `DodoSSH.Crypto` therefore never accepts a raw integer here. `Argon2Profile` takes
|
||||
> `MemoryMebibytes` and converts, so the unit cannot be got wrong at a call site.
|
||||
|
||||
**Parallelism is pinned to 1** because libsodium's Argon2id implementation supports only
|
||||
`p=1`. Memory cost compensates: 256 MiB at `t=4` is far above OWASP's 19 MiB/`t=2` floor.
|
||||
|
||||
Measured on a fast desktop (see §2 note in the test suite for the harness):
|
||||
|
||||
| Parameters | Time |
|
||||
| --- | --- |
|
||||
| 64 MiB, t=3 | 52 ms |
|
||||
| 128 MiB, t=3 | 114 ms |
|
||||
| **256 MiB, t=4 (default)** | **323 ms** |
|
||||
| 512 MiB, t=4 | 700 ms |
|
||||
|
||||
A once-per-session unlock at roughly 0.3 s on fast hardware and an estimated 1–1.5 s on a
|
||||
low-end laptop is the intended trade. Clients expose a security level of 128 / 256 / 512 MiB.
|
||||
|
||||
**KDF parameters are stored in plaintext per wrap row** (`kdf_alg`, `kdf_salt`, `kdf_m`,
|
||||
`kdf_t`, `kdf_p`). Salts are not secrets, and storing the parameters makes raising them later
|
||||
a per-user, unlock-time migration instead of a breaking change. An old client can still open
|
||||
its own wrap.
|
||||
|
||||
**No passphrase verifier is stored server-side.** An `Argon2id(passphrase)` hash held by the
|
||||
server would be an offline-crackable target on the very machine being defended against, for
|
||||
no gain: the AEAD tag on the bundle wrap already proves the passphrase. Rate limiting is the
|
||||
OIDC access-token gate plus client-side backoff.
|
||||
|
||||
## 3. Key hierarchy
|
||||
|
||||
```
|
||||
vault passphrase
|
||||
│ Argon2id(salt, m=256 MiB, t=4, p=1) → 32 B
|
||||
▼
|
||||
MK — master key, RAM only, never persisted, never transmitted
|
||||
│ HKDF-SHA512-Expand with domain-separated info labels
|
||||
├── KEK_pp info = "dsh1/kek/passphrase/v1" 32 B
|
||||
└── LocalCacheKey info = "dsh1/localcache/v1" 32 B
|
||||
▼
|
||||
UserSecretBundle — canonical CBOR, ~200 B
|
||||
{ v: 1, x25519_sk: 32 B, ed25519_sk: 32 B, created: <unix s>, keyGeneration: <u32> }
|
||||
stored server-side as N independent wraps of the SAME bundle:
|
||||
kind=passphrase → symmetric AEAD under KEK_pp
|
||||
kind=device → SealTo(device_x25519_pk) one row per enrolled device
|
||||
kind=recovery → symmetric AEAD under KEK_rc = Argon2id(recovery code)
|
||||
kind=escrow → SealTo(team_breakglass_pk) opt-in, M5
|
||||
▼
|
||||
VaultKey — 32 B CSPRNG, per vault, per key generation
|
||||
wrapped per member: SealTo(member_x25519_pk, VaultKey, aad)
|
||||
▼
|
||||
DataKey (DK) — 32 B CSPRNG, per item, per version
|
||||
wrapped: XChaCha20-Poly1305(VaultKey, DK, aad)
|
||||
▼
|
||||
item plaintext — password, private key, key passphrase, TOTP seed, encrypted metadata
|
||||
XChaCha20-Poly1305(DK, plaintext, aad)
|
||||
```
|
||||
|
||||
### Why the bundle is wrapped many ways
|
||||
|
||||
This is the load-bearing structural choice. Because every wrap protects the *same* bundle:
|
||||
|
||||
- **Passphrase change** re-derives `KEK_pp` from a new salt, re-wraps ~200 bytes and updates
|
||||
one row. No vault data is re-encrypted and no other member is involved. This is the entire
|
||||
reason an identity keypair exists rather than encrypting vault keys under the passphrase key
|
||||
directly.
|
||||
- **New device** is one additional wrap row.
|
||||
- **Recovery** is one additional wrap row.
|
||||
|
||||
### Why a per-item DataKey
|
||||
|
||||
1. **Cheap rotation.** Rotating a vault key re-wraps N × 32-byte data keys and never touches
|
||||
content blobs. A 10,000-item vault rotates in a few hundred kilobytes of writes.
|
||||
2. **Narrow sharing.** A single item can be re-wrapped to another vault key or user key.
|
||||
3. **Nonce hygiene.** Each key encrypts about one message.
|
||||
4. **Versioning.** A new item version gets a new data key, so prior ciphertext stays
|
||||
independently decryptable for history and undo.
|
||||
|
||||
Per-item keys wrapped *to individual users* — which is what would make per-item ACLs
|
||||
cryptographic rather than server-enforced — are deferred to M5. The `content_key_id` column
|
||||
exists from the first migration so that lands without a migration. Until then, **an item ACL
|
||||
is access control, not cryptographic isolation**: anyone holding the vault key can decrypt any
|
||||
ciphertext they obtain. Say so in the product.
|
||||
|
||||
## 4. Canonical AAD
|
||||
|
||||
Every AEAD operation binds its ciphertext to the identity of the row that holds it. The AAD is
|
||||
**not stored**; it is recomputed from that row's plaintext columns on both encrypt and decrypt.
|
||||
|
||||
### 4.1 Encoding
|
||||
|
||||
Fixed-width binary, 64 bytes, big-endian throughout:
|
||||
|
||||
| Offset | Size | Field | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | 5 | magic | ASCII `dsh1\n` |
|
||||
| 5 | 1 | aadVersion | `u8`, currently `1` |
|
||||
| 6 | 1 | purpose | `u8`, §4.2 |
|
||||
| 7 | 1 | resourceType | `u8`, §4.3 |
|
||||
| 8 | 16 | resourceId | UUID, RFC 4122 big-endian byte order |
|
||||
| 24 | 16 | keyId | UUID, or 16 zero bytes when not applicable |
|
||||
| 40 | 4 | keyGeneration | `u32` |
|
||||
| 44 | 4 | itemVersion | `u32`, `0` when not applicable |
|
||||
| 48 | 2 | schemaVersion | `u16` |
|
||||
| 50 | 14 | reserved | zero |
|
||||
|
||||
```
|
||||
AAD = SHA-256(canonical 64-byte encoding)
|
||||
```
|
||||
|
||||
Fixed-width encoding is used rather than delimited string concatenation so that no field
|
||||
value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
||||
**not** .NET's `Guid.ToByteArray()`, which emits the first three groups little-endian. Use
|
||||
`Guid.TryWriteBytes(dest, bigEndian: true)`.
|
||||
|
||||
> This supersedes the illustrative string form sketched in
|
||||
> [ADR 0001](adr/0001-e2ee-trust-model.md). The fields and intent are unchanged; only the
|
||||
> byte encoding is nailed down here.
|
||||
|
||||
### 4.2 `purpose`
|
||||
|
||||
| Value | Name | Binds |
|
||||
| --- | --- | --- |
|
||||
| 1 | `UserSecretBundle` | a bundle wrap |
|
||||
| 2 | `VaultKeyGrant` | a vault key sealed to a member |
|
||||
| 3 | `ItemDataKey` | a data key wrapped under a vault key |
|
||||
| 4 | `ItemPayload` | item plaintext under its data key |
|
||||
| 5 | `ItemMetadata` | encrypted host metadata under its data key |
|
||||
| 6 | `LocalCache` | a client's on-disk cache record |
|
||||
|
||||
### 4.3 `resourceType`
|
||||
|
||||
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey.
|
||||
|
||||
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||
|
||||
### 4.4 What this prevents
|
||||
|
||||
A malicious or compromised server, holding every ciphertext and every plaintext column:
|
||||
|
||||
- **cannot move** credential A's payload onto host B — `resourceId` differs, tag fails;
|
||||
- **cannot roll back** a row to an earlier key generation — `keyGeneration` differs;
|
||||
- **cannot replay** a revoked grant blob — `keyGeneration` and `resourceId` differ;
|
||||
- **cannot repurpose** a bundle wrap as a vault grant — `purpose` differs;
|
||||
- **cannot substitute** an item's metadata blob for its payload blob — `purpose` differs.
|
||||
|
||||
None of that follows from ACLs. It is the single most valuable structural property here, and
|
||||
it is why AAD derivation is frozen ahead of everything else.
|
||||
|
||||
## 5. DSH1 envelope
|
||||
|
||||
Binary layout. All multi-byte integers big-endian.
|
||||
|
||||
```
|
||||
offset size field
|
||||
0 4 magic ASCII "DSH1"
|
||||
4 1 alg_id 1 XChaCha20-Poly1305 | 2 AES-256-GCM | 3 SealTo(X25519)
|
||||
5 1 flags reserved, must be 0, readers must reject non-zero
|
||||
[alg_id = 3 only]
|
||||
6 32 ephemeral_pk X25519 ephemeral public key
|
||||
— — nonce 24 B for alg 1 and 3, 12 B for alg 2
|
||||
— n ciphertext includes the trailing 16-byte AEAD tag
|
||||
```
|
||||
|
||||
- Header is 6 bytes, plus 32 for `alg_id = 3`.
|
||||
- Nonces are drawn from a CSPRNG per message. A 192-bit nonce is why no counter is needed;
|
||||
this is a concrete reason to prefer XChaCha20 over AES-GCM's 96-bit nonce.
|
||||
- `flags` exists so a reader can fail closed on an envelope it does not fully understand.
|
||||
- Minimum lengths: 46 bytes for `alg_id = 1`, 34 for `2`, 78 for `3`. Shorter is malformed.
|
||||
|
||||
## 6. `SealTo` — anonymous-sender wrapping (`alg_id = 3`)
|
||||
|
||||
Specified explicitly rather than using libsodium's sealed box, because the sealed-box KDF is
|
||||
Blake2b over the ephemeral and recipient keys only and we require the AAD binding of §4.
|
||||
|
||||
```
|
||||
Seal(recipient_pk, plaintext, aad):
|
||||
(e_sk, e_pk) = X25519.GenerateKeyPair()
|
||||
dh = X25519(e_sk, recipient_pk) reject all-zero output
|
||||
prk = HKDF-SHA256-Extract(salt = e_pk || recipient_pk, ikm = dh)
|
||||
k = HKDF-SHA256-Expand(prk, info = "dsh1/sealto/v1|" || aad, L = 32)
|
||||
nonce = CSPRNG(24)
|
||||
ct = XChaCha20-Poly1305-Encrypt(k, nonce, aad, plaintext)
|
||||
wipe(e_sk, dh, prk, k)
|
||||
return e_pk || nonce || ct
|
||||
|
||||
Open(recipient_sk, envelope, aad):
|
||||
parse e_pk, nonce, ct
|
||||
dh = X25519(recipient_sk, e_pk) reject all-zero output
|
||||
prk = HKDF-SHA256-Extract(salt = e_pk || X25519_public(recipient_sk), ikm = dh)
|
||||
k = HKDF-SHA256-Expand(prk, info = "dsh1/sealto/v1|" || aad, L = 32)
|
||||
return XChaCha20-Poly1305-Decrypt(k, nonce, aad, ct) null on tag failure
|
||||
```
|
||||
|
||||
`SealTo` is **anonymous-sender by construction** — it proves nothing about who created the
|
||||
envelope. Every grant record therefore additionally carries a **detached Ed25519 signature**
|
||||
from the granter (§7). Without that, a server could fabricate a grant and the recipient could
|
||||
not tell.
|
||||
|
||||
## 7. Signatures
|
||||
|
||||
Ed25519 over a canonical, length-prefixed encoding. Each signature is domain-separated by a
|
||||
context string so a signature in one role can never be replayed in another:
|
||||
|
||||
| Context | Signs |
|
||||
| --- | --- |
|
||||
| `dsh1/sig/keystatement/v1` | an enrollment key statement |
|
||||
| `dsh1/sig/grant/v1` | `(vaultId, keyGeneration, granteeUserId, granteeKeyFingerprint, SHA-256(wrappedKey), grantKind, granterUserId, granterKeyFingerprint, keyLogHead, timestamp)` |
|
||||
| `dsh1/sig/attestation/v1` | an admin's attestation of another user's key statement |
|
||||
|
||||
A grant signature covers `SHA-256(wrappedKey)` rather than the wrapped key itself, so
|
||||
signature verification does not require the verifier to hold the vault key.
|
||||
|
||||
**The server stores signatures opaquely and clients verify them.** Server-side verification
|
||||
would be a convenience, never the security boundary, and would drag an asymmetric
|
||||
implementation onto a machine that is supposed to have none.
|
||||
|
||||
## 8. Fingerprints and versioning
|
||||
|
||||
```
|
||||
fingerprint = SHA-256( "dsh1/fp/v1" || x25519_pk || ed25519_pk ) 32 bytes
|
||||
```
|
||||
|
||||
Displayed as lowercase hex in groups of four. The 6-word safety number for out-of-band
|
||||
verification derives from the first 48 bits of the sorted concatenation of both parties'
|
||||
fingerprints, so both sides compute the same words regardless of who initiates.
|
||||
|
||||
SSH **host** key fingerprints are a different thing and follow OpenSSH:
|
||||
`SHA256:` + unpadded base64 of `SHA-256(host key blob)`. Compute from the raw host key blob;
|
||||
do not use SSH.NET's MD5 property.
|
||||
|
||||
### Change rules
|
||||
|
||||
| Field | Widening | Meaning change |
|
||||
| --- | --- | --- |
|
||||
| `alg_id` | new value, readers reject unknown | never |
|
||||
| `flags` | new bit, readers reject unknown bits | never |
|
||||
| `aadVersion` | new value; rows carry `payload_aad_version` | never |
|
||||
| `keyGeneration` | monotonic per vault | never |
|
||||
| `purpose`, `resourceType` | append only | never |
|
||||
|
||||
`alg_id = 4` is **reserved** for a hybrid X25519 + ML-KEM-768 seal. The identifier is claimed
|
||||
now, before it is needed, because store-now-decrypt-later is a genuine threat against
|
||||
long-lived SSH private keys and the value must not be reused. .NET 10 ships `MLKem`; the
|
||||
construction concatenates both shared secrets into HKDF-Extract. Deferred, not forgotten.
|
||||
|
||||
Raising `aadVersion` or `alg_id` requires a **client-side lazy re-encrypt-on-write path** to
|
||||
exist first. The server cannot participate.
|
||||
|
||||
## 9. Test vectors
|
||||
|
||||
`tests/fixtures/crypto/vectors.json` is generated by
|
||||
`DodoSSH.Crypto.Tests.VectorGenerator` and asserted by `GoldenVectorTests`. It pins:
|
||||
|
||||
- canonical AAD encodings and their SHA-256, including UUID byte order;
|
||||
- envelope framing for each `alg_id`, with fixed key, nonce and plaintext;
|
||||
- Argon2id and HKDF outputs for fixed inputs;
|
||||
- the negative cases of §4.4 — each must fail to decrypt.
|
||||
|
||||
Deterministic operations are pinned to exact bytes. `SealTo` and signature generation use
|
||||
fresh randomness, so those are verified by round-trip plus fixed-input `Open` vectors.
|
||||
|
||||
**A failing golden vector is never to be "fixed" by regenerating the file.** It means either a
|
||||
genuine regression or an intentional, versioned format change that requires a client migration
|
||||
path first.
|
||||
|
||||
## 10. Threat model boundaries
|
||||
|
||||
This specification protects the confidentiality and integrity of vault contents against the
|
||||
server, its operators, its backups and the network. It does **not** address:
|
||||
|
||||
- a compromised client endpoint — past the endpoint, E2EE is irrelevant;
|
||||
- a malicious authorized member — an authorization and rotation problem;
|
||||
- retroactive revocation — impossible; rotate the SSH credential itself;
|
||||
- public-key substitution — mitigated but not eliminated; see
|
||||
[ADR 0001 §Consequences](adr/0001-e2ee-trust-model.md);
|
||||
- metadata — item counts, sizes, timestamps, access patterns and the sharing graph are
|
||||
visible, as are host addresses for relay-enabled hosts;
|
||||
- a weak passphrase — §2 parameters and passphrase entropy are the whole defence;
|
||||
- supply chain — a server can serve a backdoored client. Sign releases with a key the server
|
||||
does not hold. In a self-hosted E2EE product this is the largest practical hole.
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the row a ciphertext belongs to, and computes the additional authenticated data
|
||||
/// that binds the ciphertext to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// See docs/crypto.md §4, which is normative. The AAD is never stored: it is recomputed from
|
||||
/// the row's plaintext columns on both encrypt and decrypt. That is what stops a server that
|
||||
/// holds every ciphertext from moving one between rows, rolling a row back to an earlier key
|
||||
/// generation, replaying a revoked grant, or substituting a metadata blob for a payload blob.
|
||||
/// None of those properties follow from access control.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The encoding is fixed-width rather than delimited, so no field value can forge a field
|
||||
/// boundary.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Purpose">What this ciphertext is.</param>
|
||||
/// <param name="ResourceType">The kind of entity it belongs to.</param>
|
||||
/// <param name="ResourceId">The entity's identifier.</param>
|
||||
/// <param name="KeyId">The key it is encrypted under, where one is identified.</param>
|
||||
/// <param name="KeyGeneration">The vault key generation in force.</param>
|
||||
/// <param name="ItemVersion">The item version, where applicable.</param>
|
||||
/// <param name="AadVersion">The AAD rule version, so a change can be applied lazily.</param>
|
||||
/// <param name="SchemaVersion">The relational schema version.</param>
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
public readonly record struct AadDescriptor(
|
||||
CryptoSpec.AadPurpose Purpose,
|
||||
CryptoSpec.AadResourceType ResourceType,
|
||||
Guid ResourceId,
|
||||
Guid KeyId,
|
||||
uint KeyGeneration,
|
||||
uint ItemVersion,
|
||||
byte AadVersion,
|
||||
ushort SchemaVersion)
|
||||
{
|
||||
private const int OffsetAadVersion = 5;
|
||||
private const int OffsetPurpose = 6;
|
||||
private const int OffsetResourceType = 7;
|
||||
private const int OffsetResourceId = 8;
|
||||
private const int OffsetKeyId = 24;
|
||||
private const int OffsetKeyGeneration = 40;
|
||||
private const int OffsetItemVersion = 44;
|
||||
private const int OffsetSchemaVersion = 48;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a descriptor at the current AAD and schema versions.
|
||||
/// </summary>
|
||||
public static AadDescriptor Create(
|
||||
CryptoSpec.AadPurpose purpose,
|
||||
CryptoSpec.AadResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid keyId = default,
|
||||
uint keyGeneration = 1,
|
||||
uint itemVersion = 0) =>
|
||||
new(
|
||||
purpose,
|
||||
resourceType,
|
||||
resourceId,
|
||||
keyId,
|
||||
keyGeneration,
|
||||
itemVersion,
|
||||
CryptoSpec.CurrentAadVersion,
|
||||
CryptoSpec.CurrentSchemaVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the canonical 64-byte encoding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// UUIDs are written in RFC 4122 big-endian order, <b>not</b> the mixed-endian order that
|
||||
/// <see cref="Guid.ToByteArray()"/> produces by default. Getting that wrong would make
|
||||
/// ciphertext written by one implementation undecryptable by another.
|
||||
/// </remarks>
|
||||
public void WriteCanonicalEncoding(Span<byte> destination)
|
||||
{
|
||||
if (destination.Length < CryptoSpec.AadEncodedLength)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Destination must be at least {CryptoSpec.AadEncodedLength} bytes.",
|
||||
nameof(destination));
|
||||
}
|
||||
|
||||
if (Purpose == CryptoSpec.AadPurpose.Unspecified)
|
||||
{
|
||||
throw new InvalidOperationException("AAD purpose must be specified.");
|
||||
}
|
||||
|
||||
var buffer = destination[..CryptoSpec.AadEncodedLength];
|
||||
buffer.Clear();
|
||||
|
||||
CryptoSpec.AadMagic.CopyTo(buffer);
|
||||
buffer[OffsetAadVersion] = AadVersion;
|
||||
buffer[OffsetPurpose] = (byte)Purpose;
|
||||
buffer[OffsetResourceType] = (byte)ResourceType;
|
||||
|
||||
if (!ResourceId.TryWriteBytes(buffer[OffsetResourceId..], bigEndian: true, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to write resource id.");
|
||||
}
|
||||
|
||||
if (!KeyId.TryWriteBytes(buffer[OffsetKeyId..], bigEndian: true, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to write key id.");
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buffer[OffsetKeyGeneration..], KeyGeneration);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buffer[OffsetItemVersion..], ItemVersion);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buffer[OffsetSchemaVersion..], SchemaVersion);
|
||||
|
||||
// Trailing 14 reserved bytes stay zero from the Clear above.
|
||||
}
|
||||
|
||||
/// <summary>Returns the canonical encoding as a new array. Prefer the span overload.</summary>
|
||||
public byte[] ToCanonicalEncoding()
|
||||
{
|
||||
var buffer = new byte[CryptoSpec.AadEncodedLength];
|
||||
WriteCanonicalEncoding(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>Computes the AAD: SHA-256 over the canonical encoding.</summary>
|
||||
public void ComputeAad(Span<byte> destination)
|
||||
{
|
||||
Span<byte> encoded = stackalloc byte[CryptoSpec.AadEncodedLength];
|
||||
WriteCanonicalEncoding(encoded);
|
||||
|
||||
if (!SHA256.TryHashData(encoded, destination, out _))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Destination must be at least {CryptoSpec.DigestSize} bytes.",
|
||||
nameof(destination));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Computes the AAD as a new array.</summary>
|
||||
public byte[] ComputeAad()
|
||||
{
|
||||
var aad = new byte[CryptoSpec.DigestSize];
|
||||
ComputeAad(aad);
|
||||
return aad;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// An Argon2id parameter set, in units that cannot be misread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This type exists for one reason: <c>NSec.Cryptography.Argon2Parameters.MemorySize</c> is in
|
||||
/// <b>kibibytes</b>, not bytes. Passing bytes silently produces either a catastrophically weak
|
||||
/// KDF or an impossible allocation — for a 256 MiB intent, <c>268435456</c> asks for 256 GiB,
|
||||
/// while <c>262144</c> interpreted as bytes is 256 KiB and cracks in milliseconds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Callers therefore never supply a raw memory figure. See docs/crypto.md §2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Parallelism is pinned to 1 because libsodium's Argon2id supports only <c>p=1</c>. Memory
|
||||
/// cost compensates: the default of 256 MiB at four passes is far above OWASP's 19 MiB/2-pass
|
||||
/// floor, and measured about 323 ms on a fast desktop.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record Argon2Profile
|
||||
{
|
||||
private const int KibibytesPerMebibyte = 1024;
|
||||
|
||||
private Argon2Profile(int memoryMebibytes, int passes)
|
||||
{
|
||||
if (memoryMebibytes is < 8 or > 4096)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(memoryMebibytes),
|
||||
memoryMebibytes,
|
||||
"Memory must be between 8 and 4096 MiB.");
|
||||
}
|
||||
|
||||
if (passes is < 1 or > 16)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(passes), passes, "Passes must be 1 to 16.");
|
||||
}
|
||||
|
||||
MemoryMebibytes = memoryMebibytes;
|
||||
Passes = passes;
|
||||
}
|
||||
|
||||
/// <summary>Memory cost, in mebibytes.</summary>
|
||||
public int MemoryMebibytes { get; }
|
||||
|
||||
/// <summary>Number of passes over memory.</summary>
|
||||
public int Passes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Degree of parallelism. Always 1; libsodium's Argon2id supports no other value, so this
|
||||
/// is a property of the algorithm rather than of a profile.
|
||||
/// </summary>
|
||||
public static int Parallelism => 1;
|
||||
|
||||
/// <summary>Default for deriving the master key from a vault passphrase.</summary>
|
||||
public static Argon2Profile PassphraseDefault { get; } = new(256, 4);
|
||||
|
||||
/// <summary>Reduced profile for low-powered devices. Still well above the OWASP floor.</summary>
|
||||
public static Argon2Profile PassphraseReduced { get; } = new(128, 3);
|
||||
|
||||
/// <summary>Raised profile for users who accept a slower unlock.</summary>
|
||||
public static Argon2Profile PassphraseHigh { get; } = new(512, 4);
|
||||
|
||||
/// <summary>
|
||||
/// Profile for 128-bit random secrets — recovery codes and invite secrets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// KDF hardening is nearly irrelevant for a full-entropy random secret; this is
|
||||
/// anti-nuisance only, not a defence against a serious offline attack.
|
||||
/// </remarks>
|
||||
public static Argon2Profile RandomSecret { get; } = new(64, 3);
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs a profile from stored parameters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// KDF parameters are stored in plaintext per wrap row so that raising them later is a
|
||||
/// per-user, unlock-time migration rather than a breaking change, and so an older client
|
||||
/// can still open its own wrap.
|
||||
/// </remarks>
|
||||
/// <param name="memoryKibibytes">Stored memory cost, in kibibytes.</param>
|
||||
/// <param name="passes">Stored pass count.</param>
|
||||
/// <param name="parallelism">Stored parallelism. Must be 1.</param>
|
||||
public static Argon2Profile FromStoredParameters(int memoryKibibytes, int passes, int parallelism)
|
||||
{
|
||||
if (parallelism != 1)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Argon2id parallelism {parallelism} is not supported; libsodium implements only p=1.");
|
||||
}
|
||||
|
||||
if (memoryKibibytes % KibibytesPerMebibyte != 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(memoryKibibytes),
|
||||
memoryKibibytes,
|
||||
"Stored memory cost must be a whole number of mebibytes.");
|
||||
}
|
||||
|
||||
return new Argon2Profile(memoryKibibytes / KibibytesPerMebibyte, passes);
|
||||
}
|
||||
|
||||
/// <summary>Memory cost in kibibytes, as persisted and as NSec expects it.</summary>
|
||||
public int MemoryKibibytes => MemoryMebibytes * KibibytesPerMebibyte;
|
||||
|
||||
/// <summary>Builds the NSec algorithm instance for this profile.</summary>
|
||||
public PasswordBasedKeyDerivationAlgorithm CreateAlgorithm() =>
|
||||
PasswordBasedKeyDerivationAlgorithm.Argon2id(new Argon2Parameters
|
||||
{
|
||||
// MemorySize is KiB. This single line is the reason this type exists.
|
||||
MemorySize = MemoryKibibytes,
|
||||
NumberOfPasses = Passes,
|
||||
DegreeOfParallelism = Parallelism,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSec.Cryptography" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Crypto.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Security.Cryptography;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// The DSH1 operations: symmetric content encryption and anonymous-sender key wrapping.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// docs/crypto.md is normative. Every operation takes an <see cref="AadDescriptor"/> rather
|
||||
/// than a raw AAD, so a caller cannot omit the binding that stops a server relocating
|
||||
/// ciphertext between rows or key generations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Decryption returns <see langword="null"/> on authentication failure rather than throwing.
|
||||
/// Ciphertext arrives from a server that is explicitly not trusted, so a failed tag is an
|
||||
/// expected outcome to be handled, not an exceptional one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DshCrypto
|
||||
{
|
||||
private static AeadAlgorithm Aead => AeadAlgorithm.XChaCha20Poly1305;
|
||||
|
||||
private static KeyAgreementAlgorithm Agreement => KeyAgreementAlgorithm.X25519;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts under a known symmetric key, producing a complete DSH1 envelope
|
||||
/// (<see cref="CryptoSpec.AlgorithmId.XChaCha20Poly1305"/>).
|
||||
/// </summary>
|
||||
/// <param name="key">32-byte content key.</param>
|
||||
/// <param name="plaintext">Data to protect.</param>
|
||||
/// <param name="descriptor">Identifies the row this ciphertext belongs to.</param>
|
||||
public static byte[] Seal(ReadOnlySpan<byte> key, ReadOnlySpan<byte> plaintext, in AadDescriptor descriptor)
|
||||
{
|
||||
RequireSymmetricKey(key);
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
// A 192-bit nonce is why a random nonce per message is safe with no counter to track.
|
||||
Span<byte> nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey);
|
||||
var ciphertext = Aead.Encrypt(aeadKey, nonce, aad, plaintext);
|
||||
|
||||
return DshEnvelope.Write(CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an envelope produced by <see cref="Seal"/>.
|
||||
/// </summary>
|
||||
/// <returns>The plaintext, or <see langword="null"/> if the envelope is malformed, uses
|
||||
/// another construction, or fails authentication under this descriptor.</returns>
|
||||
public static byte[]? Open(ReadOnlySpan<byte> key, ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
|
||||
{
|
||||
RequireSymmetricKey(key);
|
||||
|
||||
if (!DshEnvelope.TryRead(envelope, out var view))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (view.Algorithm != CryptoSpec.AlgorithmId.XChaCha20Poly1305)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
using var aeadKey = Key.Import(Aead, key, KeyBlobFormat.RawSymmetricKey);
|
||||
return Aead.Decrypt(aeadKey, view.Nonce, aad, view.Ciphertext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seals to a recipient's X25519 public key, producing a
|
||||
/// <see cref="CryptoSpec.AlgorithmId.SealToX25519"/> envelope. See docs/crypto.md §6.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Anonymous-sender by construction: this proves nothing about who created the envelope.
|
||||
/// Callers that need attribution — grants, in particular — must additionally attach a
|
||||
/// detached Ed25519 signature, or a server could fabricate a grant undetectably.
|
||||
/// </remarks>
|
||||
public static byte[] SealTo(
|
||||
ReadOnlySpan<byte> recipientPublicKey,
|
||||
ReadOnlySpan<byte> plaintext,
|
||||
in AadDescriptor descriptor)
|
||||
{
|
||||
RequirePublicKey(recipientPublicKey);
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
var recipient = PublicKey.Import(Agreement, recipientPublicKey, KeyBlobFormat.RawPublicKey);
|
||||
|
||||
// The ephemeral key must be exportable: its public half goes into the envelope.
|
||||
using var ephemeral = Key.Create(
|
||||
Agreement,
|
||||
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
||||
|
||||
var ephemeralPublic = ephemeral.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
using var shared = Agreement.Agree(ephemeral, recipient)
|
||||
?? throw new CryptographicException("X25519 agreement failed; the recipient key is invalid.");
|
||||
|
||||
Span<byte> derived = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
DeriveSealKey(shared, ephemeralPublic, recipientPublicKey, aad, derived);
|
||||
|
||||
Span<byte> nonce = stackalloc byte[DshEnvelope.XChaChaNonceSize];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
|
||||
using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey);
|
||||
var ciphertext = Aead.Encrypt(contentKey, nonce, aad, plaintext);
|
||||
|
||||
CryptographicOperations.ZeroMemory(derived);
|
||||
|
||||
return DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.SealToX25519,
|
||||
nonce,
|
||||
ciphertext,
|
||||
ephemeralPublic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an envelope produced by <see cref="SealTo"/> using the recipient's private key.
|
||||
/// </summary>
|
||||
/// <returns>The plaintext, or <see langword="null"/> on any failure.</returns>
|
||||
public static byte[]? OpenSealed(Key recipientKey, ReadOnlySpan<byte> envelope, in AadDescriptor descriptor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(recipientKey);
|
||||
|
||||
if (!DshEnvelope.TryRead(envelope, out var view))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (view.Algorithm != CryptoSpec.AlgorithmId.SealToX25519)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> aad = stackalloc byte[CryptoSpec.DigestSize];
|
||||
descriptor.ComputeAad(aad);
|
||||
|
||||
PublicKey ephemeralPublic;
|
||||
try
|
||||
{
|
||||
ephemeralPublic = PublicKey.Import(Agreement, view.EphemeralPublicKey, KeyBlobFormat.RawPublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var shared = Agreement.Agree(recipientKey, ephemeralPublic);
|
||||
if (shared is null)
|
||||
{
|
||||
// All-zero agreement: a low-order or otherwise invalid ephemeral key.
|
||||
return null;
|
||||
}
|
||||
|
||||
var recipientPublic = recipientKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
Span<byte> derived = stackalloc byte[CryptoSpec.SymmetricKeySize];
|
||||
DeriveSealKey(shared, view.EphemeralPublicKey, recipientPublic, aad, derived);
|
||||
|
||||
using var contentKey = Key.Import(Aead, derived, KeyBlobFormat.RawSymmetricKey);
|
||||
var plaintext = Aead.Decrypt(contentKey, view.Nonce, aad, view.Ciphertext);
|
||||
|
||||
CryptographicOperations.ZeroMemory(derived);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes an identity fingerprint over a user's two public keys. See docs/crypto.md §8.
|
||||
/// </summary>
|
||||
public static byte[] ComputeFingerprint(
|
||||
ReadOnlySpan<byte> x25519PublicKey,
|
||||
ReadOnlySpan<byte> ed25519PublicKey)
|
||||
{
|
||||
RequirePublicKey(x25519PublicKey);
|
||||
RequirePublicKey(ed25519PublicKey);
|
||||
|
||||
var label = CryptoSpec.DerivationLabels.Fingerprint;
|
||||
|
||||
Span<byte> input = stackalloc byte[label.Length + (CryptoSpec.PublicKeySize * 2)];
|
||||
label.CopyTo(input);
|
||||
x25519PublicKey.CopyTo(input[label.Length..]);
|
||||
ed25519PublicKey.CopyTo(input[(label.Length + CryptoSpec.PublicKeySize)..]);
|
||||
|
||||
var fingerprint = new byte[CryptoSpec.DigestSize];
|
||||
SHA256.HashData(input, fingerprint);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HKDF-SHA256 over the X25519 shared secret, salted with both public keys and bound to
|
||||
/// the AAD. Specified rather than reusing libsodium's sealed box, whose KDF covers only the
|
||||
/// key pair and would not carry the AAD binding.
|
||||
/// </summary>
|
||||
private static void DeriveSealKey(
|
||||
SharedSecret shared,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey,
|
||||
ReadOnlySpan<byte> recipientPublicKey,
|
||||
ReadOnlySpan<byte> aad,
|
||||
Span<byte> destination)
|
||||
{
|
||||
Span<byte> salt = stackalloc byte[CryptoSpec.PublicKeySize * 2];
|
||||
ephemeralPublicKey.CopyTo(salt);
|
||||
recipientPublicKey.CopyTo(salt[CryptoSpec.PublicKeySize..]);
|
||||
|
||||
var prefix = CryptoSpec.DerivationLabels.SealTo;
|
||||
Span<byte> info = stackalloc byte[prefix.Length + aad.Length];
|
||||
prefix.CopyTo(info);
|
||||
aad.CopyTo(info[prefix.Length..]);
|
||||
|
||||
KeyDerivationAlgorithm.HkdfSha256.DeriveBytes(shared, salt, info, destination);
|
||||
}
|
||||
|
||||
private static void RequireSymmetricKey(ReadOnlySpan<byte> key)
|
||||
{
|
||||
if (key.Length != CryptoSpec.SymmetricKeySize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Key must be {CryptoSpec.SymmetricKeySize} bytes, got {key.Length}.",
|
||||
nameof(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequirePublicKey(ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (publicKey.Length != CryptoSpec.PublicKeySize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Public key must be {CryptoSpec.PublicKeySize} bytes, got {publicKey.Length}.",
|
||||
nameof(publicKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// Framing of the DSH1 envelope. See docs/crypto.md §5, which is normative.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Layout, all integers big-endian:
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// offset size field
|
||||
/// 0 4 magic "DSH1"
|
||||
/// 4 1 alg_id
|
||||
/// 5 1 flags reserved, must be zero
|
||||
/// [alg_id = 3 only]
|
||||
/// 6 32 ephemeral_pk
|
||||
/// — — nonce 24 bytes for alg 1 and 3, 12 for alg 2
|
||||
/// — n ciphertext including the trailing 16-byte tag
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// This type does framing only; it performs no cryptography. Readers reject unknown
|
||||
/// algorithms and any non-zero flag bit, so an envelope that is not fully understood fails
|
||||
/// closed rather than being partially interpreted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DshEnvelope
|
||||
{
|
||||
/// <summary>Bytes before the algorithm-specific portion.</summary>
|
||||
public const int HeaderSize = 6;
|
||||
|
||||
private const int OffsetAlgorithm = 4;
|
||||
private const int OffsetFlags = 5;
|
||||
private const int OffsetEphemeralPublicKey = 6;
|
||||
|
||||
/// <summary>Nonce length for XChaCha20-Poly1305 and SealTo.</summary>
|
||||
public const int XChaChaNonceSize = 24;
|
||||
|
||||
/// <summary>Nonce length for AES-256-GCM.</summary>
|
||||
public const int AesGcmNonceSize = 12;
|
||||
|
||||
/// <summary>Returns the nonce length used by an algorithm.</summary>
|
||||
public static int NonceSizeFor(CryptoSpec.AlgorithmId algorithm) => algorithm switch
|
||||
{
|
||||
CryptoSpec.AlgorithmId.XChaCha20Poly1305 => XChaChaNonceSize,
|
||||
CryptoSpec.AlgorithmId.SealToX25519 => XChaChaNonceSize,
|
||||
CryptoSpec.AlgorithmId.Aes256Gcm => AesGcmNonceSize,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Unknown algorithm."),
|
||||
};
|
||||
|
||||
/// <summary>True when the algorithm carries an ephemeral public key in its header.</summary>
|
||||
public static bool HasEphemeralPublicKey(CryptoSpec.AlgorithmId algorithm) =>
|
||||
algorithm == CryptoSpec.AlgorithmId.SealToX25519;
|
||||
|
||||
/// <summary>Total prefix length before the ciphertext for a given algorithm.</summary>
|
||||
public static int PrefixSizeFor(CryptoSpec.AlgorithmId algorithm) =>
|
||||
HeaderSize
|
||||
+ (HasEphemeralPublicKey(algorithm) ? CryptoSpec.PublicKeySize : 0)
|
||||
+ NonceSizeFor(algorithm);
|
||||
|
||||
/// <summary>Smallest legal envelope for an algorithm: prefix plus a bare tag.</summary>
|
||||
public static int MinimumSizeFor(CryptoSpec.AlgorithmId algorithm) =>
|
||||
PrefixSizeFor(algorithm) + CryptoSpec.TagSize;
|
||||
|
||||
/// <summary>Exact size of an envelope for an algorithm and ciphertext length.</summary>
|
||||
public static int SizeFor(CryptoSpec.AlgorithmId algorithm, int ciphertextLength) =>
|
||||
PrefixSizeFor(algorithm) + ciphertextLength;
|
||||
|
||||
/// <summary>
|
||||
/// Writes an envelope.
|
||||
/// </summary>
|
||||
/// <param name="destination">Buffer receiving the envelope.</param>
|
||||
/// <param name="algorithm">Construction used.</param>
|
||||
/// <param name="nonce">Nonce, whose length must match the algorithm.</param>
|
||||
/// <param name="ciphertext">Ciphertext including its trailing tag.</param>
|
||||
/// <param name="ephemeralPublicKey">
|
||||
/// Ephemeral X25519 public key, required for <see cref="CryptoSpec.AlgorithmId.SealToX25519"/>
|
||||
/// and forbidden otherwise.
|
||||
/// </param>
|
||||
/// <returns>Number of bytes written.</returns>
|
||||
public static int Write(
|
||||
Span<byte> destination,
|
||||
CryptoSpec.AlgorithmId algorithm,
|
||||
ReadOnlySpan<byte> nonce,
|
||||
ReadOnlySpan<byte> ciphertext,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey = default)
|
||||
{
|
||||
var expectedNonce = NonceSizeFor(algorithm);
|
||||
if (nonce.Length != expectedNonce)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{algorithm} requires a {expectedNonce}-byte nonce, got {nonce.Length}.",
|
||||
nameof(nonce));
|
||||
}
|
||||
|
||||
var wantsEphemeral = HasEphemeralPublicKey(algorithm);
|
||||
if (wantsEphemeral && ephemeralPublicKey.Length != CryptoSpec.PublicKeySize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{algorithm} requires a {CryptoSpec.PublicKeySize}-byte ephemeral public key.",
|
||||
nameof(ephemeralPublicKey));
|
||||
}
|
||||
|
||||
if (!wantsEphemeral && !ephemeralPublicKey.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{algorithm} does not carry an ephemeral public key.",
|
||||
nameof(ephemeralPublicKey));
|
||||
}
|
||||
|
||||
if (ciphertext.Length < CryptoSpec.TagSize)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Ciphertext must include a {CryptoSpec.TagSize}-byte tag.",
|
||||
nameof(ciphertext));
|
||||
}
|
||||
|
||||
var total = SizeFor(algorithm, ciphertext.Length);
|
||||
if (destination.Length < total)
|
||||
{
|
||||
throw new ArgumentException($"Destination must be at least {total} bytes.", nameof(destination));
|
||||
}
|
||||
|
||||
CryptoSpec.EnvelopeMagic.CopyTo(destination);
|
||||
destination[OffsetAlgorithm] = (byte)algorithm;
|
||||
destination[OffsetFlags] = 0;
|
||||
|
||||
var cursor = HeaderSize;
|
||||
if (wantsEphemeral)
|
||||
{
|
||||
ephemeralPublicKey.CopyTo(destination[cursor..]);
|
||||
cursor += CryptoSpec.PublicKeySize;
|
||||
}
|
||||
|
||||
nonce.CopyTo(destination[cursor..]);
|
||||
cursor += nonce.Length;
|
||||
|
||||
ciphertext.CopyTo(destination[cursor..]);
|
||||
return cursor + ciphertext.Length;
|
||||
}
|
||||
|
||||
/// <summary>Writes an envelope into a new array.</summary>
|
||||
public static byte[] Write(
|
||||
CryptoSpec.AlgorithmId algorithm,
|
||||
ReadOnlySpan<byte> nonce,
|
||||
ReadOnlySpan<byte> ciphertext,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey = default)
|
||||
{
|
||||
var buffer = new byte[SizeFor(algorithm, ciphertext.Length)];
|
||||
Write(buffer, algorithm, nonce, ciphertext, ephemeralPublicKey);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an envelope without copying. Returns false for anything malformed or not fully
|
||||
/// understood, rather than throwing, because envelopes arrive from an untrusted server.
|
||||
/// </summary>
|
||||
public static bool TryRead(ReadOnlySpan<byte> envelope, out DshEnvelopeView view)
|
||||
{
|
||||
view = default;
|
||||
|
||||
if (envelope.Length < HeaderSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!envelope[..CryptoSpec.EnvelopeMagic.Length].SequenceEqual(CryptoSpec.EnvelopeMagic))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject unknown flag bits: an envelope we do not fully understand must fail closed.
|
||||
if (envelope[OffsetFlags] != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var algorithmByte = envelope[OffsetAlgorithm];
|
||||
if (!Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), algorithmByte))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var algorithm = (CryptoSpec.AlgorithmId)algorithmByte;
|
||||
if (algorithm == CryptoSpec.AlgorithmId.Unspecified)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (envelope.Length < MinimumSizeFor(algorithm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cursor = HeaderSize;
|
||||
var ephemeral = ReadOnlySpan<byte>.Empty;
|
||||
if (HasEphemeralPublicKey(algorithm))
|
||||
{
|
||||
ephemeral = envelope.Slice(cursor, CryptoSpec.PublicKeySize);
|
||||
cursor += CryptoSpec.PublicKeySize;
|
||||
}
|
||||
|
||||
var nonceSize = NonceSizeFor(algorithm);
|
||||
var nonce = envelope.Slice(cursor, nonceSize);
|
||||
cursor += nonceSize;
|
||||
|
||||
view = new DshEnvelopeView(algorithm, ephemeral, nonce, envelope[cursor..]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Crypto;
|
||||
|
||||
/// <summary>
|
||||
/// A parsed view over an envelope's bytes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Holds no copies, so it must not outlive the buffer it was parsed from. Produced by
|
||||
/// <see cref="DshEnvelope.TryRead"/>.
|
||||
/// </remarks>
|
||||
[SuppressMessage(
|
||||
"Performance",
|
||||
"CA1815:Override equals and object equals operator",
|
||||
Justification = "A ref struct over borrowed spans; equality is meaningless and cannot be expressed.")]
|
||||
public readonly ref struct DshEnvelopeView
|
||||
{
|
||||
internal DshEnvelopeView(
|
||||
CryptoSpec.AlgorithmId algorithm,
|
||||
ReadOnlySpan<byte> ephemeralPublicKey,
|
||||
ReadOnlySpan<byte> nonce,
|
||||
ReadOnlySpan<byte> ciphertext)
|
||||
{
|
||||
Algorithm = algorithm;
|
||||
EphemeralPublicKey = ephemeralPublicKey;
|
||||
Nonce = nonce;
|
||||
Ciphertext = ciphertext;
|
||||
}
|
||||
|
||||
/// <summary>Construction the envelope declares.</summary>
|
||||
public CryptoSpec.AlgorithmId Algorithm { get; }
|
||||
|
||||
/// <summary>Ephemeral X25519 public key, empty unless the algorithm carries one.</summary>
|
||||
public ReadOnlySpan<byte> EphemeralPublicKey { get; }
|
||||
|
||||
/// <summary>Nonce.</summary>
|
||||
public ReadOnlySpan<byte> Nonce { get; }
|
||||
|
||||
/// <summary>Ciphertext, including its trailing authentication tag.</summary>
|
||||
public ReadOnlySpan<byte> Ciphertext { get; }
|
||||
}
|
||||
@@ -19,6 +19,21 @@
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "Direct",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical AAD encoding, per docs/crypto.md §4.
|
||||
/// </summary>
|
||||
public sealed class AadDescriptorTests
|
||||
{
|
||||
private static readonly Guid ResourceId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid KeyId = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");
|
||||
|
||||
private static AadDescriptor Sample() => AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceId,
|
||||
KeyId,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3);
|
||||
|
||||
[Fact]
|
||||
public void Encoding_IsExactlySixtyFourBytes()
|
||||
{
|
||||
Sample().ToCanonicalEncoding().Length.ShouldBe(CryptoSpec.AadEncodedLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_StartsWithMagicAndVersions()
|
||||
{
|
||||
var encoded = Sample().ToCanonicalEncoding();
|
||||
|
||||
encoded[..5].ShouldBe("dsh1\n"u8.ToArray());
|
||||
encoded[5].ShouldBe(CryptoSpec.CurrentAadVersion);
|
||||
encoded[6].ShouldBe((byte)CryptoSpec.AadPurpose.ItemPayload);
|
||||
encoded[7].ShouldBe((byte)CryptoSpec.AadResourceType.Credential);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_WritesUuidsInRfc4122ByteOrder()
|
||||
{
|
||||
// Guid.ToByteArray() emits the first three groups little-endian. Using it here would
|
||||
// make our ciphertext undecryptable by any other implementation of this spec, and the
|
||||
// bug would only surface at a cross-implementation boundary.
|
||||
var encoded = Sample().ToCanonicalEncoding();
|
||||
|
||||
encoded[8..24].ShouldBe(ResourceId.ToByteArray(bigEndian: true));
|
||||
encoded[24..40].ShouldBe(KeyId.ToByteArray(bigEndian: true));
|
||||
|
||||
// And prove the mixed-endian form differs, so this test cannot pass vacuously.
|
||||
ResourceId.ToByteArray(bigEndian: true).ShouldNotBe(ResourceId.ToByteArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_WritesIntegersBigEndian()
|
||||
{
|
||||
var encoded = Sample().ToCanonicalEncoding();
|
||||
|
||||
encoded[40..44].ShouldBe(new byte[] { 0, 0, 0, 7 }); // keyGeneration
|
||||
encoded[44..48].ShouldBe(new byte[] { 0, 0, 0, 3 }); // itemVersion
|
||||
encoded[48..50].ShouldBe(new byte[] { 0, 1 }); // schemaVersion
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_LeavesReservedBytesZero()
|
||||
{
|
||||
Sample().ToCanonicalEncoding()[50..64].ShouldAllBe(b => b == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encoding_IsDeterministic()
|
||||
{
|
||||
Sample().ToCanonicalEncoding().ShouldBe(Sample().ToCanonicalEncoding());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aad_IsSha256OfTheCanonicalEncoding()
|
||||
{
|
||||
var descriptor = Sample();
|
||||
|
||||
descriptor.ComputeAad().ShouldBe(
|
||||
System.Security.Cryptography.SHA256.HashData(descriptor.ToCanonicalEncoding()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnspecifiedPurpose_IsRejected()
|
||||
{
|
||||
var descriptor = AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.Unspecified,
|
||||
CryptoSpec.AadResourceType.Host,
|
||||
ResourceId);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => descriptor.ToCanonicalEncoding());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortDestination_IsRejected()
|
||||
{
|
||||
var descriptor = Sample();
|
||||
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
{
|
||||
var tooSmall = new byte[CryptoSpec.AadEncodedLength - 1];
|
||||
descriptor.WriteCanonicalEncoding(tooSmall);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Each field must change the AAD. If one did not, the corresponding substitution attack
|
||||
/// in docs/crypto.md §4.4 would succeed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryField_ChangesTheAad()
|
||||
{
|
||||
var baseline = Sample();
|
||||
var baselineAad = baseline.ComputeAad();
|
||||
|
||||
var variants = new (string Field, AadDescriptor Descriptor)[]
|
||||
{
|
||||
("purpose", baseline with { Purpose = CryptoSpec.AadPurpose.ItemMetadata }),
|
||||
("resourceType", baseline with { ResourceType = CryptoSpec.AadResourceType.Host }),
|
||||
("resourceId", baseline with { ResourceId = Guid.Parse("0192f0c8-dead-7bee-8fee-000000000001") }),
|
||||
("keyId", baseline with { KeyId = Guid.Empty }),
|
||||
("keyGeneration", baseline with { KeyGeneration = 8 }),
|
||||
("itemVersion", baseline with { ItemVersion = 4 }),
|
||||
("aadVersion", baseline with { AadVersion = 2 }),
|
||||
("schemaVersion", baseline with { SchemaVersion = 2 }),
|
||||
};
|
||||
|
||||
foreach (var (field, descriptor) in variants)
|
||||
{
|
||||
descriptor.ComputeAad().ShouldNotBe(baselineAad, $"changing {field} must change the AAD");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,48 @@ namespace DodoSSH.Crypto.Tests;
|
||||
/// Pins the specification constants that are written into stored data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These are not busywork. The envelope magic and AAD version are persisted in every
|
||||
/// ciphertext row, and only clients can re-encrypt: if one of these changes without a
|
||||
/// deliberate migration path, existing vaults stop decrypting and the server cannot help.
|
||||
/// 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.ShouldBe("DSH1");
|
||||
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((short)1);
|
||||
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]
|
||||
@@ -37,9 +62,76 @@ public sealed class CryptoSpecTests
|
||||
[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.
|
||||
// AlgorithmId is byte-backed, matching the single alg_id byte in the envelope.
|
||||
// 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)]
|
||||
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());
|
||||
CryptoSpec.DerivationLabels.LocalCache.ToArray()
|
||||
.ShouldBe("dsh1/localcache/v1"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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,15 @@
|
||||
<ProjectReference Include="../../src/DodoSSH.Crypto/DodoSSH.Crypto.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
Copied to the output directory and read from there. Resolving it from the source tree
|
||||
via [CallerFilePath] does not work: ContinuousIntegrationBuild enables deterministic
|
||||
source paths, which rewrites caller paths to /_/... and breaks only in CI.
|
||||
-->
|
||||
<Content Include="../fixtures/crypto/vectors.json"
|
||||
Link="fixtures/crypto/vectors.json"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Crypto;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip behaviour and, more importantly, the negative cases from docs/crypto.md §4.4.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The negative tests are the point of this file. They are the executable form of the claim
|
||||
/// that a server holding every ciphertext and every plaintext column still cannot relocate,
|
||||
/// roll back, replay or repurpose a blob.
|
||||
/// </remarks>
|
||||
public sealed class DshCryptoTests
|
||||
{
|
||||
private static readonly Guid CredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid OtherCredentialId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
|
||||
|
||||
private static byte[] NewKey() => RandomNumberGenerator.GetBytes(CryptoSpec.SymmetricKeySize);
|
||||
|
||||
private static AadDescriptor Payload(Guid id, uint generation = 1, uint version = 1) =>
|
||||
AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
id,
|
||||
keyGeneration: generation,
|
||||
itemVersion: version);
|
||||
|
||||
[Fact]
|
||||
public void Seal_RoundTrips()
|
||||
{
|
||||
var key = NewKey();
|
||||
var plaintext = "correct horse battery staple"u8.ToArray();
|
||||
var descriptor = Payload(CredentialId);
|
||||
|
||||
var envelope = DshCrypto.Seal(key, plaintext, descriptor);
|
||||
|
||||
DshCrypto.Open(key, envelope, descriptor).ShouldBe(plaintext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seal_ProducesAWellFormedEnvelope()
|
||||
{
|
||||
var envelope = DshCrypto.Seal(NewKey(), "x"u8, Payload(CredentialId));
|
||||
|
||||
DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue();
|
||||
view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.XChaCha20Poly1305);
|
||||
view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize);
|
||||
view.EphemeralPublicKey.IsEmpty.ShouldBeTrue();
|
||||
envelope[..4].ShouldBe("DSH1"u8.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seal_UsesAFreshNoncePerCall()
|
||||
{
|
||||
var key = NewKey();
|
||||
var descriptor = Payload(CredentialId);
|
||||
|
||||
var first = DshCrypto.Seal(key, "same"u8, descriptor);
|
||||
var second = DshCrypto.Seal(key, "same"u8, descriptor);
|
||||
|
||||
first.ShouldNotBe(second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_RejectsTheWrongKey()
|
||||
{
|
||||
var envelope = DshCrypto.Seal(NewKey(), "secret"u8, Payload(CredentialId));
|
||||
|
||||
DshCrypto.Open(NewKey(), envelope, Payload(CredentialId)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_RejectsATamperedCiphertext()
|
||||
{
|
||||
var key = NewKey();
|
||||
var descriptor = Payload(CredentialId);
|
||||
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
|
||||
|
||||
envelope[^1] ^= 0x01;
|
||||
|
||||
DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_RejectsANonZeroFlagByte()
|
||||
{
|
||||
var key = NewKey();
|
||||
var descriptor = Payload(CredentialId);
|
||||
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
|
||||
|
||||
// Fail closed on an envelope we do not fully understand.
|
||||
envelope[5] = 0x01;
|
||||
|
||||
DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DSH0")]
|
||||
[InlineData("XSH1")]
|
||||
public void Open_RejectsABadMagic(string magic)
|
||||
{
|
||||
var key = NewKey();
|
||||
var descriptor = Payload(CredentialId);
|
||||
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
|
||||
|
||||
System.Text.Encoding.ASCII.GetBytes(magic).CopyTo(envelope, 0);
|
||||
|
||||
DshCrypto.Open(key, envelope, descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_RejectsATruncatedEnvelope()
|
||||
{
|
||||
var key = NewKey();
|
||||
var descriptor = Payload(CredentialId);
|
||||
var envelope = DshCrypto.Seal(key, "secret"u8, descriptor);
|
||||
|
||||
DshCrypto.Open(key, envelope.AsSpan(0, envelope.Length / 2), descriptor).ShouldBeNull();
|
||||
DshCrypto.Open(key, [], descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- docs/crypto.md §4.4: what a malicious server cannot do ----
|
||||
|
||||
[Fact]
|
||||
public void Server_CannotMoveCiphertextToAnotherResource()
|
||||
{
|
||||
var key = NewKey();
|
||||
var envelope = DshCrypto.Seal(key, "host-a password"u8, Payload(CredentialId));
|
||||
|
||||
// Same vault key, same everything, different row.
|
||||
DshCrypto.Open(key, envelope, Payload(OtherCredentialId)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_CannotRollBackAKeyGeneration()
|
||||
{
|
||||
var key = NewKey();
|
||||
var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId, generation: 5));
|
||||
|
||||
DshCrypto.Open(key, envelope, Payload(CredentialId, generation: 4)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_CannotRollBackAnItemVersion()
|
||||
{
|
||||
var key = NewKey();
|
||||
var envelope = DshCrypto.Seal(key, "v2 secret"u8, Payload(CredentialId, version: 2));
|
||||
|
||||
DshCrypto.Open(key, envelope, Payload(CredentialId, version: 1)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_CannotRepurposeAPayloadAsMetadata()
|
||||
{
|
||||
var key = NewKey();
|
||||
var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId));
|
||||
|
||||
var asMetadata = AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemMetadata,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
CredentialId,
|
||||
itemVersion: 1);
|
||||
|
||||
DshCrypto.Open(key, envelope, asMetadata).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_CannotRepurposeAcrossResourceTypes()
|
||||
{
|
||||
var key = NewKey();
|
||||
var envelope = DshCrypto.Seal(key, "secret"u8, Payload(CredentialId));
|
||||
|
||||
var asHost = AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Host,
|
||||
CredentialId,
|
||||
itemVersion: 1);
|
||||
|
||||
DshCrypto.Open(key, envelope, asHost).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- SealTo ----
|
||||
|
||||
private static Key NewAgreementKey() => Key.Create(
|
||||
KeyAgreementAlgorithm.X25519,
|
||||
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
|
||||
|
||||
private static AadDescriptor Grant(Guid vaultId, uint generation = 1) => AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.VaultKeyGrant,
|
||||
CryptoSpec.AadResourceType.Vault,
|
||||
vaultId,
|
||||
keyGeneration: generation);
|
||||
|
||||
[Fact]
|
||||
public void SealTo_RoundTrips()
|
||||
{
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
using var recipient = NewAgreementKey();
|
||||
var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
var vaultKey = NewKey();
|
||||
var descriptor = Grant(vaultId);
|
||||
|
||||
var envelope = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);
|
||||
|
||||
DshCrypto.OpenSealed(recipient, envelope, descriptor).ShouldBe(vaultKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealTo_ProducesAWellFormedEnvelopeCarryingAnEphemeralKey()
|
||||
{
|
||||
using var recipient = NewAgreementKey();
|
||||
var envelope = DshCrypto.SealTo(
|
||||
recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey),
|
||||
NewKey(),
|
||||
Grant(Guid.CreateVersion7()));
|
||||
|
||||
DshEnvelope.TryRead(envelope, out var view).ShouldBeTrue();
|
||||
view.Algorithm.ShouldBe(CryptoSpec.AlgorithmId.SealToX25519);
|
||||
view.EphemeralPublicKey.Length.ShouldBe(CryptoSpec.PublicKeySize);
|
||||
view.Nonce.Length.ShouldBe(DshEnvelope.XChaChaNonceSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealTo_IsNotOpenableByAnotherRecipient()
|
||||
{
|
||||
using var intended = NewAgreementKey();
|
||||
using var attacker = NewAgreementKey();
|
||||
var descriptor = Grant(Guid.CreateVersion7());
|
||||
|
||||
var envelope = DshCrypto.SealTo(
|
||||
intended.PublicKey.Export(KeyBlobFormat.RawPublicKey),
|
||||
NewKey(),
|
||||
descriptor);
|
||||
|
||||
DshCrypto.OpenSealed(attacker, envelope, descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealTo_IsBoundToItsVaultAndGeneration()
|
||||
{
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
using var recipient = NewAgreementKey();
|
||||
var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
var envelope = DshCrypto.SealTo(recipientPublic, NewKey(), Grant(vaultId, generation: 3));
|
||||
|
||||
// A revoked grant from an earlier generation must not be replayable.
|
||||
DshCrypto.OpenSealed(recipient, envelope, Grant(vaultId, generation: 2)).ShouldBeNull();
|
||||
DshCrypto.OpenSealed(recipient, envelope, Grant(Guid.CreateVersion7(), generation: 3)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealTo_UsesAFreshEphemeralKeyPerCall()
|
||||
{
|
||||
using var recipient = NewAgreementKey();
|
||||
var recipientPublic = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
var descriptor = Grant(Guid.CreateVersion7());
|
||||
var vaultKey = NewKey();
|
||||
|
||||
var first = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);
|
||||
var second = DshCrypto.SealTo(recipientPublic, vaultKey, descriptor);
|
||||
|
||||
DshEnvelope.TryRead(first, out var a).ShouldBeTrue();
|
||||
DshEnvelope.TryRead(second, out var b).ShouldBeTrue();
|
||||
|
||||
a.EphemeralPublicKey.SequenceEqual(b.EphemeralPublicKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenSealed_RejectsASymmetricEnvelope()
|
||||
{
|
||||
// Cross-construction confusion: a symmetric envelope must not be accepted here.
|
||||
using var recipient = NewAgreementKey();
|
||||
var descriptor = Grant(Guid.CreateVersion7());
|
||||
var symmetric = DshCrypto.Seal(NewKey(), "secret"u8, descriptor);
|
||||
|
||||
DshCrypto.OpenSealed(recipient, symmetric, descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Open_RejectsASealedEnvelope()
|
||||
{
|
||||
using var recipient = NewAgreementKey();
|
||||
var descriptor = Grant(Guid.CreateVersion7());
|
||||
var sealedEnvelope = DshCrypto.SealTo(
|
||||
recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey),
|
||||
NewKey(),
|
||||
descriptor);
|
||||
|
||||
DshCrypto.Open(NewKey(), sealedEnvelope, descriptor).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Fingerprints ----
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_IsStableAndOrderSensitive()
|
||||
{
|
||||
var x25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);
|
||||
var ed25519 = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);
|
||||
|
||||
var fingerprint = DshCrypto.ComputeFingerprint(x25519, ed25519);
|
||||
|
||||
fingerprint.Length.ShouldBe(CryptoSpec.DigestSize);
|
||||
fingerprint.ShouldBe(DshCrypto.ComputeFingerprint(x25519, ed25519));
|
||||
|
||||
// Swapping the keys must change the fingerprint, or the two roles would be conflated.
|
||||
fingerprint.ShouldNotBe(DshCrypto.ComputeFingerprint(ed25519, x25519));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_RejectsWrongSizedKeys()
|
||||
{
|
||||
var valid = RandomNumberGenerator.GetBytes(CryptoSpec.PublicKeySize);
|
||||
|
||||
Should.Throw<ArgumentException>(() => DshCrypto.ComputeFingerprint(new byte[31], valid));
|
||||
Should.Throw<ArgumentException>(() => DshCrypto.ComputeFingerprint(valid, new byte[33]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Seal_RejectsWrongSizedKeys()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => DshCrypto.Seal(new byte[16], "x"u8, Payload(CredentialId)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the committed golden vectors still hold.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the single most important test in the product. The server holds ciphertext and no
|
||||
/// keys, so it can never re-encrypt anything: a change to the envelope layout or to AAD
|
||||
/// derivation that reaches a release makes every existing vault undecryptable, with no
|
||||
/// server-side remedy and no rollback.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure here is never fixed by regenerating the fixture.</b> It means either a genuine
|
||||
/// regression, or an intentional format change — which requires a new
|
||||
/// <c>aadVersion</c>/<c>algId</c> and a client-side lazy re-encrypt-on-write path to exist
|
||||
/// first. See docs/crypto.md §8.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To regenerate deliberately, set <c>DODOSSH_REGENERATE_VECTORS=1</c>. The test rewrites the
|
||||
/// fixture in the source tree and then fails, so the diff has to be reviewed rather than
|
||||
/// silently absorbed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GoldenVectorTests
|
||||
{
|
||||
private const string RegenerateVariable = "DODOSSH_REGENERATE_VECTORS";
|
||||
private const string FixtureRelativePath = "fixtures/crypto/vectors.json";
|
||||
|
||||
[Fact]
|
||||
public void CommittedVectors_MatchCurrentImplementation()
|
||||
{
|
||||
var actual = GoldenVectors.Generate();
|
||||
|
||||
if (string.Equals(Environment.GetEnvironmentVariable(RegenerateVariable), "1", StringComparison.Ordinal))
|
||||
{
|
||||
var sourcePath = ResolveSourceTreeFixturePath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!);
|
||||
File.WriteAllText(sourcePath, actual);
|
||||
|
||||
Assert.Fail(
|
||||
$"Regenerated {sourcePath}. Review the diff and unset {RegenerateVariable}. "
|
||||
+ "If the envelope or AAD changed, a version bump and a client migration path are required first.");
|
||||
}
|
||||
|
||||
var expected = File.ReadAllText(OutputFixturePath());
|
||||
|
||||
Normalise(actual).ShouldBe(
|
||||
Normalise(expected),
|
||||
"The DSH1 format or AAD derivation changed. This would make every existing vault "
|
||||
+ "undecryptable. Do not regenerate the fixture to silence this.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fixture_IsCommittedAndNonTrivial()
|
||||
{
|
||||
var content = File.ReadAllText(OutputFixturePath());
|
||||
|
||||
content.Length.ShouldBeGreaterThan(1000);
|
||||
content.ShouldContain("canonicalEncoding");
|
||||
content.ShouldContain("\"specVersion\": 1");
|
||||
}
|
||||
|
||||
private static string Normalise(string json) => json.ReplaceLineEndings("\n").TrimEnd();
|
||||
|
||||
/// <summary>
|
||||
/// The fixture as copied beside the test assembly. Robust under deterministic source paths.
|
||||
/// </summary>
|
||||
private static string OutputFixturePath()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, FixtureRelativePath);
|
||||
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"Golden vector fixture missing at {path}. It should be copied to the output "
|
||||
+ $"directory by the project file. Set {RegenerateVariable}=1 to create it.");
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates the fixture in the source tree by walking up to the solution file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used only when regenerating, which is a developer-local action.
|
||||
/// </remarks>
|
||||
private static string ResolveSourceTreeFixturePath()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DodoSSH.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
if (directory is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not locate the repository root (no DodoSSH.slnx found above "
|
||||
+ $"{AppContext.BaseDirectory}). Regenerate from within the repository.");
|
||||
}
|
||||
|
||||
return Path.Combine(directory.FullName, "tests", FixtureRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using DodoSSH.Crypto;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Produces the deterministic byte-level results of the DSH1 specification.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only deterministic operations belong here. <c>SealTo</c> and signing draw fresh randomness,
|
||||
/// so they are covered by round-trip and negative tests in <see cref="DshCryptoTests"/> instead.
|
||||
/// </remarks>
|
||||
internal static class GoldenVectors
|
||||
{
|
||||
private static readonly Guid ResourceA = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
|
||||
private static readonly Guid ResourceB = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
|
||||
private static readonly Guid KeyIdA = Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc");
|
||||
|
||||
internal static string Generate()
|
||||
{
|
||||
var root = new JsonObject
|
||||
{
|
||||
["_comment"] = "Generated by DodoSSH.Crypto.Tests.GoldenVectors. Normative source: docs/crypto.md.",
|
||||
["_warning"] = "A failing assertion here is a regression or an intentional versioned format change. Do not regenerate to make it pass.",
|
||||
["specVersion"] = 1,
|
||||
["aad"] = BuildAadVectors(),
|
||||
["envelope"] = BuildEnvelopeVectors(),
|
||||
["aead"] = BuildAeadVectors(),
|
||||
["hkdf"] = BuildHkdfVectors(),
|
||||
["argon2id"] = BuildArgon2Vectors(),
|
||||
["fingerprint"] = BuildFingerprintVectors(),
|
||||
};
|
||||
|
||||
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n";
|
||||
}
|
||||
|
||||
private static JsonArray BuildAadVectors()
|
||||
{
|
||||
(string Name, AadDescriptor Descriptor)[] cases =
|
||||
[
|
||||
("item-payload", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceA,
|
||||
KeyIdA,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3)),
|
||||
("item-payload-other-resource", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceB,
|
||||
KeyIdA,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3)),
|
||||
("item-metadata", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemMetadata,
|
||||
CryptoSpec.AadResourceType.Host,
|
||||
ResourceA,
|
||||
keyGeneration: 1,
|
||||
itemVersion: 1)),
|
||||
("vault-key-grant", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.VaultKeyGrant,
|
||||
CryptoSpec.AadResourceType.Vault,
|
||||
ResourceA,
|
||||
keyGeneration: 2)),
|
||||
("user-secret-bundle", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.UserSecretBundle,
|
||||
CryptoSpec.AadResourceType.User,
|
||||
ResourceA)),
|
||||
("all-zero-ids", AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.LocalCache,
|
||||
CryptoSpec.AadResourceType.None,
|
||||
Guid.Empty,
|
||||
keyGeneration: 0)),
|
||||
];
|
||||
|
||||
var array = new JsonArray();
|
||||
foreach (var (name, descriptor) in cases)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["purpose"] = (int)descriptor.Purpose,
|
||||
["resourceType"] = (int)descriptor.ResourceType,
|
||||
["resourceId"] = descriptor.ResourceId.ToString(),
|
||||
["keyId"] = descriptor.KeyId.ToString(),
|
||||
["keyGeneration"] = descriptor.KeyGeneration,
|
||||
["itemVersion"] = descriptor.ItemVersion,
|
||||
["aadVersion"] = descriptor.AadVersion,
|
||||
["schemaVersion"] = descriptor.SchemaVersion,
|
||||
["canonicalEncoding"] = Hex(descriptor.ToCanonicalEncoding()),
|
||||
["aad"] = Hex(descriptor.ComputeAad()),
|
||||
});
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray BuildEnvelopeVectors()
|
||||
{
|
||||
// Framing only, with fixed inputs, so the byte layout of the header is pinned
|
||||
// independently of any AEAD behaviour.
|
||||
var ciphertext = Enumerable.Range(0, 20).Select(i => (byte)i).ToArray();
|
||||
var xchachaNonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0xA0 + i)).ToArray();
|
||||
var gcmNonce = Enumerable.Range(0, DshEnvelope.AesGcmNonceSize).Select(i => (byte)(0xB0 + i)).ToArray();
|
||||
var ephemeral = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0xC0 + i)).ToArray();
|
||||
|
||||
return
|
||||
[
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "xchacha20poly1305",
|
||||
["algId"] = (int)CryptoSpec.AlgorithmId.XChaCha20Poly1305,
|
||||
["nonce"] = Hex(xchachaNonce),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.XChaCha20Poly1305),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.XChaCha20Poly1305, xchachaNonce, ciphertext)),
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "aes256gcm",
|
||||
["algId"] = (int)CryptoSpec.AlgorithmId.Aes256Gcm,
|
||||
["nonce"] = Hex(gcmNonce),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.Aes256Gcm),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.Aes256Gcm, gcmNonce, ciphertext)),
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "sealto-x25519",
|
||||
["algId"] = (int)CryptoSpec.AlgorithmId.SealToX25519,
|
||||
["nonce"] = Hex(xchachaNonce),
|
||||
["ephemeralPublicKey"] = Hex(ephemeral),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["prefixSize"] = DshEnvelope.PrefixSizeFor(CryptoSpec.AlgorithmId.SealToX25519),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.SealToX25519, xchachaNonce, ciphertext, ephemeral)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private static JsonArray BuildAeadVectors()
|
||||
{
|
||||
// Fixed key and nonce, so the AEAD itself is pinned. DshCrypto.Seal draws a random
|
||||
// nonce by design, so the primitive is exercised directly here.
|
||||
var key = Enumerable.Range(0, CryptoSpec.SymmetricKeySize).Select(i => (byte)i).ToArray();
|
||||
var nonce = Enumerable.Range(0, DshEnvelope.XChaChaNonceSize).Select(i => (byte)(0x10 + i)).ToArray();
|
||||
var plaintext = "correct horse battery staple"u8.ToArray();
|
||||
|
||||
var descriptor = AadDescriptor.Create(
|
||||
CryptoSpec.AadPurpose.ItemPayload,
|
||||
CryptoSpec.AadResourceType.Credential,
|
||||
ResourceA,
|
||||
KeyIdA,
|
||||
keyGeneration: 7,
|
||||
itemVersion: 3);
|
||||
|
||||
var aad = descriptor.ComputeAad();
|
||||
|
||||
using var aeadKey = Key.Import(
|
||||
AeadAlgorithm.XChaCha20Poly1305, key, KeyBlobFormat.RawSymmetricKey);
|
||||
var ciphertext = AeadAlgorithm.XChaCha20Poly1305.Encrypt(aeadKey, nonce, aad, plaintext);
|
||||
|
||||
return
|
||||
[
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "xchacha20poly1305-with-canonical-aad",
|
||||
["key"] = Hex(key),
|
||||
["nonce"] = Hex(nonce),
|
||||
["aad"] = Hex(aad),
|
||||
["plaintext"] = Hex(plaintext),
|
||||
["ciphertext"] = Hex(ciphertext),
|
||||
["envelope"] = Hex(DshEnvelope.Write(
|
||||
CryptoSpec.AlgorithmId.XChaCha20Poly1305, nonce, ciphertext)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private static JsonArray BuildHkdfVectors()
|
||||
{
|
||||
var prk = Enumerable.Range(0, 64).Select(i => (byte)i).ToArray();
|
||||
|
||||
(string Name, byte[] Info)[] cases =
|
||||
[
|
||||
("passphrase-kek", CryptoSpec.DerivationLabels.PassphraseKek.ToArray()),
|
||||
("local-cache", CryptoSpec.DerivationLabels.LocalCache.ToArray()),
|
||||
];
|
||||
|
||||
var array = new JsonArray();
|
||||
foreach (var (name, info) in cases)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["algorithm"] = "HKDF-SHA512-Expand",
|
||||
["prk"] = Hex(prk),
|
||||
["info"] = Encoding.UTF8.GetString(info),
|
||||
["outputLength"] = CryptoSpec.SymmetricKeySize,
|
||||
["output"] = Hex(HKDF.Expand(
|
||||
HashAlgorithmName.SHA512, prk, CryptoSpec.SymmetricKeySize, info)),
|
||||
});
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray BuildArgon2Vectors()
|
||||
{
|
||||
var salt = Enumerable.Range(0, CryptoSpec.SaltSize).Select(i => (byte)(0x20 + i)).ToArray();
|
||||
const string Passphrase = "correct horse battery staple";
|
||||
|
||||
var array = new JsonArray();
|
||||
|
||||
// Parameters of every profile are pinned cheaply. Only the smallest profile's output is
|
||||
// computed, to keep the suite fast; the KDF itself is libsodium's, not ours.
|
||||
(string Name, Argon2Profile Profile)[] profiles =
|
||||
[
|
||||
("passphrase-default", Argon2Profile.PassphraseDefault),
|
||||
("passphrase-reduced", Argon2Profile.PassphraseReduced),
|
||||
("passphrase-high", Argon2Profile.PassphraseHigh),
|
||||
("random-secret", Argon2Profile.RandomSecret),
|
||||
];
|
||||
|
||||
foreach (var (name, profile) in profiles)
|
||||
{
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["memoryMebibytes"] = profile.MemoryMebibytes,
|
||||
["memoryKibibytes"] = profile.MemoryKibibytes,
|
||||
["passes"] = profile.Passes,
|
||||
["parallelism"] = Argon2Profile.Parallelism,
|
||||
});
|
||||
}
|
||||
|
||||
array.Add(new JsonObject
|
||||
{
|
||||
["name"] = "random-secret-output",
|
||||
["memoryMebibytes"] = Argon2Profile.RandomSecret.MemoryMebibytes,
|
||||
["memoryKibibytes"] = Argon2Profile.RandomSecret.MemoryKibibytes,
|
||||
["passes"] = Argon2Profile.RandomSecret.Passes,
|
||||
["parallelism"] = Argon2Profile.Parallelism,
|
||||
["passphrase"] = Passphrase,
|
||||
["salt"] = Hex(salt),
|
||||
["outputLength"] = CryptoSpec.SymmetricKeySize,
|
||||
["output"] = Hex(Argon2Profile.RandomSecret
|
||||
.CreateAlgorithm()
|
||||
.DeriveBytes(Passphrase, salt, CryptoSpec.SymmetricKeySize)),
|
||||
});
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
private static JsonArray BuildFingerprintVectors()
|
||||
{
|
||||
var x25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x40 + i)).ToArray();
|
||||
var ed25519 = Enumerable.Range(0, CryptoSpec.PublicKeySize).Select(i => (byte)(0x60 + i)).ToArray();
|
||||
|
||||
return
|
||||
[
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = "identity-fingerprint",
|
||||
["x25519PublicKey"] = Hex(x25519),
|
||||
["ed25519PublicKey"] = Hex(ed25519),
|
||||
["fingerprint"] = Hex(DshCrypto.ComputeFingerprint(x25519, ed25519)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private static string Hex(ReadOnlySpan<byte> value) =>
|
||||
Convert.ToHexString(value).ToLower(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Security.Cryptography;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Crypto.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the primitives docs/crypto.md depends on are actually available and functional on
|
||||
/// this runtime and platform.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not ceremonial. Two concrete risks motivated these:
|
||||
/// the BCL has no X25519 or Ed25519 at all, and <c>ChaCha20Poly1305.IsSupported</c> is false
|
||||
/// on macOS, which is what disqualified the in-box AEAD for a cross-platform client. If any
|
||||
/// of these fail on a target platform, the specification is wrong rather than the code.
|
||||
/// </remarks>
|
||||
public sealed class PrimitiveAvailabilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void X25519_AgreesOnASharedSecret()
|
||||
{
|
||||
var algorithm = KeyAgreementAlgorithm.X25519;
|
||||
var creation = new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport };
|
||||
|
||||
using var alice = Key.Create(algorithm, creation);
|
||||
using var bob = Key.Create(algorithm, creation);
|
||||
|
||||
using var aliceView = algorithm.Agree(alice, bob.PublicKey)!;
|
||||
using var bobView = algorithm.Agree(bob, alice.PublicKey)!;
|
||||
|
||||
var derive = KeyDerivationAlgorithm.HkdfSha256;
|
||||
var fromAlice = derive.DeriveBytes(aliceView, ReadOnlySpan<byte>.Empty, "test"u8, 32);
|
||||
var fromBob = derive.DeriveBytes(bobView, ReadOnlySpan<byte>.Empty, "test"u8, 32);
|
||||
|
||||
fromAlice.ShouldBe(fromBob);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ed25519_SignsAndVerifies()
|
||||
{
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
using var signer = Key.Create(algorithm);
|
||||
|
||||
var message = "grant tuple"u8;
|
||||
var signature = algorithm.Sign(signer, message);
|
||||
|
||||
signature.Length.ShouldBe(64);
|
||||
algorithm.Verify(signer.PublicKey, message, signature).ShouldBeTrue();
|
||||
algorithm.Verify(signer.PublicKey, "tampered"u8, signature).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void XChaCha20Poly1305_RoundTripsAndDetectsAadTampering()
|
||||
{
|
||||
var algorithm = AeadAlgorithm.XChaCha20Poly1305;
|
||||
using var key = Key.Create(algorithm);
|
||||
|
||||
var nonce = RandomNumberGenerator.GetBytes(algorithm.NonceSize);
|
||||
var plaintext = "id_ed25519 private key"u8;
|
||||
|
||||
var ciphertext = algorithm.Encrypt(key, nonce, "aad-a"u8, plaintext);
|
||||
|
||||
algorithm.Decrypt(key, nonce, "aad-a"u8, ciphertext).ShouldBe(plaintext.ToArray());
|
||||
|
||||
// The whole point of binding AAD to row identity: a different AAD must not decrypt.
|
||||
algorithm.Decrypt(key, nonce, "aad-b"u8, ciphertext).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void XChaCha20Poly1305_NonceIs24BytesSoRandomNoncesAreSafe()
|
||||
{
|
||||
// 192-bit nonces are why we can generate one at random per message without tracking
|
||||
// a counter. AES-GCM's 96-bit nonce would not permit that.
|
||||
AeadAlgorithm.XChaCha20Poly1305.NonceSize.ShouldBe(24);
|
||||
AeadAlgorithm.XChaCha20Poly1305.KeySize.ShouldBe(32);
|
||||
AeadAlgorithm.XChaCha20Poly1305.TagSize.ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Argon2id_IsAvailableAndParallelismIsPinnedToOne()
|
||||
{
|
||||
// libsodium's Argon2id implementation only supports p=1. docs/crypto.md compensates
|
||||
// with memory cost instead; this test pins the constraint so it is not forgotten.
|
||||
var algorithm = PasswordBasedKeyDerivationAlgorithm.Argon2id(
|
||||
new Argon2Parameters { DegreeOfParallelism = 1, MemorySize = 1 << 20, NumberOfPasses = 1 });
|
||||
|
||||
var salt = new byte[16];
|
||||
var derived = algorithm.DeriveBytes("correct horse battery staple", salt, 32);
|
||||
|
||||
derived.Length.ShouldBe(32);
|
||||
derived.ShouldNotBe(new byte[32]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Argon2id_IsDeterministicForTheSamePassphraseAndSalt()
|
||||
{
|
||||
var algorithm = PasswordBasedKeyDerivationAlgorithm.Argon2id(
|
||||
new Argon2Parameters { DegreeOfParallelism = 1, MemorySize = 1 << 20, NumberOfPasses = 1 });
|
||||
|
||||
var salt = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
algorithm.DeriveBytes("passphrase", salt, 32)
|
||||
.ShouldBe(algorithm.DeriveBytes("passphrase", salt, 32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HkdfSha512_IsAvailableInTheBcl()
|
||||
{
|
||||
// Subkey derivation from the master key uses the BCL, not NSec: HKDF is fully
|
||||
// supported on every platform.
|
||||
var info = "dsh1/kek/passphrase/v1"u8.ToArray();
|
||||
var okm = HKDF.Expand(HashAlgorithmName.SHA512, new byte[64], 32, info);
|
||||
|
||||
okm.Length.ShouldBe(32);
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,25 @@
|
||||
}
|
||||
},
|
||||
"dodossh.crypto": {
|
||||
"type": "Project"
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+190
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"_comment": "Generated by DodoSSH.Crypto.Tests.GoldenVectors. Normative source: docs/crypto.md.",
|
||||
"_warning": "A failing assertion here is a regression or an intentional versioned format change. Do not regenerate to make it pass.",
|
||||
"specVersion": 1,
|
||||
"aad": [
|
||||
{
|
||||
"name": "item-payload",
|
||||
"purpose": 4,
|
||||
"resourceType": 5,
|
||||
"resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
|
||||
"keyId": "0192f0c8-9999-7aaa-8bbb-cccccccccccc",
|
||||
"keyGeneration": 7,
|
||||
"itemVersion": 3,
|
||||
"aadVersion": 1,
|
||||
"schemaVersion": 1,
|
||||
"canonicalEncoding": "647368310a0104050192f0c81a2b7c3d8e4f5a6b7c8d9e0f0192f0c899997aaa8bbbcccccccccccc000000070000000300010000000000000000000000000000",
|
||||
"aad": "bb106e753e2fd9ac31142889a4356cbf9fc1f8db3777a1921ecbb5481bd4379e"
|
||||
},
|
||||
{
|
||||
"name": "item-payload-other-resource",
|
||||
"purpose": 4,
|
||||
"resourceType": 5,
|
||||
"resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10",
|
||||
"keyId": "0192f0c8-9999-7aaa-8bbb-cccccccccccc",
|
||||
"keyGeneration": 7,
|
||||
"itemVersion": 3,
|
||||
"aadVersion": 1,
|
||||
"schemaVersion": 1,
|
||||
"canonicalEncoding": "647368310a0104050192f0c81a2b7c3d8e4f5a6b7c8d9e100192f0c899997aaa8bbbcccccccccccc000000070000000300010000000000000000000000000000",
|
||||
"aad": "ae87f8da1a55b36286ed103a11fb51145adff18a222557db30422918eba6c29f"
|
||||
},
|
||||
{
|
||||
"name": "item-metadata",
|
||||
"purpose": 5,
|
||||
"resourceType": 4,
|
||||
"resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
|
||||
"keyId": "00000000-0000-0000-0000-000000000000",
|
||||
"keyGeneration": 1,
|
||||
"itemVersion": 1,
|
||||
"aadVersion": 1,
|
||||
"schemaVersion": 1,
|
||||
"canonicalEncoding": "647368310a0105040192f0c81a2b7c3d8e4f5a6b7c8d9e0f00000000000000000000000000000000000000010000000100010000000000000000000000000000",
|
||||
"aad": "cfb042451484a4f484b45e812a2c7667d12592bcb8ec47be5b5ef95c38b1d3ad"
|
||||
},
|
||||
{
|
||||
"name": "vault-key-grant",
|
||||
"purpose": 2,
|
||||
"resourceType": 3,
|
||||
"resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
|
||||
"keyId": "00000000-0000-0000-0000-000000000000",
|
||||
"keyGeneration": 2,
|
||||
"itemVersion": 0,
|
||||
"aadVersion": 1,
|
||||
"schemaVersion": 1,
|
||||
"canonicalEncoding": "647368310a0102030192f0c81a2b7c3d8e4f5a6b7c8d9e0f00000000000000000000000000000000000000020000000000010000000000000000000000000000",
|
||||
"aad": "5c9444daa7f74193b04abefb57d5a49a8782e8e1a7fd97771865f9e038f8c957"
|
||||
},
|
||||
{
|
||||
"name": "user-secret-bundle",
|
||||
"purpose": 1,
|
||||
"resourceType": 1,
|
||||
"resourceId": "0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
|
||||
"keyId": "00000000-0000-0000-0000-000000000000",
|
||||
"keyGeneration": 1,
|
||||
"itemVersion": 0,
|
||||
"aadVersion": 1,
|
||||
"schemaVersion": 1,
|
||||
"canonicalEncoding": "647368310a0101010192f0c81a2b7c3d8e4f5a6b7c8d9e0f00000000000000000000000000000000000000010000000000010000000000000000000000000000",
|
||||
"aad": "9f73034823c49cdfcad4fcc75e67ae22be151a92afed72ab7548097ef5a99f68"
|
||||
},
|
||||
{
|
||||
"name": "all-zero-ids",
|
||||
"purpose": 6,
|
||||
"resourceType": 0,
|
||||
"resourceId": "00000000-0000-0000-0000-000000000000",
|
||||
"keyId": "00000000-0000-0000-0000-000000000000",
|
||||
"keyGeneration": 0,
|
||||
"itemVersion": 0,
|
||||
"aadVersion": 1,
|
||||
"schemaVersion": 1,
|
||||
"canonicalEncoding": "647368310a0106000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000",
|
||||
"aad": "cebc8d57709c0ebe47874c85fc39aa538e17c4202b767673c8636ef181cd1664"
|
||||
}
|
||||
],
|
||||
"envelope": [
|
||||
{
|
||||
"name": "xchacha20poly1305",
|
||||
"algId": 1,
|
||||
"nonce": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7",
|
||||
"ciphertext": "000102030405060708090a0b0c0d0e0f10111213",
|
||||
"prefixSize": 30,
|
||||
"envelope": "445348310100a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7000102030405060708090a0b0c0d0e0f10111213"
|
||||
},
|
||||
{
|
||||
"name": "aes256gcm",
|
||||
"algId": 2,
|
||||
"nonce": "b0b1b2b3b4b5b6b7b8b9babb",
|
||||
"ciphertext": "000102030405060708090a0b0c0d0e0f10111213",
|
||||
"prefixSize": 18,
|
||||
"envelope": "445348310200b0b1b2b3b4b5b6b7b8b9babb000102030405060708090a0b0c0d0e0f10111213"
|
||||
},
|
||||
{
|
||||
"name": "sealto-x25519",
|
||||
"algId": 3,
|
||||
"nonce": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7",
|
||||
"ephemeralPublicKey": "c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf",
|
||||
"ciphertext": "000102030405060708090a0b0c0d0e0f10111213",
|
||||
"prefixSize": 62,
|
||||
"envelope": "445348310300c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7000102030405060708090a0b0c0d0e0f10111213"
|
||||
}
|
||||
],
|
||||
"aead": [
|
||||
{
|
||||
"name": "xchacha20poly1305-with-canonical-aad",
|
||||
"key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
||||
"nonce": "101112131415161718191a1b1c1d1e1f2021222324252627",
|
||||
"aad": "bb106e753e2fd9ac31142889a4356cbf9fc1f8db3777a1921ecbb5481bd4379e",
|
||||
"plaintext": "636f727265637420686f727365206261747465727920737461706c65",
|
||||
"ciphertext": "4793718431eb55f3feed50be98b0416d7bff929d804d53a7873495132465b6b1da6e73e042821964543ecd90",
|
||||
"envelope": "445348310100101112131415161718191a1b1c1d1e1f20212223242526274793718431eb55f3feed50be98b0416d7bff929d804d53a7873495132465b6b1da6e73e042821964543ecd90"
|
||||
}
|
||||
],
|
||||
"hkdf": [
|
||||
{
|
||||
"name": "passphrase-kek",
|
||||
"algorithm": "HKDF-SHA512-Expand",
|
||||
"prk": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
|
||||
"info": "dsh1/kek/passphrase/v1",
|
||||
"outputLength": 32,
|
||||
"output": "652b3a4a3ce03b235095ad32f1eed2cfdae915b5b0a98cc9f96face30853f4c7"
|
||||
},
|
||||
{
|
||||
"name": "local-cache",
|
||||
"algorithm": "HKDF-SHA512-Expand",
|
||||
"prk": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
|
||||
"info": "dsh1/localcache/v1",
|
||||
"outputLength": 32,
|
||||
"output": "5b69ed9266ff5f297f11667ca693b0049b805365ee34d54d6e60b843e414b1f5"
|
||||
}
|
||||
],
|
||||
"argon2id": [
|
||||
{
|
||||
"name": "passphrase-default",
|
||||
"memoryMebibytes": 256,
|
||||
"memoryKibibytes": 262144,
|
||||
"passes": 4,
|
||||
"parallelism": 1
|
||||
},
|
||||
{
|
||||
"name": "passphrase-reduced",
|
||||
"memoryMebibytes": 128,
|
||||
"memoryKibibytes": 131072,
|
||||
"passes": 3,
|
||||
"parallelism": 1
|
||||
},
|
||||
{
|
||||
"name": "passphrase-high",
|
||||
"memoryMebibytes": 512,
|
||||
"memoryKibibytes": 524288,
|
||||
"passes": 4,
|
||||
"parallelism": 1
|
||||
},
|
||||
{
|
||||
"name": "random-secret",
|
||||
"memoryMebibytes": 64,
|
||||
"memoryKibibytes": 65536,
|
||||
"passes": 3,
|
||||
"parallelism": 1
|
||||
},
|
||||
{
|
||||
"name": "random-secret-output",
|
||||
"memoryMebibytes": 64,
|
||||
"memoryKibibytes": 65536,
|
||||
"passes": 3,
|
||||
"parallelism": 1,
|
||||
"passphrase": "correct horse battery staple",
|
||||
"salt": "202122232425262728292a2b2c2d2e2f",
|
||||
"outputLength": 32,
|
||||
"output": "3573a601a50874c6c4222082d040f039ba4f557a0151e0357e8abb66fed7b29e"
|
||||
}
|
||||
],
|
||||
"fingerprint": [
|
||||
{
|
||||
"name": "identity-fingerprint",
|
||||
"x25519PublicKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"ed25519PublicKey": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"fingerprint": "fe8d8673f517688bf0d5d9b812327619a303c765af1f47dbd6a777db193c36e5"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user