Qirava DMS docs
HMAC-signed API keys & replay guard
For programmatic access without server-side session state, Qirava supports stateless signed requests. The client signs every individual request with its key's signing secret; the server (the auth.signed before-gate) recomputes the HMAC over a fixed canonical string, accepts only if the signature matches, the request timestamp is within +-5 minutes of the server clock, and the nonce has not been seen before within that window. The signing secret is a symmetric HMAC-SHA256 key returned once at key-mint time. Replay is blocked by a time-bucketed nonce guard that cannot be flooded into evicting a still-valid nonce.
Why sign each request#
A bearer key sent on every request (the auth.api_key path) is vulnerable if it leaks in transit or a log. Signed requests never transmit the secret: the client holds the sign_secret and sends only a signature derived from it plus the request contents. An interceptor cannot replay the request outside the +-5-minute window, and cannot replay it at all within the window because the nonce is single-use. ML-DSA / post-quantum signing is a planned drop-in behind the same sign_request / verify_request seam.
- sign_secret
- A 32-byte (64 hex) random symmetric secret minted with the API key and returned ONCE. Stored AS-IS in
_sys_api_keys.sign_secret(HMAC verification needs the raw key material, so it cannot be one-way hashed). To be encrypted at rest under the master seed once the KMS lands. - key_id
- The public identifier of the key (its
id), sent in the clear as theX-Key-Idheader so the server can look up the matchingsign_secret. - SIGNED_SKEW_SECS
- Allowed clock skew: 300 seconds (+-5 minutes). A request whose timestamp is further than this from the server clock is rejected.
The canonical request string#
Both sides sign exactly the same canonical string, built by qcrypto::canonical_request. It is six fields, one per line, newline-joined. The method is upper-cased so casing cannot fork the signature; the query string is the canonically SORTED k=v pairs joined by & (via qcrypto::sort_query, lexicographic by key then value); the body is represented by its lowercase-hex SHA-256.
<METHOD>
<path>
<sorted_query>
<body_sha256_hex>
<timestamp>
<nonce>- METHOD — the HTTP method, upper-cased (e.g. POST).
- path — the request path (e.g. /api/qql).
- sorted_query — query params sorted lexicographically by key then value, joined
k=v&k=v; empty string if none. - body_sha256_hex — lowercase hex SHA-256 of the raw request body; use the SHA-256 of the empty string for bodyless requests.
- timestamp — seconds since epoch, as a decimal string (sent as X-Timestamp).
- nonce — a unique, single-use value per request (sent as X-Nonce).
The signature is hmac_sha256_hex(sign_secret, canonical) — lowercase hex. The client sends it as the X-Signature header.
Required headers#
| Header | Required | Purpose |
|---|---|---|
| X-Key-Id | yes | The key's id — used to look up the signing secret + scope + grants. |
| X-Timestamp | yes | Request time, seconds since epoch (decimal string). Must be within +-300s of the server clock. |
| X-Nonce | yes | Unique per request; single-use within the skew window (replay guard). |
| X-Signature | yes | Lowercase-hex HMAC-SHA256 of the canonical string under the signing secret. |
How the server verifies (auth.signed)#
auth.signed (key auth.signed) is a before-gate. Its verification order matters for security:
- Read X-Key-Id, X-Timestamp, X-Nonce, X-Signature; if any is empty → 403 "missing signing headers".
- Parse the timestamp; non-numeric → 403 "bad timestamp".
- Skew check: if abs(now - ts) > 300 → 403 "timestamp out of skew".
- Look up the key: SELECT sign_secret, scope, table_grants FROM _sys_api_keys WHERE id = :id AND enabled = true. No row → 403 "unknown key id". Empty sign_secret → 403 "key not signing-enabled".
- Rebuild the canonical string from the request's method, path, sorted query, body-hash, timestamp, and nonce, then verify the HMAC in constant time. Mismatch → 403 "bad signature".
- ONLY AFTER the signature verifies: check the nonce against the replay guard. Already seen for its timestamp bucket → 403 "nonce replay".
- On success: inject { api_key_scope, table_grants } into ctx for downstream table-grant checks.
The replay guard (time-bucketed nonce store)#
The replay guard is process-local (single-leader model) and lives in the worker funnel, not the durable store. It is NOT a fixed-capacity LRU. Nonces are bucketed by the skew window of their OWN request timestamp: bucket index = ts / 300. Each bucket holds the set of nonces seen with a timestamp in that bucket. On every call the guard prunes buckets that can no longer hold an in-skew timestamp (anything older than now - 300).
Because a nonce lives in the bucket determined by its OWN timestamp, no volume of other nonces can evict it before its bucket ages out of the skew window. So a flood of distinct nonces can never push a still-valid nonce out to enable a replay. Memory is bounded by the number of distinct nonces seen across the small, constant set of live buckets, and self-trims as time advances.
End-to-end example#
To sign a POST /api/qql with body SELECT name FROM users and no query params, the client builds the canonical string and signs it. Note <empty> is the empty sorted-query line, and the body line is the SHA-256 hex of the body bytes.
Request
# 1) Canonical string the client signs (sorted_query line is empty):
POST
/api/qql
<sha256_hex of "SELECT name FROM users">
1750000000
7b1f9c2e-...-nonce
# signature = hmac_sha256_hex(sign_secret, canonical)
# 2) The HTTP request:
POST /api/qql HTTP/1.1
Host: localhost:8080
X-Key-Id: a1b2c3d4
X-Timestamp: 1750000000
X-Nonce: 7b1f9c2e-...-nonce
X-Signature: 9f2a...e71c
SELECT name FROM usersResponse
{
"error": null,
"data": [ { "name": "alice" }, { "name": "bob" } ],
"root": { "took_us": 0, "request_id": "…" }
}Output
On a bad/expired signature or a replayed nonce the gate short-circuits before the handler:
{ "error": { "code": "forbidden", "message": "nonce replay" }, "data": null, "root": { … } }To require signed access on the qql routes, front them with the signed-key group (signed_key_group), which chains auth.signed then auth.check_table_grant so the verified key's scope + table grants are enforced per statement. Admin-scoped keys bypass the table grant; non-admin keys are deny-by-default (see Table-level RBAC: grants CSV & deny-by-default).