Switch Unsandbox API auth to HMAC-SHA256 with public/secret keys

Replace simple Bearer token auth with HMAC-SHA256 signature scheme:
- UNSANDBOX_PUBLIC_KEY for account identification (Bearer token)
- UNSANDBOX_SECRET_KEY for request signing (never transmitted)
- X-Timestamp header for replay attack prevention
- X-Signature header with HMAC-SHA256(secret, ts:method:path:body)
This commit is contained in:
Russell Ballestrini 2025-12-28 13:44:38 -05:00
parent 331c11e8f7
commit 0d5d2c27be
2 changed files with 74 additions and 26 deletions

View file

@ -166,12 +166,21 @@ POST https://api.unsandbox.com/run
#### Authentication #### Authentication
Use Bearer token authentication: Uses HMAC-SHA256 authentication with public/secret key pairs:
**Environment Variables:**
- `UNSANDBOX_PUBLIC_KEY` - Public key (unsb-pk-xxxx) used as Bearer token to identify account
- `UNSANDBOX_SECRET_KEY` - Secret key (unsb-sk-xxxx) used for HMAC signing, never transmitted
**Request Headers:**
``` ```
Authorization: Bearer unsb-sk-xxxx-xxxx-xxxx-xxxx Authorization: Bearer <public_key>
X-Timestamp: <unix_seconds>
X-Signature: HMAC-SHA256(secret_key, timestamp:method:path:body)
``` ```
API keys start with `unsb-sk-` prefix. The secret key is never transmitted - server verifies HMAC using its stored copy.
Timestamp must be within ±5 minutes of server time (replay attack prevention).
#### Supported Languages #### Supported Languages

85
app.py
View file

@ -1242,35 +1242,76 @@ Examples:
return jsonify({"filename": "compiled_binary"}) return jsonify({"filename": "compiled_binary"})
# Unsandbox API proxy endpoints - keeps API key server-side # Unsandbox API proxy endpoints - keeps API keys server-side
UNSANDBOX_API_URL = "https://api.unsandbox.com" UNSANDBOX_API_URL = "https://api.unsandbox.com"
def get_unsandbox_auth_headers(method, path, body=None):
"""Generate HMAC authentication headers for Unsandbox API.
Authentication scheme:
Authorization: Bearer <public_key> - identifies account
X-Timestamp: <unix_seconds> - replay prevention
X-Signature: HMAC-SHA256(secret_key, ts:method:path:body) - proves secret
The secret key is NEVER transmitted. Server verifies HMAC with stored secret.
"""
import hmac
import hashlib
import time
public_key = os.environ.get("UNSANDBOX_PUBLIC_KEY")
secret_key = os.environ.get("UNSANDBOX_SECRET_KEY")
if not public_key or not secret_key:
return None
timestamp = int(time.time())
body_str = body if body else ""
# Build message: "timestamp:method:path:body"
message = f"{timestamp}:{method}:{path}:{body_str}"
# Compute HMAC-SHA256
signature = hmac.new(
secret_key.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"Authorization": f"Bearer {public_key}",
"X-Timestamp": str(timestamp),
"X-Signature": signature,
}
@app.route("/api/code/execute", methods=["POST"]) @app.route("/api/code/execute", methods=["POST"])
def proxy_code_execute(): def proxy_code_execute():
"""Proxy code execution requests to Unsandbox API. """Proxy code execution requests to Unsandbox API.
Keeps the UNSANDBOX_API_KEY secure on the server side. Keeps UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY secure on the server side.
Uses HMAC-SHA256 authentication.
""" """
import httpx import httpx
api_key = os.environ.get("UNSANDBOX_API_KEY")
if not api_key:
return jsonify({"error": "Code execution not configured"}), 503
try: try:
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({"error": "Request body required"}), 400 return jsonify({"error": "Request body required"}), 400
body_json = json.dumps(data, separators=(",", ":"))
auth_headers = get_unsandbox_auth_headers("POST", "/execute/async", body_json)
if not auth_headers:
return jsonify({"error": "Code execution not configured"}), 503
headers = {"Content-Type": "application/json", **auth_headers}
with httpx.Client(timeout=30.0) as client: with httpx.Client(timeout=30.0) as client:
response = client.post( response = client.post(
f"{UNSANDBOX_API_URL}/execute/async", f"{UNSANDBOX_API_URL}/execute/async",
headers={ headers=headers,
"Content-Type": "application/json", content=body_json,
"Authorization": f"Bearer {api_key}",
},
json=data,
) )
# Return the response from Unsandbox # Return the response from Unsandbox
@ -1288,17 +1329,16 @@ def proxy_job_status(job_id):
"""Proxy job status requests to Unsandbox API.""" """Proxy job status requests to Unsandbox API."""
import httpx import httpx
api_key = os.environ.get("UNSANDBOX_API_KEY") path = f"/jobs/{job_id}"
if not api_key: auth_headers = get_unsandbox_auth_headers("GET", path, None)
if not auth_headers:
return jsonify({"error": "Code execution not configured"}), 503 return jsonify({"error": "Code execution not configured"}), 503
try: try:
with httpx.Client(timeout=30.0) as client: with httpx.Client(timeout=30.0) as client:
response = client.get( response = client.get(
f"{UNSANDBOX_API_URL}/jobs/{job_id}", f"{UNSANDBOX_API_URL}{path}",
headers={ headers=auth_headers,
"Authorization": f"Bearer {api_key}",
},
) )
return jsonify(response.json()), response.status_code return jsonify(response.json()), response.status_code
@ -1315,17 +1355,16 @@ def proxy_job_cancel(job_id):
"""Proxy job cancellation requests to Unsandbox API.""" """Proxy job cancellation requests to Unsandbox API."""
import httpx import httpx
api_key = os.environ.get("UNSANDBOX_API_KEY") path = f"/jobs/{job_id}"
if not api_key: auth_headers = get_unsandbox_auth_headers("DELETE", path, None)
if not auth_headers:
return jsonify({"error": "Code execution not configured"}), 503 return jsonify({"error": "Code execution not configured"}), 503
try: try:
with httpx.Client(timeout=30.0) as client: with httpx.Client(timeout=30.0) as client:
response = client.delete( response = client.delete(
f"{UNSANDBOX_API_URL}/jobs/{job_id}", f"{UNSANDBOX_API_URL}{path}",
headers={ headers=auth_headers,
"Authorization": f"Bearer {api_key}",
},
) )
# DELETE may return empty body on success # DELETE may return empty body on success