Add the server client and client-side enrollment

A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.

Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.

The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.

The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.

ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.

Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.

448 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
2026-07-28 22:42:56 +02:00
parent 5fccd53824
commit a878c2b6bb
15 changed files with 3301 additions and 1 deletions
+32
View File
@@ -387,6 +387,38 @@ Appends must be serialised (the server takes a deployment-wide advisory lock). T
appends reading the same head would produce two entries claiming the same predecessor, which is
indistinguishable from the fork the chain exists to detect.
### 7.3 Vault key grant — canonical encoding
> **Added 2026-07-28.** §7 named the grant tuple without specifying its encoding. This fills that in,
> using the same conventions as §7.1. Pinned by `GrantStatementCodecTests`.
```
grant = "dsh1/grant/v1" 13 bytes, literal
|| u32 keyGeneration big-endian
|| u8 grantKind 1 = Member, 2 = Recovery, 3 = Escrow
|| vaultId 16 bytes, RFC 4122 big-endian
|| granteeUserId 16 bytes, all-zero for a non-member grant
|| granteeKeyFingerprint 32 bytes
|| SHA-256(wrappedKey) 32 bytes
|| granterUserId 16 bytes
|| granterKeyFingerprint 32 bytes
|| keyLogHead 0x00, or 0x01 followed by 32 bytes
|| i64 grantedAt big-endian, Unix milliseconds, UTC
```
Signed with context `dsh1/sig/grant/v1`.
- **The digest of the wrapped key, not the key.** A verifier must be able to check who issued a grant
without holding the vault key, which is the whole point of separating attribution from access.
- **`grantKind` values are load-bearing.** They must match `DodoSSH.Domain.GrantKind` exactly; the
crypto-layer enum is named `GrantPurpose` only to avoid a name collision in the server, where both
are visible. Renumbering either would make every grant of the changed kind fail verification
permanently.
- **The key log head is optional, with a presence byte.** Absent for a self-grant: there is no third
party whose key could have been substituted, and the log entry that would supply a head is written
by the server in the same transaction, so a client cannot have signed over it. Without the presence
byte, "no head" and "a head of 32 zero bytes" would be indistinguishable.
## 8. Fingerprints and versioning
```