> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signing data-plane requests (RFC 9421)

> How to compute the Signature-Input and Signature headers the OCSS census verifies — the one hard part of the integration, with copy-paste helpers in curl+openssl, TypeScript, Python, and Go.

Every write and every profile read on the [signed data-plane](/api-reference/data-plane/overview)
carries an **[RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) Ed25519 HTTP Message Signature**
under your Trust-List-published key. The census reconstructs the exact same signature base and
verifies **provenance** (the key is yours) and **accreditation** (your DID is trusted) before it
does anything else. Get this one thing right and the rest of the integration is ordinary JSON.

This page is the canonical reference for computing the two headers — `Signature-Input` and
`Signature` — in four languages. Every snippet below was executed against the hosted sandbox and
returns a real `2xx`; see [Verify your signer](#verify-your-signer).

<Note>
  In production the `@openchildsafety/ocss` SDK's `signRequest` (TypeScript) and the
  [`@phosra/gatekeeper`](/sdks/mcp-server) SDK do this for you. The four-language recipe here is for
  **server-side signers in other stacks** (Python/Go services, edge workers, CI) and for
  understanding exactly what the wire looks like. Ed25519 signing keys are secret — keep them
  server-side, never ship a seed to a browser or mobile client.
</Note>

## The covered components

OCSS signs a **closed, ordered set** of components. Nothing else is covered, and the order is
literal — the verifier reconstructs the base in exactly this sequence:

| # | Component           | Value                                                                    |
| - | ------------------- | ------------------------------------------------------------------------ |
| 1 | `@method`           | the HTTP method, e.g. `POST`                                             |
| 2 | `@target-uri`       | the **full** absolute URL, e.g. `https://…/api/v1/enforcement-endpoints` |
| 3 | `ocss-spec-version` | the value of the `OCSS-Spec-Version` header — always `OCSS-v1.0-pre`     |
| 4 | `content-digest`    | **only when the request has a body** — the `Content-Digest` header value |

A `GET` (a profile poll) covers components 1–3. Any request with a body (bind, rotate, confirm)
adds component 4. There are no other permutations.

<Warning>
  Three traps sink most first-time signers:

  1. **Order is literal.** `@method`, `@target-uri`, `ocss-spec-version`, then `content-digest`.
     Re-ordering the covered list changes the base and fails verification.
  2. **Base64 is *standard*, not URL-safe.** The `Signature` value and the `Content-Digest` value
     both use padded standard base64 (`+`/`/`/`=`). Only the DID key material elsewhere uses
     base64url — not these two.
  3. **No trailing newline after `@signature-params`.** Every covered line ends in `\n`; the final
     `@signature-params` line has **no** trailing newline.
</Warning>

## The signature base

The base is built per [RFC 9421 §2.5](https://www.rfc-editor.org/rfc/rfc9421#section-2.5). For a
`POST` with a body it looks exactly like this (the `\n` are real newlines):

```text The exact bytes that get signed theme={null}
"@method": POST
"@target-uri": https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-endpoints
"ocss-spec-version": OCSS-v1.0-pre
"content-digest": sha-256=:NIK7mwcaIj22qhAF1stpRE6wKwKO4nZMK0PqGsjdT5A=:
"@signature-params": ("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"
```

You then **Ed25519-sign those UTF-8 bytes** and emit four headers:

```http theme={null}
OCSS-Spec-Version: OCSS-v1.0-pre
Content-Digest: sha-256=:NIK7mwcaIj22qhAF1stpRE6wKwKO4nZMK0PqGsjdT5A=:
Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"
Signature: ocss=:qAR6e0phugzYAz4APa7xY309kOeKeBQm00…:
```

* **`Content-Digest`** — `sha-256=:<standard-base64 of SHA-256(body)>:`. The digest is over the
  **exact request-body bytes you transmit**. Serialize your JSON once, then hash *and* send those
  same bytes — if you re-serialize, the digest won't match. (Omit this header entirely on a `GET`.)
* **`Signature-Input`** — the label `ocss=` followed by the same `(...)` params list used in the
  `@signature-params` base line. `created` is a Unix timestamp; the census rejects signatures
  skewed more than a few minutes, so use a monotonic clock, not a hard-coded value.
* **`Signature`** — the label `ocss=` wrapping the base64 signature between colons: `ocss=:<sig>:`.

## Copy-paste signers

Pick your stack. All four produce byte-identical headers for the same inputs and all four are
verified below against the live sandbox.

<CodeGroup>
  ```python Python (stdlib + cryptography) theme={null}
  import base64, hashlib, time
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

  SPEC_VERSION = "OCSS-v1.0-pre"

  def _b64url_decode(s: str) -> bytes:
      return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

  def sign_request(method: str, target_uri: str, key_id: str, seed_b64url: str,
                   body: bytes | None = None, created: int | None = None) -> dict[str, str]:
      """Return the OCSS RFC 9421 headers. `seed_b64url` is your 32-byte Ed25519 seed."""
      created = created or int(time.time())
      sk = Ed25519PrivateKey.from_private_bytes(_b64url_decode(seed_b64url))

      headers = {"OCSS-Spec-Version": SPEC_VERSION}
      covered = ["@method", "@target-uri", "ocss-spec-version"]
      values = {"ocss-spec-version": SPEC_VERSION}

      if body:
          cd = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
          headers["Content-Digest"] = cd
          values["content-digest"] = cd
          covered.append("content-digest")

      quoted = " ".join(f'"{c}"' for c in covered)
      params = f'({quoted});created={created};keyid="{key_id}";alg="ed25519"'
      headers["Signature-Input"] = "ocss=" + params

      lines = []
      for c in covered:
          v = method if c == "@method" else target_uri if c == "@target-uri" else values[c]
          lines.append(f'"{c}": {v}')
      base = "\n".join(lines) + "\n" + f'"@signature-params": {params}'

      sig = sk.sign(base.encode())
      headers["Signature"] = "ocss=:" + base64.b64encode(sig).decode() + ":"
      return headers

  # Usage — sign and send with requests (or urllib):
  # import json, requests
  # SEED  = "bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"   # sandbox "loopline" test seed
  # KEYID = "did:ocss:loopline#2026-06"
  # url  = "https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-endpoints"
  # body = json.dumps({"audience_did": "did:ocss:loopline",
  #                    "child_ref": "child:5ba0d00c-0000-4000-8000-0000000000c1",
  #                    "window_seconds": 3600}).encode()
  # h = sign_request("POST", url, KEYID, SEED, body); h["Content-Type"] = "application/json"
  # print(requests.post(url, data=body, headers=h).status_code)   # -> 201
  ```

  ```go Go (stdlib only) theme={null}
  package ocsssign

  import (
  	"crypto/ed25519"
  	"crypto/sha256"
  	"encoding/base64"
  	"strconv"
  	"strings"
  	"time"
  )

  const SpecVersion = "OCSS-v1.0-pre"

  // SignRequest returns the OCSS RFC 9421 headers. seedB64url is your 32-byte Ed25519 seed.
  func SignRequest(method, targetURI, keyID, seedB64url string, body []byte) (map[string]string, error) {
  	seed, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(seedB64url, "="))
  	if err != nil {
  		return nil, err
  	}
  	priv := ed25519.NewKeyFromSeed(seed)

  	h := map[string]string{"OCSS-Spec-Version": SpecVersion}
  	covered := []string{"@method", "@target-uri", "ocss-spec-version"}
  	values := map[string]string{"ocss-spec-version": SpecVersion}

  	if len(body) > 0 {
  		sum := sha256.Sum256(body)
  		cd := "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
  		h["Content-Digest"] = cd
  		values["content-digest"] = cd
  		covered = append(covered, "content-digest")
  	}

  	quoted := make([]string, len(covered))
  	for i, c := range covered {
  		quoted[i] = `"` + c + `"`
  	}
  	created := strconv.FormatInt(time.Now().Unix(), 10)
  	params := "(" + strings.Join(quoted, " ") + ");created=" + created +
  		`;keyid="` + keyID + `";alg="ed25519"`
  	h["Signature-Input"] = "ocss=" + params

  	var lines []string
  	for _, c := range covered {
  		v := values[c]
  		switch c {
  		case "@method":
  			v = method
  		case "@target-uri":
  			v = targetURI
  		}
  		lines = append(lines, `"`+c+`": `+v)
  	}
  	base := strings.Join(lines, "\n") + "\n" + `"@signature-params": ` + params

  	sig := ed25519.Sign(priv, []byte(base))
  	h["Signature"] = "ocss=:" + base64.StdEncoding.EncodeToString(sig) + ":"
  	return h, nil
  }
  ```

  ```ts TypeScript (@openchildsafety/ocss) theme={null}
  import { signRequest } from "@openchildsafety/ocss"

  const BASE  = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
  const seed  = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
  const keyID = "did:ocss:loopline#2026-06"

  async function signedPost(path: string, body: unknown) {
    const targetURI = BASE + path
    const bodyText  = JSON.stringify(body)
    const headers   = signRequest({
      method: "POST",
      targetURI,
      body: new TextEncoder().encode(bodyText),   // digest is over these exact bytes
      keyID, seed,
      created: Math.floor(Date.now() / 1000),
    })
    headers["Content-Type"] = "application/json"
    return fetch(targetURI, { method: "POST", headers, body: bodyText })
  }

  // GET (profile poll) — no body, no content-digest:
  async function signedGet(path: string) {
    const targetURI = BASE + path
    const headers   = signRequest({ method: "GET", targetURI, keyID, seed, created: Math.floor(Date.now() / 1000) })
    return fetch(targetURI, { method: "GET", headers })
  }
  ```

  ```bash curl + openssl (OpenSSL 3.0+) theme={null}
  #!/usr/bin/env bash
  # A fully signed OCSS request with nothing but bash, openssl, and curl.
  set -euo pipefail
  BASE="https://phosra-api-sandbox-production.up.railway.app/api/v1"
  SPEC="OCSS-v1.0-pre"
  KEYID="did:ocss:loopline#2026-06"
  SEED_B64URL="bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"   # 32-byte Ed25519 seed

  # 1. Wrap the raw 32-byte seed into a PKCS#8 PEM openssl can load.
  pad=$(( (4 - ${#SEED_B64URL} % 4) % 4 )); P="$SEED_B64URL"
  for _ in $(seq 1 $pad); do P="${P}="; done
  HEX=$(printf '%s' "$P" | tr '_-' '/+' | openssl base64 -d -A | xxd -p -c256)
  printf '302e020100300506032b657004220420%s' "$HEX" | xxd -r -p \
    | openssl pkey -inform DER -out /tmp/ocss_ed25519.pem

  # 2. Sign a POST with a JSON body.
  METHOD="POST"; PATH_="/enforcement-endpoints"
  BODY='{"audience_did":"did:ocss:loopline","child_ref":"child:5ba0d00c-0000-4000-8000-0000000000c1","window_seconds":3600}'
  TARGET="$BASE$PATH_"; CREATED=$(date +%s)
  DIGEST="sha-256=:$(printf '%s' "$BODY" | openssl dgst -sha256 -binary | openssl base64 -A):"
  PARAMS="(\"@method\" \"@target-uri\" \"ocss-spec-version\" \"content-digest\");created=${CREATED};keyid=\"${KEYID}\";alg=\"ed25519\""
  {
    printf '"@method": %s\n' "$METHOD"
    printf '"@target-uri": %s\n' "$TARGET"
    printf '"ocss-spec-version": %s\n' "$SPEC"
    printf '"content-digest": %s\n' "$DIGEST"
    printf '"@signature-params": %s' "$PARAMS"
  } > /tmp/ocss_base.txt
  SIG=$(openssl pkeyutl -sign -inkey /tmp/ocss_ed25519.pem -rawin -in /tmp/ocss_base.txt | openssl base64 -A)

  # 3. Send it.
  curl -sS -X "$METHOD" "$TARGET" \
    -H "Content-Type: application/json" \
    -H "OCSS-Spec-Version: $SPEC" \
    -H "Content-Digest: $DIGEST" \
    -H "Signature-Input: ocss=${PARAMS}" \
    -H "Signature: ocss=:${SIG}:" \
    -d "$BODY"
  # -> 201 {"binding_id":"…","connect_secret":"…","endpoint_id_label":"…"}
  ```
</CodeGroup>

<Note>
  The `curl + openssl` path needs **OpenSSL 3.0 or newer** (the first release with raw Ed25519
  `pkeyutl -rawin`). LibreSSL and OpenSSL 1.1 cannot sign Ed25519 from the command line — use the
  Python, Go, or TypeScript signer there.
</Note>

## Verify your signer

The fastest way to know your base is byte-correct is to sign a real request against the hosted
sandbox and watch for a `2xx`. The sandbox emitter **`loopline`** signs with a deterministic test
seed (the slug bytes `loopline` right-padded with `0x01` to 32 bytes) — it is a **test key, valid
only in the sandbox**. Use it to smoke-test, then swap in your real Trust-List seed.

A `401 signature_invalid` means the base you signed doesn't match what the census rebuilt — check
the three traps above (component order, standard vs URL-safe base64, the trailing-newline rule).
A `403` means the signature verified but your DID isn't accredited (or the consent-first gate
isn't satisfied). The full self-serve loop is:

<Steps>
  <Step title="Mint test consent (no roster edit)">
    A signed `POST /sandbox/consent-attestations` satisfies the §4.2.2 consent-first precondition
    for a seeded sandbox child and returns its `target_ref`. See
    [Mint test consent](/api-reference/data-plane/sandbox-consent-attestation).
  </Step>

  <Step title="Bind">
    A signed `POST /enforcement-endpoints` with that `child_ref` returns `201` and your
    `endpoint_id_label`. See [Bind an endpoint](/api-reference/data-plane/bind-endpoint).
  </Step>

  <Step title="Poll">
    A signed `GET /enforcement-profiles/{label}` returns `200` with the router-signed profile.
    See [Fetch the signed profile](/api-reference/data-plane/fetch-profile).
  </Step>
</Steps>

<Note>
  **These signers are proven, not aspirational.** The Python, Go, and `curl+openssl` recipes on this
  page were each run end-to-end against `https://phosra-api-sandbox-production.up.railway.app` and
  returned the real `201`/`200` responses shown across the data-plane reference pages. If a snippet
  here ever fails to verify, it is a bug in the docs — [tell us](/support).
</Note>

## What the census does with it

On receipt the census:

1. Parses `Signature-Input`, rebuilds the covered base **in the fixed order**, and re-hashes the
   body to check `Content-Digest`.
2. Resolves `keyid` to a public key on the [OCSS Trust List](/ocss/trust-framework) and verifies
   the Ed25519 signature over the base.
3. Checks that your DID's accreditation tier and the request's band/scope are permitted, plus any
   endpoint-specific precondition (e.g. an active consent attestation for a bind).

Only then does it process the request. Signature failures are `401`; trust/accreditation failures
are `403`; neither leaks whether the underlying resource exists (§9.3(a) fail-closed). See
[Errors](/errors) for the full catalog.
