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:
#
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.
Three traps sink most first-time signers:
Order is literal.@method, @target-uri, ocss-spec-version, then content-digest.
Re-ordering the covered list changes the base and fails verification.
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.
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):
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.
python
import base64, hashlib, timefrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKeySPEC_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
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
Bind
A signed POST /enforcement-endpoints with that child_ref returns 201 and your
endpoint_id_label. See Bind an endpoint.
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:
Parses Signature-Input, rebuilds the covered base in the fixed order, and re-hashes the
body to check Content-Digest.
Resolves keyid to a public key on the OCSS Trust List and verifies
the Ed25519 signature over the base.
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.