HTTP API: Signed Request Format
On this page 11 sections
Wire-level reference for the per-request ed25519 identity gate.
Browser Reference Implementation
If you are enabling the identity gate on your own origin, you do not need to re-implement the wire format below in the browser. The same client shim used by spooksystems.net and spook.systems in production is packaged as a drop-in SDK:
github.com/SpookSystems/qscs-crypto, qscs-crypto.js (~12 KB) plus the matching qscs-substrate.wasm released as a tagged asset (v20260513c is the current build, sha256 4e82d26bbecdbba0dc972d2f704183b3d543f36636b941c7a23ad4dad9906bf2).
Host both files on your own origin, include the script tag before any application code that uses fetch, and every same-origin request will be signed and gated correctly. The WASM path defaults to /wasm/qscs-substrate.wasm and is overridable via window.QSCS_WASM_URL before the script tag. The rest of this page is the wire reference for non-browser clients or for re-implementing the signer in another language.
Required Request Headers
| Header | Format | Description |
|---|---|---|
X-QSCS-Client-Uuid | 32 lowercase hex characters | Client identity. Generated by the WASM substrate on first visit; persists across reloads via IndexedDB. Required on every authenticated request, including /auth/login. |
X-QSCS-Ts | Decimal milliseconds since Unix epoch | Request timestamp. Must fall within ±60,000 ms of the node clock; otherwise the request is rejected. |
X-QSCS-Nonce | 32 lowercase hex characters | Cryptographically random per-request value. The (UUID, nonce) tuple is recorded server-side and accepted at most once. |
X-QSCS-Sig | Base64 of 64 raw bytes (88 chars including padding) | Detached ed25519 signature of the canonical message under the client's private key. |
Canonical Message
The bytes signed by X-QSCS-Sig are produced by concatenating, in order:
METHOD uppercase ASCII (e.g. GET, POST)
"
" single LF byte
HOST value of the HTTP Host header, without port for 80/443
"
"
URI request-target including query string
"
"
BODY raw request body (empty string for GET)
"
"
TS_MS decimal string matching X-QSCS-Ts
"
"
NONCE_HEX 32-char hex string matching X-QSCS-Nonce
No trailing newline. No URL-encoding of the URI. No JSON re-canonicalisation of the body. The bytes the client signs and the bytes the server reconstructs must be byte-identical.
Login Body
The POST to /auth/login carries:
{
"username": "alice",
"password": "...",
"client_uuid": "865c9393716f46a3bbd055b32bbcbf8c",
"pubkey": "<64 hex characters>"
}
The signature is verified against the supplied pubkey (proof that the requester holds the matching private key) before the credentials are forwarded to the control plane. On success, the node persists the (uuid, origin, pubkey) triple and replicates it to peers.
Subsequent Requests
Once logged in, the client signs every same-origin request with the same private key. The server looks up the stored pubkey for the supplied UUID and verifies. Requests with no signature, an unknown UUID, or a signature that fails to verify all return:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"error":"identity signature required"}
Replay Protection
- Timestamps outside the ±60 s window are rejected.
- Each (UUID, nonce) tuple is recorded in an in-memory LRU and rejected on second use.
- Nonces older than the window are pruned automatically; the cache is bounded.
Logout
POST /auth/logout is signed identically. The server uses the stored pubkey for the supplied UUID to verify the signature, then erases the (uuid, origin) row. After a successful logout, all subsequent requests with that UUID fail.
Server-Injected Header
After a request passes signature verification, QSCSCore injects:
X-QSCS-Identity: <32-hex client UUID>
before forwarding to upstream PHP. Any client-supplied X-QSCS-Identity is stripped first; PHP can therefore trust the header unconditionally. The PHP helper requireIdentityAuth() reads this header and rejects requests that lack it, providing defence-in-depth in case of nginx misconfiguration.
Curl Example
For a manual integration test (note: you must construct the signature yourself; the standard curl invocation will not sign for you):
METHOD=POST
HOST=spooksystems.net
URI=/auth/login
BODY='{"username":"alice","password":"...","client_uuid":"...","pubkey":"..."}'
TS=$(date +%s%3N)
NONCE=$(openssl rand -hex 16)
MSG="${METHOD}
${HOST}
${URI}
${BODY}
${TS}
${NONCE}"
SIG=$(printf "$MSG" | ./ed25519-sign --key=privkey.bin | base64 -w0)
curl -X POST "https://${HOST}${URI}"
-H "Content-Type: application/json"
-H "X-QSCS-Client-Uuid: $UUID"
-H "X-QSCS-Ts: $TS"
-H "X-QSCS-Nonce: $NONCE"
-H "X-QSCS-Sig: $SIG"
-d "$BODY"
The browser substrate handles all of this automatically; the snippet above is provided only for those building non-browser clients against QSCS.
Common Failure Modes
| Server log reason | Cause |
|---|---|
missing signature headers | One of the four X-QSCS-* headers is absent. Typically a client-side bootstrap error (WASM not loaded, fetch shim bypassed). |
nonce length | X-QSCS-Nonce is not exactly 32 hex characters. |
ts not numeric / ts empty / ts overflow | X-QSCS-Ts is malformed. |
ts/nonce rejected | Timestamp outside window or nonce already used. Re-issue with a fresh nonce and current timestamp. |
sig length | Decoded signature is not 64 bytes. Check base64 padding. |
signature mismatch | Signature does not verify against the stored pubkey. Most commonly the canonical message bytes differ between client and server, check method case, host (no port), URI (raw, not re-encoded), and body (raw bytes, not re-serialised JSON). |
Related
- Identity Gate, conceptual overview and lifecycle.
- API Reference, non-identity endpoints.
- qscs-crypto on GitHub, drop-in browser SDK (JS + WASM) for tenants enabling the identity gate.