# Signed statements

**Status: describes implemented behaviour, made normative. Four inconsistencies
found while writing it are recorded in §8 and are not resolved.**

[canonicalization.md](canonicalization.md) specifies the predicate envelope —
the JSON object a grant's action hash is taken over. This document specifies the
other thing that gets signed: the **line-oriented statements** a device, a
provider or an executor signs to prove a decision.

They are a different shape for a reason. An envelope is data a caller
constructs; a statement is a claim a key makes about one event, and it is built
by the platform so that the signer and the verifier cannot disagree about the
bytes. A client that assembles its own statement has already lost the property
the signature exists to provide.

Written by transcribing `DeviceDecisionVerifier.cs` and
`VerifiedActionDisplay.cs` line by line. Everything below was read from the
implementation, not inferred from the pattern — which is how §8 was found.

---

## 1. The shape

```
statement=<domain>.v<n>\n
<key>=<value>\n
<key>=<value>\n
```

- UTF-8.
- `\n` only. Never `\r\n`.
- **A trailing newline after the final line.** The statement ends with `\n`; it
  is a sequence of terminated lines, not a join.
- No whitespace around `=`. No blank lines. No comments.
- Signed as the UTF-8 bytes of that text, ES256, raw 64-byte R||S, Base64Url.
- Digested, where a statement is digested rather than signed, as SHA-256 over
  the same bytes, rendered **uppercase hex**.

### The first line is a domain separator, and it is load-bearing

Without it, one key signs two kinds of statement and a signature collected for
one can be presented as the other. With it, an approval signature can never
verify as a contract signature, because the bytes begin differently.

A new statement family **must** take a new `statement=` line. Reusing one with
different fields is the same key signing two meanings. Every family below was
introduced this way rather than by extending an existing one, so signatures
already in flight keep verifying.

**One exception exists and it is legacy. See §4.**

### Keys are not necessarily unique

`verid.approval.display.v1` repeats `row=` once per displayed row, in order. A
parser that loads a statement into a map keyed by field name will silently
collapse those rows and compute a digest over fewer facts than were shown.
**Parse a statement as an ordered list of lines, not as a dictionary.**

---

## 2. Value encoding

| Kind | Rule |
|---|---|
| GUID | Lowercase, hyphenated, no braces — .NET `D` format |
| Integer | Invariant culture, no group separators |
| Boolean | `true` / `false`, lowercase |
| Enum | The member **name**, never its ordinal |
| Absent | the literal `none` |
| Set of strings | Each trimmed, sorted ordinal, joined with `,` |
| Nested set | Inner joined with `\|`, outer with `,` |

Hashes, decimals and timestamps are **not uniform across families**. They are
specified per field in §3, because they genuinely differ and a general rule
stated here would be wrong in at least one place. §8.1 and §8.2 are those
places.

**Absent is `none`, not empty.** `matching_number=` and `matching_number=none`
are different bytes, and omitting the line entirely would be a third thing. One
representation, or the statement is not canonical. `operator` and
`relying_party_mark` take `none` for the same reason: an attestor vouches for
its own staff and no one else's, and a missing operator must not read the same
as any operator.

**Sets sort ordinally, not by culture.** Ordinal comparison is byte order;
culture-aware comparison orders `a` against `B` differently per locale, so the
same set would produce different statements on different servers. The
implementation passes `StringComparer.Ordinal` explicitly at every site.

---

## 3. The families

**8 versioned signed formats exist**: the 7 line-oriented statement families
below, and one delivery envelope with an entirely different shape (§4a). Plus the
legacy shape in §4, which carries no version at all.

Field order is **exactly as listed**. It is part of the signed bytes.

### 3.1 `verid.contract.sign.v1`

What a signer's device signs to activate a contract.

```
statement=verid.contract.sign.v1
intent_id=<guid>
version=<int>
content_hash=<trimmed, UPPERCASED>
signer_id=<guid>
device_id=<guid>
tenant_id=<guid>
```

Binds the exact contract version and content hash, the signer, the device and
the tenant — so a signature cannot be moved to another contract, a later
version, or another person. Signing previously accepted any string as a
"signature reference", which meant a contract became Active on assertion.

**This family's field order is not alphabetical. Every other one's is.** See
§8.1.

### 3.2 `verid.approval.displayed.v1`

What an approver's device signs to prove it approved *what was shown*.

```
statement=verid.approval.displayed.v1
action_hash=<trimmed, UPPERCASED>
approval_id=<guid>
approver_id=<guid>
device_id=<guid>
display_digest=<trimmed, UPPERCASED>
relying_party_id=<guid>
relying_party_identity_version=<int>
tenant_id=<guid>
```

`action_hash` alone proves the approval covers the evaluated parameters. It does
not prove the person saw them: a device that rendered "Approve account update"
over a transfer would produce a signature indistinguishable from an honest one.
`display_digest` closes that — it binds the rendered rows, so what the approver
saw and what was authorized become the same object.

`relying_party_identity_version` binds the identity shown at the time. A party
that later changes its name or domains does not inherit approvals given under
the previous identity.

### 3.3 `verid.approval.display.v1`

The bytes `display_digest` is taken over. Not signed directly — digested, and
the digest is signed in §3.2.

```
statement=verid.approval.display.v1
relying_party_id=<guid>
relying_party_identity_version=<int>
relying_party_mark=<sha256|none>
row=<label>U+001F<value>
row=<label>U+001F<value>
...
```

- `row=` repeats, **once per row, in display order**. See §1.
- Label and value are separated by **U+001F UNIT SEPARATOR**, not by `=` or a
  colon. A visible delimiter could appear inside a value; U+001F cannot, because
  control characters are refused.
- **No caller supplies any row.** Rows derive from the evaluated envelope and
  the relying-party registry. A requester that can name itself can name someone
  else, so the organization name and verified domain are registry data.

Three rules govern the rows, and each prevents a specific attack:

1. **Every predicate renders.** An unrenderable predicate refuses the whole
   display rather than dropping a line. A display that silently omits a material
   fact is worse than no display, because it looks complete.
2. **No value may forge a row.** A counterparty reference containing a newline
   could paint `row=Amount…` under the real one. Control characters are a
   **refusal, not an escape** — there is no escaping layer to get wrong.
3. **Amounts keep their text.** The display renders the envelope's raw amount
   text, so an approver who saw `9400.00` is bound to that and not to `9400`.

Row order: the five fixed rows (Organization, Verified domain, Action, Requested
by, Environment), then one row per predicate **sorted ordinally by predicate
`type`**, then Expires. Note that this is a different sort from the envelope's,
which orders predicates by their whole canonical form — see §8.4.

### 3.4 `verid.signin.approve.v1`

```
statement=verid.signin.approve.v1
challenge_id=<guid>
device_id=<guid>
matching_number=<int|none>
nonce=<trimmed>
principal_id=<guid>
tenant_id=<guid>
```

Approving a sign-in once required only a non-empty "signature reference", so
anyone holding a tenant token and the challenge nonce could approve someone
else's sign-in — and the audit chain recorded it as device-signed.

### 3.5 `verid.resource.capability.v1`

What a resource provider signs about what it actually delivered.

```
statement=verid.resource.capability.v1
capabilities=<sorted,csv>
cost=<decimal, formatted>
data_classes=<sorted,csv>
identities=<id:cap|cap,id:cap>
provider=<trimmed>
resource_id=<guid>
reusable_credential=<true|false>
spawned_agents=<int>
```

`identities` is the nested-set case: each entry is an identifier, a colon, then
its capabilities joined with `|`; entries joined with `,`. Both levels trim and
sort ordinally, so two parties that agree on the contents agree on the bytes
whatever order they hold them in.

`cost` is formatted from a decimal rather than carried as text. **This is the
one decimal here that does not follow the carry-the-text rule** — §8.2.

### 3.6 `verid.executor.attest.v1`

What an attestor signs about who is actually performing an effect.

```
statement=verid.executor.attest.v1
attestor=<trimmed>
executor_id=<trimmed>
executor_kind=<enum name>
expires_at=<round-trip timestamp>
operator=<verbatim|none>
```

`executor_kind` is the enum name. An ordinal is unreadable in evidence and
silently changes meaning if a member is inserted mid-enum.

`operator` is **not trimmed** when present, unlike every neighbouring field.
`expires_at` uses a round-trip format, not the envelope's — §8.3.

### 3.7 `verid.payment.admission.v1`

What the platform signs to tell a card issuer that a charge is covered.

```
statement=verid.payment.admission.v1
admission_id=<guid>
amount=<trimmed, verbatim text>
credential_hash=<trimmed, case preserved>
currency=<trimmed, case preserved>
grant_id=<guid>
issuer=<trimmed>
not_after=<round-trip timestamp>
receiver=<trimmed>
tenant_id=<guid>
```

`amount` is the **same text the envelope hashed**, carried rather than
reformatted. `60` and `60.00` are the same number and different strings, and an
issuer recomputing this over a reserialized decimal would not reproduce the
bytes.

`credential_hash` is trimmed only — **not uppercased**, unlike `action_hash`,
`content_hash` and `display_digest` (§8.1). The credential appears only as a
hash: an issuer needs to know which credential this covers, and nobody needs the
signed text to be spendable.

`currency` is trimmed only here. The envelope uppercases it. A verifier
comparing this statement's `currency` against an envelope's must compare
case-insensitively or uppercase both.

---

## 4. The one statement with no domain separator

`CanonicalApproval` predates domain separation and has **no `statement=` line**:

```
approval_id=<guid>
action_hash=<trimmed, UPPERCASED>
approver_id=<guid>
device_id=<guid>
tenant_id=<guid>
```

It is still verifiable, via `VerifyApproval`. It is also the one statement whose
fields are **not** sorted — `approval_id` precedes `action_hash`.

Treat it as deprecated. A new integration must sign §3.2, which covers the same
facts plus what was displayed. It is recorded here because a verifier that
encounters one needs to know what it is, and because an undocumented verifiable
statement shape is exactly the thing an attacker looks for: a statement with no
domain separator is the one whose bytes are cheapest to collide with another
format.

Deleting it is the right end state. That requires establishing that no enrolled
device still signs it, which is deployment evidence this repository does not
have.

---

## 4a. `verid.audit.egress.v1` — not a statement at all

Found by the gate that checks this document, which is the only reason it is
here: it is the one versioned signed format that does not follow §1, and a
reader who assumed otherwise would build the wrong verifier.

An audit event delivered to a SIEM is **JSON**, with snake_case field names, and
it is signed by a **shared secret rather than a device key**:

```
Verid-Signature: t=<unix seconds>,v1=<hmac-sha256, lowercase hex>
```

The MAC covers `<unix seconds>.<body>` — the timestamp **inside** the signed
material, not beside it. A signature covering only the body replays forever, and
a receiver that wants a freshness window needs a timestamp an attacker cannot
rewrite.

| | §3 statements | Egress delivery |
|---|---|---|
| Bytes | Terminated `key=value` lines | JSON body |
| Algorithm | ES256, asymmetric | HMAC-SHA256, shared secret |
| Proves | Which enrolled key decided | That this delivery came from the platform |
| Replay | Single-use by the thing it binds | Timestamp tolerance window |

The difference in what they prove is the point. An HMAC proves the sender held
the secret; it does not prove to a third party who signed, because the receiver
could have produced it too. That is adequate for "this webhook is genuine" and
inadequate for "this person approved this payment", which is why approvals do
not use it.

The event carries its own `hash` and `previous_hash`, so a SIEM can verify the
chain it was sent rather than trusting that what arrived is what happened.
Verifiers must compare in fixed time and reject outside the tolerance **before**
comparing.

---

## 5. The contract content hash

`content_hash` in §3.1 is a digest over the draft contract's material
constraints and its version, computed by the platform. **A client never computes
it.**

That is deliberate. The content hash is the platform's own definition of what
the contract says; a client computing it independently could sign something that
differs by a byte from what will be enforced, and the difference would surface
as a refused activation rather than as a mismatch anybody could read.

`GET /v1/intents/{id}/signing-statement` returns the hash **and the full
canonical text to be signed**.

> **Sign the canonical text as returned. Do not rebuild the statement from its
> parts.**

The same rule as carrying the envelope, for the same reason, and with the same
failure mode: a reconstruction that differs by one byte produces a signature
that verifies against nothing, and the error says the signature does not cover
the contract — which reads like a key problem and is a serialization problem.

---

## 6. Verification

A verifier must check, in order:

1. The first line is **exactly** the expected `statement=` domain.
2. The remaining lines are the expected keys, in the expected order, with no
   extras. An unexpected line is a refusal, not something to ignore.
3. The signature verifies over the UTF-8 bytes, ES256, raw 64-byte R||S.
4. The key is the one **registered** for that device, provider or executor —
   **never a key supplied alongside the signature**.

Step 4 has been got wrong in this repository: an early quorum signer carried its
own public key, so a caller could sign with any key it liked and pass. Keys come
from the enrolment record. It is the same rule the SAML validator applies when it
refuses a certificate embedded in `KeyInfo`, and the same class of bug.

Step 2 matters more than it looks. A verifier that checks the fields it knows and
ignores the rest will accept a statement with an appended line, and an appended
line is a free-text channel into a format whose whole purpose is being closed.

---

## 7. Relationship to the envelope

| | Envelope | Statement |
|---|---|---|
| Shape | Canonical JSON | Terminated `key=value` lines |
| Built by | The caller, then carried | The platform, then signed as given |
| Signed by | Nobody — it is hashed | A device, provider or executor key |
| Binds | What the action is | That somebody decided something about it |
| Keys | Unique, sorted by code point | May repeat (`row=`), order fixed per family |

`action_hash` in §3.2 **is** the envelope hash. The two representations meet
there: the envelope says what the action was, the statement says who decided
what about it, and the hash is the join. A receipt later binds that the effect
happened.

---

## 8. Inconsistencies found while writing this

All four were found by transcribing the implementations side by side. None is
resolved here. None should be changed casually: each alters bytes that existing
signatures were made over, so a fix is a new statement version with both
accepted during migration, not an edit.

They are recorded rather than smoothed over because a specification that hides
its irregularities is worse than one that names them — the irregularity still
exists, and the next implementer meets it without warning.

### 8.1 Field order and hash casing are both per-family

Six of the seven families sort their fields alphabetically after the
`statement=` line. `verid.contract.sign.v1` does not: it runs `intent_id`,
`version`, `content_hash`, `signer_id`, `device_id`, `tenant_id`. The legacy
statement in §4 does not either.

Hash casing splits the same way: `action_hash`, `content_hash` and
`display_digest` are uppercased; `credential_hash` is not.

An implementer who inferred either rule from the majority would produce
different bytes and a signature that verifies against nothing — and would most
likely conclude their ECDSA was wrong rather than their field order, because
that is what a signature failure looks like.

Alphabetical order and uniform uppercasing are the better rules, because they
are derivable rather than memorized. Adopting them invalidates every existing
contract signature, so it is a `v2` or it stays as it is and §3 is the
specification.

### 8.2 One decimal is formatted, not carried

`canonicalization.md` §1 is emphatic: carry the decimal text, never
reconstruct it. `verid.payment.admission.v1` obeys — it carries `AmountText`.

`verid.resource.capability.v1` formats `cost` from a decimal with the invariant
culture. .NET's `decimal` does preserve scale through `ToString`, so this is not
currently lossy — but it is lossy in **every other language**, where the same
field would arrive as a float and come back with a different scale.

This is no longer an argument. The published vector has to declare `cost` as the
**string** `"12.50"`, although the domain type is a decimal, because a JSON
number cannot survive the trip: `JSON.parse` turns `12.50` into `12.5` and the
trailing zero is gone before any implementation sees it. The vector has to
misdeclare the type to be reproducible at all, which is the defect stated as
plainly as it can be.

The rule the rest of the system follows is the right one. `cost` should carry
text.

### 8.3 Two timestamp canonicalizations

The envelope requires RFC 3339, UTC, `Z`, **exactly three** fractional digits,
and rejects everything else — including `+00:00`, and including `…:30Z` with no
fraction.

`verid.executor.attest.v1` and `verid.payment.admission.v1` use .NET's
round-trip specifier, which emits **seven** fractional digits, and emits the
value's own offset rather than forcing UTC. So a timestamp that is not already
UTC is serialized with a local offset into a signed statement.

The published vectors show what that produces for an already-UTC value:

```
expires_at=2026-09-24T10:46:00.0000000+00:00
```

Seven fractional digits, and `+00:00` — the exact form the envelope rejects **by
name**, in the same specification, for the stated reason that one representation
is what makes a canonical form canonical. Two signed statements emit it.

That means the platform has two timestamp canonicalizations, the stricter one is
documented and the looser one was not, and verifying either statement in another
language requires reproducing one runtime's formatter exactly — a dependency on
.NET's string output, inside a signature.

Resolve before a second language implements either statement. These two are the
statements a card issuer and a receiver verify, which is to say the ones most
likely to be implemented by somebody else first.

### 8.4 Predicates sort two different ways

The display (§3.3) sorts predicates ordinally **by `type`**. The envelope sorts
them by the **whole canonical form**, which orders on whichever key sorts first —
so `data_class` precedes `amount` there, because `{"access"` precedes
`{"currency"`.

Both are internally consistent and neither is wrong: the display digest and the
envelope hash are separate digests over separate representations.

It was listed because the envelope README claimed the envelope sorts by `type`,
which is what the display actually does — so the one sentence in the
specification described the wrong one of the two sorts, and an implementer
following it would get the envelope hash wrong.

**This one is now fixed**, and it is the only one of the four that could be:
it was a documentation defect, so correcting it changed no bytes and invalidated
no signature. The envelope README now states the real rule, and
[signing-registry.md](signing-registry.md) §2 separates
`CRYPTOGRAPHIC_CANONICALIZATION` from `DISPLAY_ORDERING` so the two cannot be
conflated again. The other three remain open because fixing them would change
signed bytes.

---

## 9. For an implementer

- Never assemble a statement. Sign the canonical text the platform returns.
- Never supply the verification key alongside the signature.
- Parse as ordered lines. `row=` repeats.
- Refuse unexpected lines.
- `none` is a value; absent is not.
- Sort ordinally, never by culture.
- Field order, hash casing and timestamp format are **per family** (§8). Do not
  generalize from one statement to another.
- Decimals keep their text.
- A new family takes a new `statement=` line, always.

## 10. Conformance

[`statement-vectors.v1.json`](statement-vectors.v1.json) publishes nine vectors
covering six of the eight formats, each with its raw input, its canonical bytes
and its SHA-256 digest.

Inputs are published **raw** — untrimmed, lower-cased where the statement
uppercases, and in the caller's order — so an implementation has to apply the
normalization rules rather than receive values already normalized. A vector
whose input is the answer tests nothing.

Three things verify them, and they are deliberately not the same thing:

| Artifact | What it establishes |
|---|---|
| `tests/Verid.Core.Tests/StatementVectorTests.cs` | The implementation produces these exact bytes. Each family is pinned as a literal transcribed from this document, so the test fails if either side drifts — and emits the vectors file, comparing rather than trusting. |
| [`verify-statements.mjs`](verify-statements.mjs) | The rules here are sufficient to implement. It rebuilds every vector from its input in another language with `node:crypto` and no platform code, and refuses to pass while a family it knows has no vector. |
| `verid-mobile/test/displayed_approval_test.dart` | The display and displayed approval recompute in Dart, which is the language that actually signs them on a phone. |

The remaining two formats are covered differently on purpose: the display and
displayed approval are pinned by the Dart test above, and
`verid.audit.egress.v1` is an HMAC over a JSON body (§4a) rather than a
statement, so it needs a receiver-side vector of a different shape. **That one
has no vector and should get one.**

### What this is not

The same people wrote the specification, the implementation and
`verify-statements.mjs`. Reproducing a vector from a document you also wrote
establishes that the document is *complete enough to follow*, which is worth
having — it is how §8.2 went from an argument to a demonstration — but it is not
third-party conformance. That needs an implementation this repository did not
write, in a language nobody here chose.

§8 is the evidence for why it matters: four irregularities survived in signed
formats because nothing outside the implementation ever had to reproduce them.
