Public Access
Add the client key hierarchy: bundle, master key, vault and item keys
Everything crypto.md section 3 describes below the identity key, which is what the desktop client needs before it can enroll or store anything. DshAad gives every descriptor in the specification a named constructor. The AAD binding is the most valuable structural property in the design -- it is what stops a server holding every ciphertext from pasting one row's bytes onto another, rolling a row back to a superseded generation, or replaying a revoked grant -- and all of it depends on callers getting purpose, resource type and ids right at every single call site. Hand-constructing descriptors makes that a matter of care; picking a method name makes it a matter of spelling. UserSecretBundle holds private keys in libsodium's guarded, mlocked allocations rather than a byte[], so they are not paged out and do not land in a core dump. They are created exportable, deliberately: re-wrapping the same bundle for a passphrase change or a new device needs to re-encode it, and the alternative -- a long-lived managed array so the keys need not be exportable -- keeps the identical secret in strictly worse memory. Every export is into a buffer zeroed before the method returns. Two spec changes, both found by implementing it, which is the argument for writing code before calling a spec frozen: - MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK cannot be expanded with SHA-512 at all. Widening it keeps the specified primitive; the alternatives were dropping to SHA-256 or adding an Extract step that conditions nothing. - The bundle encoding is a fixed 92-byte layout rather than canonical CBOR. Canonicality is not load-bearing here -- unlike a key statement the bundle is never hashed or signed, only encrypted -- so CBOR's one advantage does not apply, while its canonicalisation rules are a real source of cross-implementation disagreement. It also costs a dependency System.Formats.Cbor is not in the shared framework. Safe to change now and not later: no bundle has ever been stored. 53 new tests. The encoding is checked against an independent codec written in the test rather than by round-tripping production code against itself -- a round trip passes just as happily when both directions are wrong the same way, and this format cannot change after one bundle is stored. The pinned 92-byte hex constant is the golden vector for the layout. Most of the rest are negative, because a binding is only demonstrated by the substitutions that fail: a wrap for another user, a grant from a superseded generation, a payload pasted onto another item, a metadata blob offered as a payload, a version rolled back.
This commit is contained in:
+44
-3
@@ -94,15 +94,14 @@ OIDC access-token gate plus client-side backoff.
|
||||
|
||||
```
|
||||
vault passphrase
|
||||
│ Argon2id(salt, m=256 MiB, t=4, p=1) → 32 B
|
||||
│ Argon2id(salt, m=256 MiB, t=4, p=1) → 64 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> }
|
||||
UserSecretBundle — fixed binary, 92 B (see 3.1)
|
||||
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
|
||||
@@ -119,6 +118,48 @@ item plaintext — password, private key, key passphrase, TOTP seed, encrypted m
|
||||
XChaCha20-Poly1305(DK, plaintext, aad)
|
||||
```
|
||||
|
||||
> **Changed 2026-07-28: MK is 64 bytes, not 32.** Skipping HKDF-Extract — correct, because an
|
||||
> Argon2id output is already uniformly random, per RFC 5869 §3.3 — means MK *is* the PRK of the
|
||||
> expansion. .NET's `HKDF.Expand` rejects a PRK shorter than the hash output, so a 32-byte MK cannot
|
||||
> be expanded with SHA-512 at all. Widening MK keeps the specified primitive; the alternatives were
|
||||
> dropping to SHA-256 or adding an Extract step that conditions nothing. Extra Argon2id output is
|
||||
> free. Discovered by implementing it, which is the argument for writing the code before declaring a
|
||||
> spec frozen.
|
||||
|
||||
### 3.1 UserSecretBundle encoding
|
||||
|
||||
> **Changed 2026-07-28**, from "canonical CBOR" to the fixed layout below. This reverses a stated
|
||||
> choice rather than clarifying an unstated one, so the reasoning is recorded here. It is safe to
|
||||
> make now and would not be later: nothing has been implemented against CBOR and no bundle has ever
|
||||
> been stored, so there is nothing to migrate.
|
||||
|
||||
```
|
||||
bundle = "dsh1/bundle/v1" 14 bytes, literal
|
||||
|| u16 version big-endian
|
||||
|| u32 keyGeneration big-endian
|
||||
|| i64 createdAt big-endian, Unix milliseconds, UTC
|
||||
|| x25519_sk 32 bytes, raw scalar
|
||||
|| ed25519_sk 32 bytes, raw seed
|
||||
= 92 bytes, fixed
|
||||
```
|
||||
|
||||
Three reasons for the change:
|
||||
|
||||
- **Canonicality is not load-bearing here.** Unlike a key statement (§7.1), the bundle is never
|
||||
hashed or signed — only encrypted. Any deterministic encoding is sufficient, so the one property
|
||||
CBOR was chosen for does not apply. Canonical CBOR's rules (definite-length maps, sorted keys,
|
||||
shortest-form integers) are a source of cross-implementation disagreement bought for nothing.
|
||||
- **It costs a dependency.** `System.Formats.Cbor` is not in the .NET 10 shared framework. Keeping
|
||||
`DodoSSH.Crypto` down to NSec alone matters for a client that wants trimming.
|
||||
- **Consistency.** §7.1 and §7.2 already establish a fixed big-endian layout with an explicit
|
||||
domain label. One convention to learn and to review beats two.
|
||||
|
||||
Forward compatibility is unaffected: the bundle is versioned, and only our own clients ever read it,
|
||||
so a new field means bumping `version` — which a fixed layout handles as well as CBOR would.
|
||||
|
||||
Readers **must** reject a bundle whose length, label or version does not match exactly. This is the
|
||||
root of everything a user can read; there is no safe way to guess at a malformed one.
|
||||
|
||||
### Why the bundle is wrapped many ways
|
||||
|
||||
This is the load-bearing structural choice. Because every wrap protects the *same* bundle:
|
||||
|
||||
Reference in New Issue
Block a user