Skip to content

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 the X-Key-Id header so the server can look up the matching sign_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.

text
<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#

HeaderRequiredPurpose
X-Key-IdyesThe key's id — used to look up the signing secret + scope + grants.
X-TimestampyesRequest time, seconds since epoch (decimal string). Must be within +-300s of the server clock.
X-NonceyesUnique per request; single-use within the skew window (replay guard).
X-SignatureyesLowercase-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:

  1. Read X-Key-Id, X-Timestamp, X-Nonce, X-Signature; if any is empty → 403 "missing signing headers".
  2. Parse the timestamp; non-numeric → 403 "bad timestamp".
  3. Skew check: if abs(now - ts) > 300 → 403 "timestamp out of skew".
  4. 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".
  5. 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".
  6. ONLY AFTER the signature verifies: check the nonce against the replay guard. Already seen for its timestamp bucket → 403 "nonce replay".
  7. 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 users

Response

{
  "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).