Signing data-plane requests (RFC 9421)

Every write and every profile read on the signed data-plane carries an RFC 9421 Ed25519 HTTP Message Signature under your sandbox-registry key. The sandbox reconstructs the exact same signature base and verifies key ownership plus fixture standing before processing. Sandbox standing is not independent accreditation.

This page is the canonical reference for computing the two headers — Signature-Input and Signature — in four languages. Supply a freshly self-registered synthetic identity before running the snippets.

The @openchildsafety/ocss SDK's signRequest (TypeScript) and the @phosra/gatekeeper SDK candidate can do this for you in evaluation. 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.

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:

#ComponentValue
1@methodthe HTTP method, e.g. POST
2@target-urithe full absolute URL, e.g. https://…/api/v1/enforcement-endpoints
3ocss-spec-versionthe value of the OCSS-Spec-Version header — always OCSS-v1.0-pre
4content-digestonly 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.

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.

The signature base

The base is built per RFC 9421 §2.5. For a POST with a body it looks exactly like this (the \n are real newlines):

text
"@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
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-Digestsha-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.

python
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, os, requests
# SEED  = os.environ["OCSS_SANDBOX_TEST_SEED_B64URL"]   # generate a fresh sandbox-only seed
# KEYID = os.environ["OCSS_SANDBOX_KEY_ID"]
# url  = "https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-endpoints"
# body = json.dumps({"audience_did": os.environ["OCSS_SANDBOX_DID"],
#                    "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

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.

Verify your signer

The fastest way to know your base is byte-correct is to sign a request against the hosted sandbox and watch for a 2xx. Self-register a synthetic sandbox identity, then provide the private seed through OCSS_SANDBOX_TEST_SEED_B64URL and the exact returned key_id through OCSS_SANDBOX_KEY_ID. Private-key fixtures are intentionally redacted.

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 the synthetic sandbox identity lacks the required fixture standing, or the consent-first gate is not satisfied. The full self-serve loop is:

  1. 1
    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.

  2. 2
    Bind

    A signed POST /enforcement-endpoints with that child_ref returns 201 and your endpoint_id_label. See Bind an endpoint.

  3. 3
    Poll

    A signed GET /enforcement-profiles/{label} returns 200 with the router-signed profile. See Fetch the signed profile.

These signer recipes are designed for the public sandbox. Secret-bearing values are intentionally omitted, so supply a fresh sandbox-only key before running them. If a snippet fails to verify after registration, tell us.

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 and verifies the Ed25519 signature over the base.
  3. Checks that your DID's synthetic tier field 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; sandbox standing failures are 403; neither leaks whether the underlying resource exists (§9.3(a) fail-closed). See Errors for the full catalog.