Request signing
The exact string that is signed, the headers that carry it, and the rules that reject a replay or a stale clock.
Attestation proves a device once. Signing proves that each later request came from that same device and has not been edited on the way.
The headers
| Header | Value |
|---|---|
X-Vouch-Run-Token | The token from the attestation exchange. |
X-Vouch-Timestamp | Unix seconds. |
X-Vouch-Nonce | Any value, used once. |
X-Vouch-Signature | Base64url of HMAC-SHA256(run_token, canonical_string). |
The canonical string
METHOD \n path \n hex(sha256(body)) \n timestamp \n nonceFive lines joined with newlines. An empty body still contributes the hash of an empty string.
POST
/v1/sdk/opens
2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881
1789635600
3f9a1c4e8b2d6071The path excludes the query string. Proxies reorder query parameters, and a signature that breaks when they do is a support ticket rather than a security feature.
What is rejected
| Error | When |
|---|---|
signature_invalid | The signature does not match the request. |
nonce_replayed | This nonce has already been used inside the window. |
timestamp_skew | The timestamp is outside the skew window, five minutes by default. |
run_token_expired | The run token has passed its expiry. |
run_token_invalid | The token is unknown or has been revoked. |
All of them are 401. A request refused by policy rather than by signature is 403 with
device_blocked.
Two details worth knowing. The signature is compared in constant time before the nonce is spent, so an attacker cannot burn somebody else's nonce with a wrong signature. And changing any part of the request, the method, the path, a single byte of the body, changes the hash and the signature with it.
Which endpoints require it
Signing is applied per endpoint pattern, so public endpoints stay unsigned. Your policies decide where it is required, matching on the request path.
Handling expiry
Run tokens are short lived by design. On a 401 carrying run_token_expired or
run_token_invalid, attest again and retry the request once. The SDKs do this for you. Do not loop:
if the second attempt also fails, something is wrong that retrying will not fix.
Signing by hand
The SDKs sign the HTTP clients they wrap. For anything else, compute the signature yourself. In Python:
import base64, hashlib, hmac, secrets, time
def sign(run_token: str, method: str, path: str, body: bytes) -> dict[str, str]:
timestamp = str(int(time.time()))
nonce = secrets.token_hex(8)
canonical = "\n".join([
method,
path, # no query string
hashlib.sha256(body).hexdigest(),
timestamp,
nonce,
])
digest = hmac.new(run_token.encode(), canonical.encode(), hashlib.sha256).digest()
signature = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return {
"X-Vouch-Run-Token": run_token,
"X-Vouch-Timestamp": timestamp,
"X-Vouch-Nonce": nonce,
"X-Vouch-Signature": signature,
}The base64url encoding has its padding stripped.