# Probity Signing Envelope, v1

This document specifies the signing envelope shared by every predicate in the Probity Predicate
Standard. It is **predicate-agnostic**: the same envelope, the same signature construction, and the
same verification procedure apply to every predicate type. A verifier that implements this document
can check any conforming Probity verdict offline, given a trust anchor, regardless of which predicate
the verdict carries.

The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL are to be interpreted as in
[RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).

## Purpose

A verdict must be readable and trustworthy by a third party who does not run, and does not trust, the
tool that produced it. That requires (a) a documented, stable byte layout for the signed payload,
(b) a signature scheme any standard implementation can verify, and (c) a trust model that lets the
verifier decide *whose* signatures to accept. This document provides all three. It deliberately
separates the signed payload from any transport or distribution wrapper so that a verifier always
knows exactly which bytes the signature commits to.

## Envelope structure

Every predicate is the `predicate` field of an
[in-toto Statement v1](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md):

```json
{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [
    { "name": "<artifact-name>", "digest": { "sha256": "<64-hex>" } }
  ],
  "predicateType": "https://getprobity.dev/predicate/v1/<name>",
  "predicate": { "...": "predicate-specific fields" }
}
```

- **`_type`** (REQUIRED) - MUST be exactly `https://in-toto.io/Statement/v1`.
- **`subject`** (REQUIRED) - a non-empty array of subject objects, each with a `name` and a `digest`
  map. The subject is always the artifact the verdict is *about* (the per-predicate document states
  exactly which digest). A verifier binds the verdict to the artifact through this digest.
- **`predicateType`** (REQUIRED) - the full predicate-type URI. A verifier dispatches on this string
  and MUST reject a type it does not recognize.
- **`predicate`** (REQUIRED) - the predicate body, specified by the per-predicate document.

The Statement is then wrapped in a DSSE envelope and signed.

### DSSE envelope

The signed artifact is a [DSSE](https://github.com/secure-systems-lab/dsse) envelope:

```json
{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "<base64(canonical Statement JSON)>",
  "signatures": [
    { "keyid": "<64-hex>", "sig": "<base64(ed25519 signature)>" }
  ]
}
```

- **`payloadType`** (REQUIRED) - `application/vnd.in-toto+json` for an in-toto Statement payload.
- **`payload`** (REQUIRED) - base64 of the serialized in-toto Statement (the bytes the signature
  commits to; see Canonicalization).
- **`signatures`** (REQUIRED) - a non-empty array. Each entry carries a `keyid` and a `sig`.
- **`keyid`** (REQUIRED, in every signature entry) - see Key identity below. This is MANDATORY in
  every envelope; an envelope without a `keyid` is non-conforming.
- **`sig`** (REQUIRED) - base64 of the raw 64-byte ed25519 signature over the PAE of the payload.

## Signature algorithm

The signature scheme is **ed25519** ([RFC 8032](https://www.rfc-editor.org/rfc/rfc8032)) over the
DSSE Pre-Authentication Encoding (PAEv1) of `(payloadType, payload)`. The `sig` value is the raw
64-byte ed25519 signature, base64-encoded.

## Key identity (`keyid`)

The `keyid` MANDATORY in every envelope is the
[RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK thumbprint of the ed25519 public key, with the
project's lowercase-hex encoding:

```
canonical_jwk = {"crv":"Ed25519","kty":"OKP","x":"<base64url(raw 32-byte public key), no padding>"}
                (the required OKP members crv, kty, x in lexicographic order, no whitespace, UTF-8)
keyid         = lowercase_hex( SHA-256( canonical_jwk bytes ) )
```

The hash input is the **RFC 7638 canonical JWK** built from the raw 32-byte ed25519 public key (the
RFC defines the thumbprint as the SHA-256 of this canonical JWK). We adopt the RFC 7638 *input* with
the project-standard lowercase-hex *encoding* (RFC 7638 itself leaves the output encoding to the
application), so the result is 64 lowercase hexadecimal characters. The `keyid` lets a verifier select
the matching public key from its trust anchor before checking the signature, and lets two parties
refer to the same key unambiguously without exchanging the key bytes. The reference oracle for this
derivation and its cross-language conformance vectors is
[`gen-keyid-vectors.py`](gen-keyid-vectors.py).

> Note: the `keyid` is a hint, not a credential. It is excluded from every signed pre-image (the DSSE
> PAE and the catch-record canonical bytes), so re-deriving it under a different scheme re-stamps
> metadata without invalidating any signature. Hashing a public key to a compact, stable identifier
> follows the cosign / Rekor transparency-log-ID convention (`GetTransparencyLogID` =
> hex(SHA-256(DER SubjectPublicKeyInfo))); this envelope uses the RFC 7638 JWK-thumbprint input rather
> than DER SubjectPublicKeyInfo, and is NOT the X.509 Subject Key Identifier of RFC 5280 (which is a
> SHA-1 over the public-key BIT STRING). The envelope `keyid`, the catch-record per-record `key_id`,
> and the freeze-binding `key_id` are now ALL derived by this single RFC 7638 scheme; earlier
> revisions derived the catch-record `key_id` from the DER SubjectPublicKeyInfo, but that divergence
> is retired. The per-record `key_id` field is defined in [catch-record.md](catch-record.md).

## PAE construction (PAEv1)

The signature is never computed over bare bytes. It is computed over the DSSE Pre-Authentication
Encoding, which prefixes the payload with its type and both lengths so a signature is bound to a
specific payload type:

```
PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
```

where:

- `SP` is a single ASCII space (0x20);
- `"DSSEv1"` is the literal six-byte ASCII string;
- `LEN(x)` is the byte length of `x` rendered as an ASCII decimal integer with no leading zeros;
- `type` and `body` are the raw bytes (not base64) of the payload type and payload, concatenated
  positionally with the single-space separators shown.

For the **envelope** itself, `type = "application/vnd.in-toto+json"` and `body =` the serialized
in-toto Statement bytes (the same bytes that are base64-encoded into `payload`).

### Component payload types

The catch-record component carries its own signatures over its own canonical pre-images, under
dedicated payload types so a catch-record signature can never be replayed as a Statement signature
(or vice versa). These types are used as the `type` argument to the same PAE construction above:

| What is signed | PAE `type` | PAE `body` |
|---|---|---|
| In-toto Statement (verdict envelope) | `application/vnd.in-toto+json` | serialized Statement bytes |
| Individual catch-record | `application/vnd.probity.catch-record.v2+json` | the catch-record canonical pre-image |
| Catch-record batch root | `application/vnd.probity.catch-batch-root.v1` | `uint64-BE(count)` followed by the 32-byte root |

A verifier MUST reconstruct the PAE with the payload type it expects for the artifact it is checking
and MUST reject on any mismatch. The per-record and batch-root pre-image constructions are specified
in [catch-record.md](catch-record.md).

## Canonicalization (and the residual fragility risk)

The bytes the envelope signature commits to are the serialized in-toto Statement. Producers serialize
the Statement deterministically (a JSON canonicalization: sorted object keys, no insignificant
whitespace, UTF-8). The signed bytes are exactly those carried, base64-encoded, in `payload`.

The load-bearing rule is **separate the signed payload from any transport wrapper.** A verifier MUST
verify the signature over the *exact payload bytes* carried in the envelope (decode `payload` from
base64 and use those bytes), and MUST NOT re-serialize, re-canonicalize, pretty-print, or otherwise
transform the Statement before verifying. Any tool that ships, stores, or proxies a verdict MUST
preserve the payload bytes byte-for-byte; wrapping a verdict in a transport envelope (an HTTP body, a
message bus frame, an A2A message) MUST NOT mutate those bytes.

> Residual risk (stated honestly): JSON canonicalization is fragile across a supply chain. If any
> intermediary re-serializes the Statement - even a semantically identical re-encoding - the byte
> stream changes and the signature fails. This is by design (the signature is over bytes, not over a
> JSON value), but it means producers and intermediaries MUST treat the signed payload as an opaque
> blob, not as a JSON document to be reformatted. Carry the base64 `payload` verbatim end to end.

## Trust root (v1)

A signature proves the bytes were signed by *some* key; it does not, by itself, say that key is
*trusted*. The verifier decides trust.

- **A verifier MUST accept a configurable trust anchor.** The set of acceptable signing keys (by
  `keyid` and public-key bytes) MUST be supplied to the verifier as configuration. A verifier MUST
  NOT hardcode any single key as the only acceptable one. This is what makes the format genuinely
  open: any party can publish verdicts under their own key and any verifier can be pointed at that
  key.

- **`probity.pub` is documented as the DEFAULT, never a format constraint.** A verifier MAY ship the
  Probity public key as a convenience default trust anchor, but the format does not require it and a
  conforming verdict signed by any key the verifier trusts is valid. Nothing in the wire format names
  or assumes `probity.pub`.

- **Key distribution (non-normative).** A producer MAY publish its verification keys at a
  `.well-known` endpoint. The following is an EXAMPLE shape, NON-NORMATIVE - verifiers are not
  required to fetch it and producers are not required to publish it:

  ```
  GET https://<producer-domain>/.well-known/verification-keys
  ```
  ```json
  {
    "keys": [
      {
        "keyid": "<64-hex SHA-256 of the RFC 7638 JWK thumbprint input for the ed25519 public key>",
        "alg": "ed25519",
        "public_key_b64": "<base64 of the raw 32-byte ed25519 public key>"
      }
    ]
  }
  ```

  A verifier that chooses to use such an endpoint resolves the `keyid` from the envelope against the
  published keys, then verifies. The endpoint is a distribution convenience; trust still rests on the
  verifier's configured decision to accept that producer's keys.

- **Keyless / decentralized trust is deferred to v2.** A keyless model where signing identity is
  bound to a workload identity through a transparency-log-backed certificate
  ([TUF](https://theupdateframework.io/), [Sigstore](https://www.sigstore.dev/) Fulcio) is explicitly
  out of scope for the v1 envelope and is reserved for a v2 envelope profile. v1 trust is
  configurable public keys, full stop.

## Verification procedure (predicate-agnostic, offline)

Given an envelope and a configured trust anchor, a verifier MUST:

1. Parse the DSSE envelope. Confirm `payloadType == application/vnd.in-toto+json` and that
   `signatures` is non-empty and each entry carries a `keyid`.
2. Decode `payload` from base64 to the exact Statement bytes. Do not re-serialize.
3. For each signature entry, resolve `keyid` against the trust anchor to a public key. If no
   configured key matches, the verifier MUST reject (untrusted signer). Independently confirm
   `keyid == lowercase_hex(SHA-256(RFC 7638 canonical JWK of the public key))` for the resolved key.
4. Reconstruct `PAE("application/vnd.in-toto+json", <Statement bytes from step 2>)` and verify the
   ed25519 `sig` against it with the resolved public key. Reject on failure.
5. Parse the Statement. Confirm `_type == https://in-toto.io/Statement/v1`, `subject` is non-empty,
   and `predicateType` is a type the verifier recognizes. Reject an unrecognized `predicateType`.
6. Hand the `predicate` and `subject` to the predicate-specific verification procedure (in the
   per-predicate document), which performs subject binding, vocabulary checks, any referenced
   catch-record verification, and any auditor-supplied value-binding checks.

No live execution environment is required at any step. Verification is pure cryptography plus JSON
parsing plus the verifier's configured anchors.

## Worked offline-verify example

The following illustrates a verification of a verdict envelope. Values are abbreviated; the structure
is exact.

Input envelope (`verdict.dsse.json`):

```json
{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCAiLi4uIjogIi4uLiJ9",
  "signatures": [
    {
      "keyid": "3f2a...c91d",
      "sig": "MEUCIQ...base64-ed25519-sig...=="
    }
  ]
}
```

Trust anchor (configured): a key whose raw 32-byte public key hashes to `3f2a...c91d`.

Steps:

1. `payloadType` is `application/vnd.in-toto+json`. OK.
2. `payload_bytes = base64_decode(envelope.payload)`. Keep verbatim.
3. The signature's `keyid` is `3f2a...c91d`. The configured anchor has a key whose RFC 7638 JWK
   thumbprint (hex) is `3f2a...c91d`. Resolve it. (If it were not in the anchor: reject.)
4. `pae = b"DSSEv1" + b" " + b"28" + b" " + b"application/vnd.in-toto+json" + b" " +`
   `str(len(payload_bytes)).encode() + b" " + payload_bytes`. Note `28` is the byte length of
   `application/vnd.in-toto+json`. Verify `sig` over `pae` with the resolved ed25519 key. Suppose it
   verifies. OK.
5. Parse `payload_bytes` as JSON. `_type` is the Statement v1 URI, `subject` is non-empty,
   `predicateType` is `https://getprobity.dev/predicate/v1/security-verdict` (recognized). OK.
6. Dispatch to the security-verdict procedure with the parsed `predicate` and `subject`. That
   procedure resolves the catch-record batch root, checks the subject digest against the expected
   artifact, and applies any auditor-supplied exhaustion / freshness anchors.

If every step passes, the verdict is authentic, bound to its subject artifact, and produced by a
trusted key - established without running anything the producer ran.

## Related standards

- **DSSE** - this envelope IS DSSE with PAEv1 and ed25519; no deviation.
- **in-toto attestation** - the payload is an in-toto Statement v1; subject binding and
  `predicateType` routing are used as in-toto defines them.
- **Sigstore / cosign** - a verdict can be verified by any DSSE-aware tool given the payload type and
  the trust anchor; `keyless` Sigstore identity is the deferred v2 profile, not v1.
- **TUF** - named as a v2 candidate for decentralized key distribution; not used in v1.

## Versioning

This is the v1 envelope. Versioning has **two independent axes** that MUST NOT be conflated:

- **The envelope axis.** A change to the signature algorithm, the PAE construction, the `keyid`
  derivation, or the trust model (for example, adopting a keyless profile) is an **envelope-version**
  concern. The migration of the `keyid` derivation onto the RFC 7638 JWK thumbprint (this revision) is
  exactly such a change: because the `keyid` is excluded from every signed pre-image, it re-stamps
  metadata without invalidating any signature, and it does NOT change any predicate body or schema.
- **The predicate-schema axis.** The `predicateType` namespace (`/predicate/v1/<name>`) bumps only
  when a predicate's own SCHEMA changes incompatibly. A pure envelope-axis change (such as the `keyid`
  re-derivation) does **NOT** bump the predicate-type namespace; predicates keep `/predicate/v1/...`.
  Additive optional fields in a predicate body affect neither axis.

A v1 verifier MUST reject an envelope that claims a payload type or signature scheme it does not
implement.
