Files
story/scripts/onboarding-request.py
T
2026-09-12 08:57:29 +08:00

72 lines
3.6 KiB
Python

"""DEVREQ1 call helper. Keys stay in a user-specified local file, never argv.
Prints signed headers by default; --send explicitly submits one HTTPS request.
The caller keeps request-id + original body on retries and changes nonce/time.
"""
import argparse
import base64
import hashlib
import hmac
import json
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
def sign(key, method, path, device_id, key_version, timestamp, nonce, request_id, body):
canonical = '\n'.join(['DEVREQ1', method.upper(), path, device_id, str(key_version), str(timestamp), nonce, request_id, hashlib.sha256(body).hexdigest()])
return hmac.new(key, canonical.encode('utf-8'), hashlib.sha256).hexdigest(), canonical
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--device-id', required=True)
parser.add_argument('--key-file', type=Path, required=True, help='file containing Base64URL (unpadded) encoded raw32 key')
parser.add_argument('--key-version', type=int, default=1)
parser.add_argument('--path', required=True)
parser.add_argument('--body-file', type=Path, required=True)
parser.add_argument('--request-id', required=True)
parser.add_argument('--method', default='POST', choices=['POST', 'GET'])
parser.add_argument('--send', help='explicit HTTPS origin, e.g. https://saas.example')
args = parser.parse_args()
if not args.path.startswith('/') or '?' in args.path or '#' in args.path or '\n' in args.path:
parser.error('path must be absolute without query or fragment')
if str(uuid.UUID(args.request_id)) != args.request_id or uuid.UUID(args.request_id).version != 4:
parser.error('request-id must be canonical UUID v4')
encoded = args.key_file.read_text(encoding='utf-8').strip()
key = base64.urlsafe_b64decode(encoded + '=' * (-len(encoded) % 4))
if len(key) != 32:
parser.error('device key must decode to 32 bytes')
body = args.body_file.read_bytes()
if len(body) > 8192 or body.startswith(b'\xef\xbb\xbf'):
parser.error('body must be <=8KiB UTF-8 without BOM')
json.loads(body.decode('utf-8'))
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).decode().rstrip('=')
timestamp = int(time.time() * 1000)
signature, _ = sign(key, args.method, args.path, args.device_id, args.key_version, timestamp, nonce, args.request_id, body)
headers = {'Authorization': 'Device-HMAC-SHA256 ' + signature, 'X-Device-ID': args.device_id, 'X-Key-Version': str(args.key_version), 'X-Timestamp-Ms': str(timestamp), 'X-Nonce': nonce, 'X-Request-ID': args.request_id, 'Content-Type': 'application/json'}
if not args.send:
print(json.dumps({'method': args.method, 'path': args.path, 'headers': headers}, ensure_ascii=False, indent=2))
return
origin = urllib.parse.urlsplit(args.send)
if origin.scheme != 'https' or not origin.netloc or origin.username or origin.query or origin.fragment or origin.path not in ('', '/'):
parser.error('--send must be a fixed HTTPS origin')
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *unused):
return None
request = urllib.request.Request(args.send.rstrip('/') + args.path, data=body, headers=headers, method=args.method)
try:
with urllib.request.build_opener(NoRedirect).open(request, timeout=30) as response:
print(response.read(65536).decode('utf-8'))
except urllib.error.HTTPError as error:
print(error.read(65536).decode('utf-8'))
raise SystemExit(error.code)
if __name__ == '__main__':
main()