72 lines
3.6 KiB
Python
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()
|