Public Access
Specify the key statement encoding and key log chain (crypto.md 7.1, 7.2)
Section 7 always required "a canonical, length-prefixed encoding" for signatures without ever specifying one. That gap had to be closed before enrollment could exist: the client hashes the key statement and uses the result as an OIDC nonce, so the provider signs over those exact bytes. Two implementations disagreeing by one byte produce two nonces and an enrollment nobody can verify -- and it only shows up against a real provider, never in a local test. JSON cannot be the hashed form. Property order, number formatting, Unicode escaping and whitespace all vary between serialisers. So the statement is transmitted as JSON and hashed as a fixed binary encoding, and the two are independent by construction. Three details are load-bearing rather than stylistic: - The presence byte before each string is what makes the encoding injective. Without it an absent email and an empty one encode identically, and two different statements share a binding. - Timestamps truncate to milliseconds. PostgreSQL stores microseconds, so a statement that has been through the database must still hash to what the client hashed. The same applies to the key log, where an entry that cannot reproduce its own hash after being read back makes the chain unverifiable. - The key log entry hash deliberately excludes the database sequence. It is unknown until the insert runs, and order already follows the hash links -- so a renumbered or gapped sequence column cannot silently reorder history. KeyStatementFields is separate from Contracts.KeyStatement on purpose: one may gain JSON fields freely, the other cannot change without invalidating every stored binding, and Crypto must not depend on the contract assembly. KeyStatementDriftTests makes a field added to one and not the other a build failure, because a wire field outside the binding is unauthenticated data the server can change undetected. 54 new tests and two new golden vector sections. The vectors pin the absent-versus-empty email case and confirm that an offset-bearing sub-millisecond timestamp encodes identically to its truncated UTC form. Only additions to vectors.json; nothing existing moved.
This commit is contained in:
+72
-1
@@ -279,6 +279,73 @@ signature verification does not require the verifier to hold the vault key.
|
||||
would be a convenience, never the security boundary, and would drag an asymmetric
|
||||
implementation onto a machine that is supposed to have none.
|
||||
|
||||
The one exception is the key statement self-signature in §7.1, which the server *does* verify.
|
||||
That is a data-integrity check, not a boundary: an unverifiable statement admitted into the
|
||||
append-only key log (§7.2) is permanent, and every client auditing the chain afterwards would
|
||||
see an entry it cannot validate and cannot distinguish from tampering.
|
||||
|
||||
### 7.1 Key statement — canonical encoding and the identity-provider binding
|
||||
|
||||
> **Added 2026-07-28.** §7 always required "a canonical, length-prefixed encoding"; this
|
||||
> specifies it exactly. This is a clarification of an underspecified detail, made before any
|
||||
> client exists, not a change to a defined one. Pinned by `keyStatement` in the test vectors.
|
||||
|
||||
```
|
||||
statement = "dsh1/keystatement/v1" 20 bytes, literal
|
||||
|| u16 version big-endian
|
||||
|| u32 keyGeneration big-endian
|
||||
|| i64 createdAt big-endian, Unix milliseconds, UTC
|
||||
|| x25519_pk 32 bytes
|
||||
|| ed25519_pk 32 bytes
|
||||
|| str(issuer) || str(subject) || str(email) || str(deviceName)
|
||||
|
||||
str(absent) = 0x00
|
||||
str(present) = 0x01 || u32 length (big-endian) || UTF-8 bytes
|
||||
|
||||
binding = SHA-256(statement) 32 bytes
|
||||
nonce = base64url(binding), unpadded 43 characters
|
||||
```
|
||||
|
||||
Notes that are normative, not stylistic:
|
||||
|
||||
- **The presence byte is what makes the encoding injective.** Without it an absent email and an
|
||||
empty one encode identically, and two different statements would share a binding.
|
||||
- **`createdAt` is truncated to milliseconds by construction.** PostgreSQL stores microseconds,
|
||||
so a value that has been through a database round trip must still hash to the same thing. The
|
||||
offset is normalised to UTC, so the timezone a client happens to hold is irrelevant.
|
||||
- **JSON must never be hashed.** Property order, number formatting, Unicode escaping and
|
||||
whitespace all vary between serialisers. Two implementations disagreeing by one byte produce
|
||||
two nonces and an enrollment nobody can verify. The statement is *transmitted* as JSON and
|
||||
*hashed* as the encoding above; the two are independent on purpose.
|
||||
- The nonce is base64url because it travels in an authorization request query string.
|
||||
|
||||
The client uses `nonce` for a **fresh** OIDC authorization with `prompt=login`, so the resulting
|
||||
ID token is an identity-provider signature over exactly these keys. Verifiers must check:
|
||||
signature against the provider's JWKS **fetched directly from the provider**, `iss` matching the
|
||||
statement, `sub` matching the account, `aud` equal to the **client id** — an ID token is
|
||||
audienced to the client, never to the API — and `nonce` equal to the value above.
|
||||
|
||||
### 7.2 Key log chain
|
||||
|
||||
```
|
||||
entryHash = SHA-256( "dsh1/keylog/v1" 14 bytes, literal
|
||||
|| previousHash 32 bytes, all-zero for the first entry
|
||||
|| userId 16 bytes, RFC 4122 big-endian
|
||||
|| u32 generation big-endian
|
||||
|| x25519_pk 32 bytes
|
||||
|| ed25519_pk 32 bytes
|
||||
|| statementSignature 64 bytes
|
||||
|| i64 createdAt ) big-endian, Unix milliseconds, UTC
|
||||
```
|
||||
|
||||
The database-assigned sequence is deliberately **not** an input. It is unknown until the insert
|
||||
executes, and order already follows the hash links — so a renumbered or gapped sequence column
|
||||
cannot silently reorder history.
|
||||
|
||||
Appends must be serialised (the server takes a deployment-wide advisory lock). Two concurrent
|
||||
appends reading the same head would produce two entries claiming the same predecessor, which is
|
||||
indistinguishable from the fork the chain exists to detect.
|
||||
|
||||
## 8. Fingerprints and versioning
|
||||
|
||||
```
|
||||
@@ -319,7 +386,11 @@ exist first. The server cannot participate.
|
||||
- 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.
|
||||
- the negative cases of §4.4 — each must fail to decrypt;
|
||||
- key statement encodings, bindings and nonces (§7.1), including an absent versus empty email,
|
||||
a multi-byte device name, and a sub-millisecond offset-bearing timestamp that must encode
|
||||
identically to its truncated UTC form;
|
||||
- key log entry hashes (§7.2), including the genesis link and a second entry chained to it.
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user