glacis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
glacis/wasm_runtime.py ADDED
@@ -0,0 +1,519 @@
1
+ """
2
+ WASM runtime for offline cryptographic operations.
3
+
4
+ Uses wasmtime-py to execute the s3p-core WASM module compiled for WASI target.
5
+ Falls back to PyNaCl if WASM runtime fails.
6
+
7
+ This provides Ed25519 signing and verification without reimplementing crypto in Python.
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import ctypes
13
+ from base64 import b64encode
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+
18
+ class WasmRuntimeError(Exception):
19
+ """Error from WASM runtime operations."""
20
+
21
+ pass
22
+
23
+
24
+ class PyNaClRuntime:
25
+ """
26
+ Pure Python Ed25519 runtime using PyNaCl (libsodium bindings).
27
+
28
+ Used as a fallback when WASM runtime is unavailable or fails.
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ try:
33
+ import nacl.signing
34
+ import nacl.encoding
35
+ self._nacl_signing = nacl.signing
36
+ self._nacl_encoding = nacl.encoding
37
+ except ImportError:
38
+ raise WasmRuntimeError(
39
+ "PyNaCl not installed. Install with: pip install pynacl"
40
+ )
41
+
42
+ def derive_public_key(self, seed: bytes) -> bytes:
43
+ """Derive Ed25519 public key from a 32-byte seed."""
44
+ if len(seed) != 32:
45
+ raise ValueError("Seed must be exactly 32 bytes")
46
+ signing_key = self._nacl_signing.SigningKey(seed)
47
+ return bytes(signing_key.verify_key)
48
+
49
+ def get_public_key_hex(self, seed: bytes) -> str:
50
+ """Get hex-encoded public key from a 32-byte seed."""
51
+ return self.derive_public_key(seed).hex()
52
+
53
+ def sha256(self, data: bytes) -> bytes:
54
+ """Compute SHA-256 hash."""
55
+ return hashlib.sha256(data).digest()
56
+
57
+ def ed25519_sign(self, seed: bytes, message: bytes) -> bytes:
58
+ """Sign a message with Ed25519."""
59
+ if len(seed) != 32:
60
+ raise ValueError("Seed must be exactly 32 bytes")
61
+ signing_key = self._nacl_signing.SigningKey(seed)
62
+ signed = signing_key.sign(message)
63
+ return signed.signature # Just the 64-byte signature
64
+
65
+ def ed25519_verify(
66
+ self, public_key: bytes, message: bytes, signature: bytes
67
+ ) -> bool:
68
+ """Verify an Ed25519 signature."""
69
+ if len(public_key) != 32:
70
+ raise ValueError("Public key must be exactly 32 bytes")
71
+ if len(signature) != 64:
72
+ raise ValueError("Signature must be exactly 64 bytes")
73
+ try:
74
+ verify_key = self._nacl_signing.VerifyKey(public_key)
75
+ verify_key.verify(message, signature)
76
+ return True
77
+ except Exception:
78
+ return False
79
+
80
+ def sign_attestation_json(self, seed: bytes, attestation_json: str) -> str:
81
+ """Sign an attestation JSON and return SignedAttestation JSON."""
82
+ if len(seed) != 32:
83
+ raise ValueError("Seed must be exactly 32 bytes")
84
+
85
+ json_bytes = attestation_json.encode("utf-8")
86
+ signature = self.ed25519_sign(seed, json_bytes)
87
+ signature_b64 = b64encode(signature).decode("ascii")
88
+
89
+ # Parse and re-serialize to ensure valid JSON
90
+ payload = json.loads(attestation_json)
91
+ return json.dumps({
92
+ "payload": payload,
93
+ "signature": signature_b64,
94
+ }, separators=(",", ":"))
95
+
96
+
97
+ class WasmRuntime:
98
+ """
99
+ Singleton WASM runtime for Ed25519 cryptographic operations.
100
+
101
+ Loads the s3p_core_wasi.wasm module and provides Python-friendly wrappers
102
+ around the low-level C-ABI functions.
103
+ """
104
+
105
+ _instance: Optional["WasmRuntime"] = None
106
+
107
+ def __init__(self) -> None:
108
+ """Initialize the WASM runtime and load the s3p-core module."""
109
+ try:
110
+ import wasmtime
111
+ except ImportError:
112
+ raise WasmRuntimeError("wasmtime not installed. Install with: pip install wasmtime")
113
+
114
+ # Create engine and linker
115
+ self._engine = wasmtime.Engine()
116
+ self._linker = wasmtime.Linker(self._engine)
117
+ self._linker.define_wasi()
118
+
119
+ # Load WASM module from package
120
+ wasm_path = Path(__file__).parent / "wasm" / "s3p_core_wasi.wasm"
121
+ if not wasm_path.exists():
122
+ raise WasmRuntimeError(f"WASM module not found at {wasm_path}")
123
+
124
+ self._module = wasmtime.Module.from_file(self._engine, str(wasm_path))
125
+
126
+ # Create store with WASI config
127
+ wasi_config = wasmtime.WasiConfig()
128
+ wasi_config.inherit_stdout()
129
+ wasi_config.inherit_stderr()
130
+
131
+ self._store = wasmtime.Store(self._engine)
132
+ self._store.set_wasi(wasi_config)
133
+
134
+ # Instantiate the module
135
+ self._wasm_instance = self._linker.instantiate(self._store, self._module)
136
+
137
+ # Initialize WASI Reactor if present
138
+ exports = self._wasm_instance.exports(self._store)
139
+ if "_initialize" in exports:
140
+ exports["_initialize"](self._store)
141
+
142
+ # Get memory and exported functions
143
+ self._memory = exports["memory"]
144
+ self._alloc = exports["wasi_alloc"]
145
+ self._dealloc = exports["wasi_dealloc"]
146
+ self._derive_public_key = exports["wasi_derive_public_key"]
147
+ self._sha256 = exports["wasi_sha256"]
148
+ self._hash_canonical_json = exports["wasi_hash_canonical_json"]
149
+ self._ed25519_sign = exports["wasi_ed25519_sign"]
150
+ self._ed25519_verify = exports["wasi_ed25519_verify"]
151
+ self._sign_attestation_json = exports["wasi_sign_attestation_json"]
152
+ self._get_public_key_hex = exports["wasi_get_public_key_hex"]
153
+
154
+ @classmethod
155
+ def get_instance(cls, use_wasm: bool = False) -> "WasmRuntime":
156
+ """
157
+ Get or create the singleton runtime instance.
158
+
159
+ Args:
160
+ use_wasm: If True, try WASM runtime first. If False (default),
161
+ use PyNaCl directly for better compatibility.
162
+
163
+ Falls back to PyNaCl if WASM runtime initialization fails.
164
+ """
165
+ if cls._instance is None:
166
+ if use_wasm:
167
+ try:
168
+ cls._instance = WasmRuntime()
169
+ except Exception:
170
+ # WASM failed, use PyNaCl fallback
171
+ cls._instance = PyNaClRuntime() # type: ignore[assignment]
172
+ else:
173
+ # Use PyNaCl by default for better compatibility
174
+ cls._instance = PyNaClRuntime() # type: ignore[assignment]
175
+ return cls._instance
176
+
177
+ def _write_bytes(self, data: bytes) -> int:
178
+ """Allocate WASM memory and write bytes, returning the pointer."""
179
+ size = len(data)
180
+ ptr = self._alloc(self._store, size)
181
+ if ptr == 0:
182
+ raise WasmRuntimeError("Failed to allocate WASM memory")
183
+
184
+ # Write data to WASM memory using ctypes
185
+ mem_ptr = self._memory.data_ptr(self._store)
186
+ base_addr = ctypes.addressof(mem_ptr.contents)
187
+ dest_addr = base_addr + ptr
188
+
189
+ ctypes.memmove(dest_addr, data, size)
190
+
191
+ return ptr
192
+
193
+ def _read_bytes(self, ptr: int, size: int) -> bytes:
194
+ """Read bytes from WASM memory."""
195
+ mem_ptr = self._memory.data_ptr(self._store)
196
+ base_addr = ctypes.addressof(mem_ptr.contents)
197
+ src_addr = base_addr + ptr
198
+
199
+ return ctypes.string_at(src_addr, size)
200
+
201
+ def _free(self, ptr: int, size: int) -> None:
202
+ """Free WASM memory."""
203
+ if ptr != 0 and size > 0:
204
+ self._dealloc(self._store, ptr, size)
205
+
206
+ def derive_public_key(self, seed: bytes) -> bytes:
207
+ """
208
+ Derive Ed25519 public key from a 32-byte seed.
209
+
210
+ Args:
211
+ seed: 32-byte Ed25519 signing seed
212
+
213
+ Returns:
214
+ 32-byte public key
215
+
216
+ Raises:
217
+ WasmRuntimeError: If derivation fails
218
+ ValueError: If seed is not 32 bytes
219
+ """
220
+ if len(seed) != 32:
221
+ raise ValueError("Seed must be exactly 32 bytes")
222
+
223
+ # Allocate memory for input and output
224
+ seed_ptr = self._write_bytes(seed)
225
+ out_ptr = self._alloc(self._store, 32)
226
+ if out_ptr == 0:
227
+ self._free(seed_ptr, 32)
228
+ raise WasmRuntimeError("Failed to allocate output buffer")
229
+
230
+ try:
231
+ result = self._derive_public_key(self._store, seed_ptr, 32, out_ptr)
232
+ if result != 0:
233
+ raise WasmRuntimeError(f"derive_public_key failed with code {result}")
234
+
235
+ return self._read_bytes(out_ptr, 32)
236
+ finally:
237
+ self._free(seed_ptr, 32)
238
+ self._free(out_ptr, 32)
239
+
240
+ def get_public_key_hex(self, seed: bytes) -> str:
241
+ """
242
+ Get hex-encoded public key from a 32-byte seed.
243
+
244
+ Args:
245
+ seed: 32-byte Ed25519 signing seed
246
+
247
+ Returns:
248
+ 64-character hex string of public key
249
+
250
+ Raises:
251
+ WasmRuntimeError: If derivation fails
252
+ ValueError: If seed is not 32 bytes
253
+ """
254
+ if len(seed) != 32:
255
+ raise ValueError("Seed must be exactly 32 bytes")
256
+
257
+ seed_ptr = self._write_bytes(seed)
258
+ out_ptr = self._alloc(self._store, 64)
259
+ if out_ptr == 0:
260
+ self._free(seed_ptr, 32)
261
+ raise WasmRuntimeError("Failed to allocate output buffer")
262
+
263
+ try:
264
+ result = self._get_public_key_hex(self._store, seed_ptr, 32, out_ptr)
265
+ if result != 0:
266
+ raise WasmRuntimeError(f"get_public_key_hex failed with code {result}")
267
+
268
+ return self._read_bytes(out_ptr, 64).decode("ascii")
269
+ finally:
270
+ self._free(seed_ptr, 32)
271
+ self._free(out_ptr, 64)
272
+
273
+ def sha256(self, data: bytes) -> bytes:
274
+ """
275
+ Compute SHA-256 hash.
276
+
277
+ Args:
278
+ data: Input bytes to hash
279
+
280
+ Returns:
281
+ 32-byte hash
282
+
283
+ Raises:
284
+ WasmRuntimeError: If hashing fails
285
+ """
286
+ data_ptr = self._write_bytes(data)
287
+ out_ptr = self._alloc(self._store, 32)
288
+ if out_ptr == 0:
289
+ self._free(data_ptr, len(data))
290
+ raise WasmRuntimeError("Failed to allocate output buffer")
291
+
292
+ try:
293
+ result = self._sha256(self._store, data_ptr, len(data), out_ptr)
294
+ if result != 0:
295
+ raise WasmRuntimeError(f"sha256 failed with code {result}")
296
+
297
+ return self._read_bytes(out_ptr, 32)
298
+ finally:
299
+ self._free(data_ptr, len(data))
300
+ self._free(out_ptr, 32)
301
+
302
+ def hash_canonical_json(self, json_str: str) -> bytes:
303
+ """
304
+ Hash a JSON string using RFC 8785 canonical JSON + SHA-256.
305
+
306
+ Args:
307
+ json_str: JSON string to canonicalize and hash
308
+
309
+ Returns:
310
+ 32-byte hash
311
+
312
+ Raises:
313
+ WasmRuntimeError: If hashing fails
314
+ """
315
+ json_bytes = json_str.encode("utf-8")
316
+ json_ptr = self._write_bytes(json_bytes)
317
+ out_ptr = self._alloc(self._store, 32)
318
+ if out_ptr == 0:
319
+ self._free(json_ptr, len(json_bytes))
320
+ raise WasmRuntimeError("Failed to allocate output buffer")
321
+
322
+ try:
323
+ result = self._hash_canonical_json(
324
+ self._store, json_ptr, len(json_bytes), out_ptr
325
+ )
326
+ if result != 0:
327
+ raise WasmRuntimeError(
328
+ f"hash_canonical_json failed with code {result}"
329
+ )
330
+
331
+ return self._read_bytes(out_ptr, 32)
332
+ finally:
333
+ self._free(json_ptr, len(json_bytes))
334
+ self._free(out_ptr, 32)
335
+
336
+ def ed25519_sign(self, seed: bytes, message: bytes) -> bytes:
337
+ """
338
+ Sign a message with Ed25519.
339
+
340
+ Args:
341
+ seed: 32-byte signing seed
342
+ message: Message bytes to sign
343
+
344
+ Returns:
345
+ 64-byte signature
346
+
347
+ Raises:
348
+ WasmRuntimeError: If signing fails
349
+ ValueError: If seed is not 32 bytes
350
+ """
351
+ if len(seed) != 32:
352
+ raise ValueError("Seed must be exactly 32 bytes")
353
+
354
+ seed_ptr = self._write_bytes(seed)
355
+ msg_ptr = self._write_bytes(message)
356
+ out_ptr = self._alloc(self._store, 64)
357
+ if out_ptr == 0:
358
+ self._free(seed_ptr, 32)
359
+ self._free(msg_ptr, len(message))
360
+ raise WasmRuntimeError("Failed to allocate output buffer")
361
+
362
+ try:
363
+ result = self._ed25519_sign(
364
+ self._store, seed_ptr, 32, msg_ptr, len(message), out_ptr
365
+ )
366
+ if result != 0:
367
+ raise WasmRuntimeError(f"ed25519_sign failed with code {result}")
368
+
369
+ return self._read_bytes(out_ptr, 64)
370
+ finally:
371
+ self._free(seed_ptr, 32)
372
+ self._free(msg_ptr, len(message))
373
+ self._free(out_ptr, 64)
374
+
375
+ def ed25519_verify(
376
+ self, public_key: bytes, message: bytes, signature: bytes
377
+ ) -> bool:
378
+ """
379
+ Verify an Ed25519 signature.
380
+
381
+ Args:
382
+ public_key: 32-byte public key
383
+ message: Message bytes that were signed
384
+ signature: 64-byte signature to verify
385
+
386
+ Returns:
387
+ True if signature is valid, False otherwise
388
+
389
+ Raises:
390
+ ValueError: If public_key or signature have wrong length
391
+ """
392
+ if len(public_key) != 32:
393
+ raise ValueError("Public key must be exactly 32 bytes")
394
+ if len(signature) != 64:
395
+ raise ValueError("Signature must be exactly 64 bytes")
396
+
397
+ pubkey_ptr = self._write_bytes(public_key)
398
+ msg_ptr = self._write_bytes(message)
399
+ sig_ptr = self._write_bytes(signature)
400
+
401
+ try:
402
+ result = self._ed25519_verify(
403
+ self._store, pubkey_ptr, 32, msg_ptr, len(message), sig_ptr, 64
404
+ )
405
+ return result == 0
406
+ finally:
407
+ self._free(pubkey_ptr, 32)
408
+ self._free(msg_ptr, len(message))
409
+ self._free(sig_ptr, 64)
410
+
411
+ def sign_attestation_json(self, seed: bytes, attestation_json: str) -> str:
412
+ """
413
+ Sign an attestation JSON and return SignedAttestation JSON.
414
+
415
+ Args:
416
+ seed: 32-byte signing seed
417
+ attestation_json: JSON string of attestation payload
418
+
419
+ Returns:
420
+ JSON string of SignedAttestation with payload and signature
421
+
422
+ Raises:
423
+ WasmRuntimeError: If signing fails
424
+ ValueError: If seed is not 32 bytes
425
+ """
426
+ if len(seed) != 32:
427
+ raise ValueError("Seed must be exactly 32 bytes")
428
+
429
+ json_bytes = attestation_json.encode("utf-8")
430
+ seed_ptr = self._write_bytes(seed)
431
+ json_ptr = self._write_bytes(json_bytes)
432
+
433
+ # Allocate generous output buffer (input + base64 sig + JSON wrapper)
434
+ out_cap = len(json_bytes) + 200
435
+ out_ptr = self._alloc(self._store, out_cap)
436
+ out_len_ptr = self._alloc(self._store, 8) # size_t on wasm32 is 4 bytes, but use 8 for safety
437
+
438
+ if out_ptr == 0 or out_len_ptr == 0:
439
+ self._free(seed_ptr, 32)
440
+ self._free(json_ptr, len(json_bytes))
441
+ self._free(out_ptr, out_cap)
442
+ self._free(out_len_ptr, 8)
443
+ raise WasmRuntimeError("Failed to allocate output buffer")
444
+
445
+ try:
446
+ result = self._sign_attestation_json(
447
+ self._store,
448
+ seed_ptr,
449
+ 32,
450
+ json_ptr,
451
+ len(json_bytes),
452
+ out_ptr,
453
+ out_cap,
454
+ out_len_ptr,
455
+ )
456
+ if result != 0:
457
+ raise WasmRuntimeError(
458
+ f"sign_attestation_json failed with code {result}"
459
+ )
460
+
461
+ # Read output length (4 bytes for wasm32 usize)
462
+ out_len_bytes = self._read_bytes(out_len_ptr, 4)
463
+ out_len = int.from_bytes(out_len_bytes, "little")
464
+
465
+ # Read output JSON
466
+ return self._read_bytes(out_ptr, out_len).decode("utf-8")
467
+ finally:
468
+ self._free(seed_ptr, 32)
469
+ self._free(json_ptr, len(json_bytes))
470
+ self._free(out_ptr, out_cap)
471
+ self._free(out_len_ptr, 8)
472
+
473
+
474
+ def sign_offline_attestation(
475
+ signing_seed: bytes,
476
+ payload_hash: str,
477
+ service_id: str,
478
+ operation_type: str,
479
+ timestamp_ms: int,
480
+ ) -> dict[str, Any]:
481
+ """
482
+ Create and sign an offline attestation.
483
+
484
+ This is a convenience function that builds the attestation structure
485
+ and signs it using the WASM runtime.
486
+
487
+ Args:
488
+ signing_seed: 32-byte Ed25519 signing seed
489
+ payload_hash: SHA-256 hash of input+output (64-char hex string)
490
+ service_id: Service identifier
491
+ operation_type: Type of operation (e.g., "completion")
492
+ timestamp_ms: Timestamp in milliseconds
493
+
494
+ Returns:
495
+ Dict with 'payload' and 'signature' keys (SignedAttestation structure)
496
+
497
+ Raises:
498
+ WasmRuntimeError: If signing fails
499
+ ValueError: If signing_seed is not 32 bytes
500
+ """
501
+ import json
502
+
503
+ runtime = WasmRuntime.get_instance()
504
+
505
+ # Build a minimal attestation structure for offline use
506
+ # This is simpler than AttestationL0 since we don't need DPRF sampling
507
+ attestation = {
508
+ "version": 1,
509
+ "serviceId": service_id,
510
+ "operationType": operation_type,
511
+ "payloadHash": payload_hash,
512
+ "timestampMs": str(timestamp_ms),
513
+ "mode": "offline",
514
+ }
515
+
516
+ attestation_json = json.dumps(attestation, separators=(",", ":"), sort_keys=True)
517
+ signed_json = runtime.sign_attestation_json(signing_seed, attestation_json)
518
+
519
+ return json.loads(signed_json)