138 lines
4.3 KiB
Python
138 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Vector Zulu — LICK Signing Module
|
|
LICK (Layered Integrity Cryptographic Keying) — HMAC-SHA256 signing
|
|
for all mutating KoreNet API calls.
|
|
|
|
Canonical format (primary):
|
|
signatureInput = canonicalJSON(payload) + "\n" + unixTimestamp
|
|
signature = hex(HMAC-SHA256(base64Decode(lickKey), UTF8(signatureInput)))
|
|
|
|
Universal format (legacy, replay protection):
|
|
signatureInput = canonicalJSON(payload) + "|" + timestamp + "|" + nonce + "|" + signerId
|
|
signature = base64(HMAC-SHA256(base64Decode(lickKey), UTF8(signatureInput)))
|
|
|
|
Per welcome pack section 5: json.dumps with separators=(',',':') and sort_keys=True.
|
|
Key is base64-encoded in Key Vault; decode to raw bytes before computing HMAC.
|
|
Timestamp freshness: server rejects if |now - x-lick-ts| > 300s (5 minutes).
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
|
|
SIGNER_ID = 'vector-zulu'
|
|
|
|
|
|
def _get_lick_key() -> bytes:
|
|
"""
|
|
Load LICK HMAC key from environment.
|
|
Key is stored as raw string (NotMyCircu$_NotMyMonkey$) — NOT base64 encoded.
|
|
Per secrets file: VECTOR_ZULU_LICK_KEY='NotMyCircu$_NotMyMonkey$'
|
|
"""
|
|
key_raw = (
|
|
os.environ.get('VECTOR_ZULU_LICK_KEY') or
|
|
os.environ.get('KORE_LICK_HMAC_KEY') or
|
|
os.environ.get('LICK_HMAC_SECRET', '')
|
|
)
|
|
if not key_raw:
|
|
raise ValueError('LICK key not configured — set VECTOR_ZULU_LICK_KEY')
|
|
|
|
# Try base64 decode first (for keys that are base64-encoded in Key Vault)
|
|
# Fall back to raw bytes if not valid base64
|
|
try:
|
|
decoded = base64.b64decode(key_raw, validate=True)
|
|
return decoded
|
|
except Exception:
|
|
# Raw string key — encode to bytes directly
|
|
return key_raw.encode('utf-8')
|
|
|
|
|
|
def canonicalize(payload: dict) -> str:
|
|
"""
|
|
Canonical JSON serialization per LICK spec.
|
|
compact JSON, no whitespace, separators=(',',':'), sort_keys=True
|
|
"""
|
|
return json.dumps(payload, separators=(',', ':'), sort_keys=True)
|
|
|
|
|
|
def sign_canonical(payload: dict, timestamp: Optional[int] = None) -> str:
|
|
"""
|
|
Canonical LICK signature (primary format).
|
|
Returns lowercase hex string.
|
|
"""
|
|
if timestamp is None:
|
|
timestamp = int(time.time())
|
|
key = _get_lick_key()
|
|
canonical = canonicalize(payload)
|
|
msg = f"{canonical}\n{timestamp}"
|
|
return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def sign_universal(payload: dict, timestamp: Optional[int] = None,
|
|
nonce: Optional[str] = None,
|
|
signer_id: str = SIGNER_ID) -> str:
|
|
"""
|
|
Universal LICK signature (legacy format with replay protection).
|
|
Returns base64-encoded string.
|
|
"""
|
|
if timestamp is None:
|
|
timestamp = int(time.time())
|
|
if nonce is None:
|
|
nonce = str(uuid.uuid4())
|
|
key = _get_lick_key()
|
|
canonical = canonicalize(payload)
|
|
msg = f"{canonical}|{timestamp}|{nonce}|{signer_id}"
|
|
raw = hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest()
|
|
return base64.b64encode(raw).decode('ascii')
|
|
|
|
|
|
def build_headers(payload: dict, variant: str = 'canonical') -> dict:
|
|
"""
|
|
Build the full set of LICK HTTP headers for a request.
|
|
Returns dict of headers to merge into request.
|
|
"""
|
|
ts = int(time.time())
|
|
nonce = str(uuid.uuid4())
|
|
sentinel_trace = str(uuid.uuid4())
|
|
idempotency = str(uuid.uuid4())
|
|
|
|
if variant == 'universal':
|
|
sig = sign_universal(payload, ts, nonce, SIGNER_ID)
|
|
else:
|
|
sig = sign_canonical(payload, ts)
|
|
|
|
return {
|
|
'x-lick-ts': str(ts),
|
|
'x-lick-sig': sig,
|
|
'x-lick-nonce': nonce,
|
|
'x-lick-signer': SIGNER_ID,
|
|
'x-lick-variant': variant,
|
|
'x-sentinel-trace': sentinel_trace,
|
|
'Idempotency-Key': idempotency,
|
|
'X-Tenant-Id': 'vector-zulu',
|
|
'Content-Type': 'application/json',
|
|
}
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import os
|
|
os.environ['VECTOR_ZULU_LICK_KEY'] = "NotMyCircu$_NotMyMonkey$"
|
|
|
|
payload = {'test': 'payload', 'amount': 1000000}
|
|
print("=== Canonical ===")
|
|
headers = build_headers(payload, 'canonical')
|
|
for k, v in headers.items():
|
|
print(f" {k}: {v[:40]}...")
|
|
|
|
print("\n=== Universal ===")
|
|
headers = build_headers(payload, 'universal')
|
|
for k, v in headers.items():
|
|
print(f" {k}: {v[:40]}...")
|