Skip to content
New kind of attack — caught and fixed before it was publicly known·Read the timeline
Probity Open Predicate Standard
/predicate/v1/signing-envelope
View raw .md →

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.

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:

{
  "_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 envelope:

{
  "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) 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 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.

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.

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 signedPAE typePAE body
In-toto Statement (verdict envelope)application/vnd.in-toto+jsonserialized Statement bytes
Individual catch-recordapplication/vnd.probity.catch-record.v2+jsonthe catch-record canonical pre-image
Catch-record batch rootapplication/vnd.probity.catch-batch-root.v1uint64-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.

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
    
    {
      "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, Sigstore 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):

{
  "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.

Get access

Run your agents through Probity.

A few details about your setup and we'll get you gating agents fast.