diff --git a/__pycache__/identity_iot_ed25519.cpython-312.pyc b/__pycache__/identity_iot_ed25519.cpython-312.pyc new file mode 100644 index 0000000..9619549 Binary files /dev/null and b/__pycache__/identity_iot_ed25519.cpython-312.pyc differ diff --git a/config.json b/config.json new file mode 100644 index 0000000..11594e0 --- /dev/null +++ b/config.json @@ -0,0 +1,15 @@ +{ + "wifi_ssid": "YOUR_SSD", + "wifi_pass": "YOUR_PASSWORD", + "wifi_static": false, + "wifi_ip": "192.168.1.2", + "wifi_mask": "255.255.255.0", + "wifi_gw": "192.168.1.1", + "wifi_dns": "8.8.8.8", + "comm_key": "pm4u5YahhYSZJwGNzpA7QeD0F0O8R8Eo", + "comm_iv": "DPUBMeD0Jc42ZbMM", + "base_url": "https://api.parta.app/API/V1/identity_layer/identity_iot/", + "tenant_id": 1, + "node_type": "sensor", + "node_label": "pico-01" +} \ No newline at end of file diff --git a/identity_iot.py b/identity_iot.py index 6202209..0ce28f2 100644 --- a/identity_iot.py +++ b/identity_iot.py @@ -12,9 +12,9 @@ # # Usage: # from identity_iot import IdentityIoT -# identity = IdentityIoT(base_url="https://ap.parta.app/API/V1/identity_layer/identity_iot/index.php") -# await identity.ensure_registered(tenant_id=1) -# token = await identity.authenticate() +# identity = IdentityIoT(base_url="https://api.parta.app/API/V1/identity_layer/identity_iot/") +# identity.ensure_registered() +# token = identity.authenticate() # ============================================================+ import ujson @@ -23,51 +23,48 @@ import uhashlib import urandom import machine import network -import uasyncio as asyncio +import utime try: import uurequests as requests - _ASYNC = False except ImportError: import urequests as requests - _ASYNC = False -# Pure-Python Ed25519 — replaced by natmod in production from identity_iot_ed25519 import ed25519_sign, ed25519_keypair_from_seed class IdentityIoT: """ Identity IoT authentication service for MicroPython. - Device-bound Ed25519 keypair — private key never leaves the device. - Communicates with identity_layer/identity_iot PHP backend. """ - # Storage keys (on flash filesystem) - _F_SEED = '/identity_seed.bin' # 32 bytes raw seed - _F_PK = '/identity_pk.b64' # public key b64 - _F_JWT = '/identity_jwt.txt' # current JWT - _F_NODEID = '/identity_node_uid.txt' # hashed node UID + _F_SEED = '/identity_seed.bin' + _F_PK = '/identity_pk.b64' + _F_JWT = '/identity_jwt.txt' + _F_NODEID = '/identity_node_uid.txt' def __init__( self, - base_url: str, - app_id: str = 'identity_iot', - node_type: str = 'sensor', - node_label: str = None, - tenant_id: int = 0, - comm_key: bytes = None, - comm_iv: bytes = None, + base_url: str, + app_id: str = 'identity_iot', + node_type: str = 'sensor', + node_label: str = None, + tenant_id: int = 0, + comm_key: bytes = None, + comm_iv: bytes = None, + wifi_ssid: str = None, + wifi_pass: str = None, ): self.base_url = base_url self.app_id = app_id self.node_type = node_type self.node_label = node_label self.tenant_id = tenant_id - # Bootstrap AES-CBC keys — replaced after init_key if used self._comm_key = comm_key self._comm_iv = comm_iv + self._wifi_ssid = wifi_ssid + self._wifi_pass = wifi_pass # ========================================================================= # Public API @@ -80,7 +77,7 @@ class IdentityIoT: pk = self._generate_keypair() node_uid = self._get_node_uid() resp = self._post({ - 'request': 'register_node', + 'request': 'register_node', 'public_key_b64': pk, 'node_uid': node_uid, 'node_type': self.node_type, @@ -92,35 +89,25 @@ class IdentityIoT: return resp def authenticate(self): - """ - Full challenge/response flow. Returns JWT string. - Stores JWT on flash for next session. - """ - # Try existing JWT first + """Full challenge/response flow. Returns JWT string.""" jwt = self._load_jwt() if jwt and not self._is_jwt_expired(jwt): return jwt node_uid = self._get_node_uid() - # Challenge - resp = self._post({ - 'request': 'challenge', - 'node_uid': node_uid, - }) + resp = self._post({'request': 'challenge', 'node_uid': node_uid}) if resp.get('status') != 'ok': raise Exception('challenge failed: ' + str(resp)) challenge_id = resp['challenge_id'] nonce_b64 = resp['nonce_b64'] - # Sign nonce - nonce_bytes = ubinascii.a2b_base64(_normalize_b64(nonce_b64)) - seed = self._load_seed() - signature = ed25519_sign(seed, nonce_bytes) - sig_b64 = ubinascii.b2a_base64(signature).decode().strip() + nonce_bytes = ubinascii.a2b_base64(_normalize_b64(nonce_b64)) + seed = self._load_seed() + signature = ed25519_sign(seed, nonce_bytes) + sig_b64 = ubinascii.b2a_base64(signature).decode().strip() - # Verify resp = self._post({ 'request': 'verify_signature', 'challenge_id': challenge_id, @@ -136,7 +123,6 @@ class IdentityIoT: return token def get_node_uid(self): - """Return the hashed node UID (safe to share).""" return self._get_node_uid() # ========================================================================= @@ -144,11 +130,9 @@ class IdentityIoT: # ========================================================================= def _generate_keypair(self): - """Generate Ed25519 keypair from secure random seed, persist to flash.""" - seed = bytes(urandom.getrandbits(8) for _ in range(32)) - pk, _ = ed25519_keypair_from_seed(seed) + seed = bytes(urandom.getrandbits(8) for _ in range(32)) + pk, _ = ed25519_keypair_from_seed(seed) pk_b64 = ubinascii.b2a_base64(pk).decode().strip() - # Persist with open(self._F_SEED, 'wb') as f: f.write(seed) with open(self._F_PK, 'w') as f: @@ -170,25 +154,18 @@ class IdentityIoT: return None # ========================================================================= - # Node UID — device fingerprint, hashed before sending + # Node UID # ========================================================================= def _get_node_uid(self): - """ - Returns SHA-256 hex of device UID. - Cached on flash after first call. - """ try: with open(self._F_NODEID, 'r') as f: return f.read().strip() except OSError: pass - - # Pico W: machine.unique_id() returns 8 bytes raw_uid = machine.unique_id() - h = uhashlib.sha256(raw_uid) + h = uhashlib.sha256(raw_uid) uid_hex = ubinascii.hexlify(h.digest()).decode() - with open(self._F_NODEID, 'w') as f: f.write(uid_hex) return uid_hex @@ -213,65 +190,94 @@ class IdentityIoT: parts = jwt.split('.') if len(parts) != 3: return True - payload_b64 = _normalize_b64(parts[1]) + payload_b64 = _normalize_b64(parts[1]) payload_json = ubinascii.a2b_base64(payload_b64).decode() - payload = ujson.loads(payload_json) - exp = payload.get('exp', 0) - # MicroPython: use time.time() — requires NTP sync - import utime + payload = ujson.loads(payload_json) + exp = payload.get('exp', 0) return utime.time() >= exp except Exception: - return True # assume expired on any error + return True # ========================================================================= - # HTTP transport — ITEMS envelope (AES-CBC) or plain JSON + # WiFi reconnect + # ========================================================================= + + def _ensure_wifi(self): + wlan = network.WLAN(network.STA_IF) + if wlan.isconnected(): + return True + if not self._wifi_ssid: + return False + print('WiFi lost, reconnecting...') + wlan.active(True) + wlan.connect(self._wifi_ssid, self._wifi_pass) + timeout = 15 + while not wlan.isconnected() and timeout > 0: + utime.sleep(1) + timeout -= 1 + return wlan.isconnected() + + # ========================================================================= + # HTTP transport # ========================================================================= def _post(self, inner: dict) -> dict: - """ - POST to backend. - If comm_key/iv provided: ITEMS AES-CBC envelope. - Otherwise: plain JSON (for PoC / testing). - """ if self._comm_key and self._comm_iv: return self._post_encrypted(inner) return self._post_plain(inner) def _post_plain(self, inner: dict) -> dict: - """Plain JSON POST — for PoC and testing.""" - body = ujson.dumps({'data': ujson.dumps(inner)}) - resp = requests.post( - self.base_url, - headers={'Content-Type': 'application/json'}, - data=body, - ) - outer = ujson.loads(resp.text) - resp.close() - # Backend returns encrypted data — decode if possible, else return raw - if 'data' in outer: + for attempt in range(3): try: - return ujson.loads(outer['data']) - except Exception: + if not self._ensure_wifi(): + raise Exception('WiFi not connected') + body = ujson.dumps({'data': ujson.dumps(inner)}) + resp = requests.post( + self.base_url, + headers={'Content-Type': 'application/json'}, + data=body, + timeout=20 + ) + outer = ujson.loads(resp.text) + resp.close() + if 'data' in outer: + try: + return ujson.loads(outer['data']) + except Exception: + return outer return outer - return outer + except Exception as e: + print('plain retry', attempt + 1, ':', e) + utime.sleep(3) + raise Exception('POST failed after 3 attempts') def _post_encrypted(self, inner: dict) -> dict: - """ITEMS AES-CBC envelope POST.""" from identity_iot_aes import aes_cbc_encrypt, aes_cbc_decrypt - inner_json = ujson.dumps(inner) - cipher_b64 = aes_cbc_encrypt(inner_json, self._comm_key, self._comm_iv) - body = ujson.dumps({'data': cipher_b64}) - resp = requests.post( - self.base_url, - headers={'Content-Type': 'application/json'}, - data=body, - ) - outer = ujson.loads(resp.text) - resp.close() - if 'data' not in outer: - raise Exception('Missing outer.data in response') - clear = aes_cbc_decrypt(outer['data'], self._comm_key, self._comm_iv) - return ujson.loads(clear) + for attempt in range(3): + try: + if not self._ensure_wifi(): + raise Exception('WiFi not connected') + inner_json = ujson.dumps(inner) + cipher_b64 = aes_cbc_encrypt(inner_json, self._comm_key, self._comm_iv) + body = ujson.dumps({'data': cipher_b64}) + resp = requests.post( + self.base_url, + headers={'Content-Type': 'application/json'}, + data=body, + timeout=20 + ) + print('status:', resp.status_code) + print('raw:', resp.text[:100]) + outer = ujson.loads(resp.text) + resp.close() + if 'data' not in outer: + raise Exception('Missing outer.data') + clear = aes_cbc_decrypt(outer['data'], self._comm_key, self._comm_iv) + return ujson.loads(clear) + except Exception as e: + print('enc retry', attempt + 1, ':', e) + utime.sleep(3) + raise Exception('POST failed after 3 attempts') # ============================================================================= @@ -279,7 +285,6 @@ class IdentityIoT: # ============================================================================= def _normalize_b64(s: str) -> str: - """Normalize base64 string for ubinascii.""" s = s.strip().replace('\n', '').replace('\r', '').replace(' ', '') s = s.replace('-', '+').replace('_', '/') pad = len(s) % 4 diff --git a/identity_iot_ed25519.py b/identity_iot_ed25519.py index cffc935..721097f 100644 --- a/identity_iot_ed25519.py +++ b/identity_iot_ed25519.py @@ -1,163 +1,126 @@ # ============================================================+ -# Identity IoT — Ed25519 Pure Python +# Identity IoT — Ed25519 for MicroPython # (c) Copyright : ae Aeonian Engineering Limited - Hong Kong # (c) Copyright : WIDE di D. Papa - Naples - Italy # ============================================================+ -# Pure-Python Ed25519 for MicroPython PoC. -# Production: replace with natmod C implementation. -# -# Based on public domain Ed25519 reference implementation. -# Optimized for MicroPython memory constraints. +# Based on Bernstein et al. reference implementation (public domain) +# Iterative scalarmult — no recursion, safe for MicroPython stack # ============================================================+ -import uhashlib -import ubinascii +from sha512 import sha512 -# ---- Field arithmetic mod p ---- -_p = 2**255 - 19 -_q = 2**252 + 27742317777372353535851937790883648493 -_d = -121665 * pow(121666, _p - 2, _p) % _p -_I = pow(2, (_p - 1) // 4, _p) -_Gx = 15112221349535807912866137220509078750507884956996801637620 -_Gy = 46316835694926478169428394003475163141307993866256225615783 -_G = (_Gx % _p, _Gy % _p, 1, _Gx * _Gy % _p) +_q = 2**255 - 19 +_l = 2**252 + 27742317777372353535851937790883648493 +_d = -121665 * pow(121666, _q - 2, _q) % _q +_I = pow(2, (_q - 1) // 4, _q) -def _sha512(data: bytes) -> bytes: - # MicroPython uhashlib has sha256 but not sha512 - # Use double-sha256 as fallback — NOT cryptographically equivalent - # TODO: replace with proper sha512 natmod for production - h1 = uhashlib.sha256(data).digest() - h2 = uhashlib.sha256(h1 + data).digest() - return h1 + h2 +def _xrecover(y): + x2 = (y * y - 1) * pow(_d * y * y + 1, _q - 2, _q) + x = pow(x2, (_q + 3) // 8, _q) + if (x * x - x2) % _q != 0: + x = x * _I % _q + if x % 2 != 0: + x = _q - x + return x -def _clamp(h: bytes) -> int: - a = bytearray(h[:32]) - a[0] &= 248 - a[31] &= 127 - a[31] |= 64 - return int.from_bytes(a, 'little') +_By = 4 * pow(5, _q - 2, _q) % _q +_Bx = _xrecover(_By) +_B = [_Bx % _q, _By % _q, 1, _Bx * _By % _q] -def _point_add(P, Q): +def _edwards_add(P, Q): x1, y1, z1, t1 = P x2, y2, z2, t2 = Q - A = (y1 - x1) * (y2 - x2) % _p - B = (y1 + x1) * (y2 + x2) % _p - C = 2 * t1 * t2 * _d % _p - D = 2 * z1 * z2 % _p - E = B - A - F = D - C - G_ = D + C - H = B + A - x3 = E * F % _p - y3 = G_ * H % _p - t3 = E * H % _p - z3 = F * G_ % _p - return (x3, y3, z3, t3) + A = (y1 - x1) * (y2 - x2) % _q + B = (y1 + x1) * (y2 + x2) % _q + C = t1 * 2 * _d * t2 % _q + D = z1 * 2 * z2 % _q + E = B - A; F = D - C; G = D + C; H = B + A + return [E * F % _q, G * H % _q, F * G % _q, E * H % _q] -def _point_mul(s, P): - Q = (0, 1, 1, 0) - while s > 0: - if s & 1: - Q = _point_add(Q, P) - P = _point_add(P, P) - s >>= 1 +def _scalarmult(P, e): + # Iterative double-and-add — no recursion + Q = [0, 1, 1, 0] # neutral element + while e > 0: + if e & 1: + Q = _edwards_add(Q, P) + P = _edwards_add(P, P) + e >>= 1 return Q -def _point_encode(P) -> bytes: +def _encodeint(y): + bits = [(y >> i) & 1 for i in range(256)] + return bytes([sum([bits[i * 8 + j] << j for j in range(8)]) for i in range(32)]) + + +def _encodepoint(P): x, y, z, _ = P - zi = pow(z, _p - 2, _p) - x = x * zi % _p - y = y * zi % _p - out = bytearray(y.to_bytes(32, 'little')) - out[31] ^= (x & 1) << 7 - return bytes(out) + zi = pow(z, _q - 2, _q) + x = x * zi % _q + y = y * zi % _q + bits = [(y >> i) & 1 for i in range(255)] + [x & 1] + return bytes([sum([bits[i * 8 + j] << j for j in range(8)]) for i in range(32)]) -def _point_decode(s: bytes): - y = int.from_bytes(s, 'little') & ~(1 << 255) - x_neg = s[31] >> 7 - y2 = y * y % _p - u = (y2 - 1) % _p - v = (_d * y2 + 1) % _p - x = pow(u * pow(v, _p - 2, _p), (_p + 3) // 8, _p) - vx2 = v * x * x % _p - if (vx2 - u) % _p != 0: - if (vx2 + u) % _p != 0: - raise ValueError('Invalid point') - x = x * _I % _p - if x % 2 != x_neg: - x = _p - x - return (x, y, 1, x * y % _p) +def _bit(h, i): + return (h[i // 8] >> (i % 8)) & 1 +def _hint(m): + return int.from_bytes(sha512(m), 'little') + + +def _decodepoint(s): + y = sum(_bit(s, i) * 2 ** i for i in range(255)) + x = _xrecover(y) + if x & 1 != _bit(s, 255): + x = _q - x + return [x, y, 1, x * y % _q] + + +# ============================================================ +# Public API +# ============================================================ + def ed25519_keypair_from_seed(seed: bytes): - """ - Derive Ed25519 keypair from 32-byte seed. - Returns (public_key_bytes, private_key_bytes). - """ if len(seed) != 32: raise ValueError('Seed must be 32 bytes') - h = _sha512(seed) - a = _clamp(h) - A = _point_mul(a, _G) - pk = _point_encode(A) - sk = seed + pk # 64 bytes: seed || pubkey - return pk, sk + h = sha512(seed) + a = 2 ** 254 + sum(2 ** i * _bit(h, i) for i in range(3, 254)) + A = _scalarmult(_B, a) + pk = _encodepoint(A) + return pk, seed + pk def ed25519_sign(seed: bytes, message: bytes) -> bytes: - """ - Sign message with Ed25519 private key derived from seed. - Returns 64-byte signature. - Compatible with PHP ITEMSSignature::verifyDetached(). - """ if len(seed) != 32: raise ValueError('Seed must be 32 bytes') - h = _sha512(seed) - a = _clamp(h) - pk_bytes, _ = ed25519_keypair_from_seed(seed) - - # Nonce: SHA512(h[32:64] || message) - r_hash = _sha512(h[32:] + message) - r = int.from_bytes(r_hash, 'little') % _q - R = _point_mul(r, _G) - R_enc = _point_encode(R) - - # k = SHA512(R || pk || message) - k_hash = _sha512(R_enc + pk_bytes + message) - k = int.from_bytes(k_hash, 'little') % _q - - # s = (r + k * a) mod q - s = (r + k * a) % _q - S = s.to_bytes(32, 'little') - - return R_enc + S + h = sha512(seed) + a = 2 ** 254 + sum(2 ** i * _bit(h, i) for i in range(3, 254)) + pk = ed25519_keypair_from_seed(seed)[0] + r = _hint(h[32:64] + message) + R = _scalarmult(_B, r) + S = (r + _hint(_encodepoint(R) + pk + message) * a) % _l + return _encodepoint(R) + _encodeint(S) -def ed25519_verify(pk_bytes: bytes, message: bytes, signature: bytes) -> bool: - """ - Verify Ed25519 signature. - Returns True if valid. - """ - if len(signature) != 64 or len(pk_bytes) != 32: +def ed25519_verify(pk: bytes, message: bytes, signature: bytes) -> bool: + if len(signature) != 64 or len(pk) != 32: return False try: R_enc = signature[:32] S = int.from_bytes(signature[32:], 'little') - if S >= _q: + if S >= _l: return False - A = _point_decode(pk_bytes) - R = _point_decode(R_enc) - k_hash = _sha512(R_enc + pk_bytes + message) - k = int.from_bytes(k_hash, 'little') % _q - # Check: [8][S]B == [8]R + [8][k]A - SB = _point_mul(8 * S, _G) - RkA = _point_add(_point_mul(8, R), _point_mul(8 * k, A)) - return _point_encode(SB) == _point_encode(RkA) + A = _decodepoint(pk) + R = _decodepoint(R_enc) + h = _hint(R_enc + pk + message) + lhs = _scalarmult(_B, 8 * S) + rhs = _edwards_add(_scalarmult(R, 8), _scalarmult(A, 8 * h)) + return _encodepoint(lhs) == _encodepoint(rhs) except Exception: return False diff --git a/sha512.py b/sha512.py new file mode 100644 index 0000000..4e46143 --- /dev/null +++ b/sha512.py @@ -0,0 +1,87 @@ +# SHA-512 pure Python for MicroPython +# Based on public domain implementation +# Compact version optimized for MicroPython memory constraints + +_K = [ + 0x428a2f98d728ae22, 0x7137449123ef65cd, 0xb5c0fbcfec4d3b2f, 0xe9b5dba58189dbbc, + 0x3956c25bf348b538, 0x59f111f1b605d019, 0x923f82a4af194f9b, 0xab1c5ed5da6d8118, + 0xd807aa98a3030242, 0x12835b0145706fbe, 0x243185be4ee4b28c, 0x550c7dc3d5ffb4e2, + 0x72be5d74f27b896f, 0x80deb1fe3b1696b1, 0x9bdc06a725c71235, 0xc19bf174cf692694, + 0xe49b69c19ef14ad2, 0xefbe4786384f25e3, 0x0fc19dc68b8cd5b5, 0x240ca1cc77ac9c65, + 0x2de92c6f592b0275, 0x4a7484aa6ea6e483, 0x5cb0a9dcbd41fbd4, 0x76f988da831153b5, + 0x983e5152ee66dfab, 0xa831c66d2db43210, 0xb00327c898fb213f, 0xbf597fc7beef0ee4, + 0xc6e00bf33da88fc2, 0xd5a79147930aa725, 0x06ca6351e003826f, 0x142929670a0e6e70, + 0x27b70a8546d22ffc, 0x2e1b21385c26c926, 0x4d2c6dfc5ac42aed, 0x53380d139d95b3df, + 0x650a73548baf63de, 0x766a0abb3c77b2a8, 0x81c2c92e47edaee6, 0x92722c851482353b, + 0xa2bfe8a14cf10364, 0xa81a664bbc423001, 0xc24b8b70d0f89791, 0xc76c51a30654be30, + 0xd192e819d6ef5218, 0xd69906245565a910, 0xf40e35855771202a, 0x106aa07032bbd1b8, + 0x19a4c116b8d2d0c8, 0x1e376c085141ab53, 0x2748774cdf8eeb99, 0x34b0bcb5e19b48a8, + 0x391c0cb3c5c95a63, 0x4ed8aa4ae3418acb, 0x5b9cca4f7763e373, 0x682e6ff3d6b2b8a3, + 0x748f82ee5defb2fc, 0x78a5636f43172f60, 0x84c87814a1f0ab72, 0x8cc702081a6439ec, + 0x90befffa23631e28, 0xa4506cebde82bde9, 0xbef9a3f7b2c67915, 0xc67178f2e372532b, + 0xca273eceea26619c, 0xd186b8c721c0c207, 0xeada7dd6cde0eb1e, 0xf57d4f7fee6ed178, + 0x06f067aa72176fba, 0x0a637dc5a2c898a6, 0x113f9804bef90dae, 0x1b710b35131c471b, + 0x28db77f523047d84, 0x32caab7b40c72493, 0x3c9ebe0a15c9bebc, 0x431d67c49c100d4c, + 0x4cc5d4becb3e42b6, 0x597f299cfc657e2a, 0x5fcb6fab3ad6faec, 0x6c44198c4a475817, +] + +_M64 = 0xffffffffffffffff + +def _rotr64(x, n): + return ((x >> n) | (x << (64 - n))) & _M64 + +def _sha512_compress(state, block): + w = list(block) + for i in range(16, 80): + s0 = _rotr64(w[i-15], 1) ^ _rotr64(w[i-15], 8) ^ (w[i-15] >> 7) + s1 = _rotr64(w[i-2], 19) ^ _rotr64(w[i-2], 61) ^ (w[i-2] >> 6) + w.append((w[i-16] + s0 + w[i-7] + s1) & _M64) + + a, b, c, d, e, f, g, h = state + + for i in range(80): + S1 = _rotr64(e, 14) ^ _rotr64(e, 18) ^ _rotr64(e, 41) + ch = (e & f) ^ ((~e & _M64) & g) + temp1 = (h + S1 + ch + _K[i] + w[i]) & _M64 + S0 = _rotr64(a, 28) ^ _rotr64(a, 34) ^ _rotr64(a, 39) + maj = (a & b) ^ (a & c) ^ (b & c) + temp2 = (S0 + maj) & _M64 + + h = g; g = f; f = e + e = (d + temp1) & _M64 + d = c; c = b; b = a + a = (temp1 + temp2) & _M64 + + return [ + (state[0] + a) & _M64, (state[1] + b) & _M64, + (state[2] + c) & _M64, (state[3] + d) & _M64, + (state[4] + e) & _M64, (state[5] + f) & _M64, + (state[6] + g) & _M64, (state[7] + h) & _M64, + ] + +def sha512(data: bytes) -> bytes: + # Initial hash values + state = [ + 0x6a09e667f3bcc908, 0xbb67ae8584caa73b, + 0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1, + 0x510e527fade682d1, 0x9b05688c2b3e6c1f, + 0x1f83d9abfb41bd6b, 0x5be0cd19137e2179, + ] + + msg = bytearray(data) + length = len(data) * 8 + msg.append(0x80) + while len(msg) % 128 != 112: + msg.append(0x00) + # Append length as 128-bit big-endian (high 64 bits are 0) + msg += (0).to_bytes(8, 'big') + msg += length.to_bytes(8, 'big') + + for i in range(0, len(msg), 128): + block = [] + for j in range(0, 128, 8): + block.append(int.from_bytes(msg[i+j:i+j+8], 'big')) + state = _sha512_compress(state, block) + + return b''.join(s.to_bytes(8, 'big') for s in state) +